@@ -209,7 +221,7 @@ export function MonacoCodeEditor(props: MonacoCodeEditorProps) {
onDelete={onDelete}
onToggleMarkdownPreview={onToggleMarkdownPreview}
/>
-
+
}
/>
+ {walkthroughRange ? (
+
+ ) : null}
{state.floatingButtonPos && !state.formZoneRange && (
diff --git a/apps/web/components/task/mobile/session-mobile-layout.test.tsx b/apps/web/components/task/mobile/session-mobile-layout.test.tsx
index 0ef5191552..2e141ac6af 100644
--- a/apps/web/components/task/mobile/session-mobile-layout.test.tsx
+++ b/apps/web/components/task/mobile/session-mobile-layout.test.tsx
@@ -29,6 +29,7 @@ const OTHER_FILE: OpenFileTab = {
};
const CHAT_LINK_PATH = "src/chat-link.ts";
+const REPO = "frontend";
function renderHandlers(initialSid: string | null = "s1") {
const handlePanelChange = vi.fn();
@@ -63,6 +64,7 @@ describe("useMobilePanelHandlers", () => {
CHAT_LINK_PATH,
expect.any(Function),
expect.any(Function),
+ { repo: undefined, signal: expect.objectContaining({ aborted: false }) },
);
const openFile = fetchAndOpenFileMock.mock.calls[0]?.[2] as (file: OpenFileTab) => void;
@@ -72,6 +74,19 @@ describe("useMobilePanelHandlers", () => {
expect(handlePanelChange).toHaveBeenCalledWith("files");
});
+ it("passes repo through when opening a walkthrough file from mobile", () => {
+ const { result } = renderHandlers();
+ act(() => result.current.handleOpenFileFromChat(CHAT_LINK_PATH, REPO));
+
+ expect(fetchAndOpenFileMock).toHaveBeenCalledWith(
+ "s1",
+ CHAT_LINK_PATH,
+ expect.any(Function),
+ expect.any(Function),
+ { repo: REPO, signal: expect.objectContaining({ aborted: false }) },
+ );
+ });
+
it("handleOpenFileFromChat no-ops when no active session", () => {
const { result } = renderHandlers(null);
act(() => result.current.handleOpenFileFromChat(CHAT_LINK_PATH));
@@ -116,6 +131,7 @@ describe("useMobilePanelHandlers", () => {
CHAT_LINK_PATH,
expect.any(Function),
expect.any(Function),
+ { repo: undefined, signal: expect.objectContaining({ aborted: false }) },
);
// Simulate session switch before the async callback fires
@@ -139,3 +155,31 @@ describe("useMobilePanelHandlers", () => {
expect(result.current.selectedFile).toEqual(OTHER_FILE);
});
});
+
+describe("useMobilePanelHandlers request cancellation", () => {
+ beforeEach(() => {
+ fetchAndOpenFileMock.mockReset();
+ });
+
+ it("aborts stale chat file requests when a newer one starts", () => {
+ const { result } = renderHandlers();
+ act(() => result.current.handleOpenFileFromChat(CHAT_LINK_PATH));
+ const firstOptions = fetchAndOpenFileMock.mock.calls[0]?.[4] as { signal: AbortSignal };
+
+ act(() => result.current.handleOpenFileFromChat("src/newer.ts"));
+ const secondOptions = fetchAndOpenFileMock.mock.calls[1]?.[4] as { signal: AbortSignal };
+
+ expect(firstOptions.signal.aborted).toBe(true);
+ expect(secondOptions.signal.aborted).toBe(false);
+ });
+
+ it("aborts stale chat file requests when the session changes", () => {
+ const { result, rerender } = renderHandlers();
+ act(() => result.current.handleOpenFileFromChat(CHAT_LINK_PATH));
+ const firstOptions = fetchAndOpenFileMock.mock.calls[0]?.[4] as { signal: AbortSignal };
+
+ rerender({ sid: "s2" });
+
+ expect(firstOptions.signal.aborted).toBe(true);
+ });
+});
diff --git a/apps/web/components/task/mobile/session-mobile-layout.tsx b/apps/web/components/task/mobile/session-mobile-layout.tsx
index a0218c4531..665121b0d2 100644
--- a/apps/web/components/task/mobile/session-mobile-layout.tsx
+++ b/apps/web/components/task/mobile/session-mobile-layout.tsx
@@ -1,6 +1,6 @@
"use client";
-import { memo, useCallback, useEffect, useRef, useState } from "react";
+import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { SessionMobileTopBar } from "./session-mobile-top-bar";
import { SessionMobileBottomNav } from "./session-mobile-bottom-nav";
import { SessionTaskSwitcherSheet } from "./session-task-switcher-sheet";
@@ -9,6 +9,7 @@ import { TaskChatPanel } from "../task-chat-panel";
import { TaskPlanPanel } from "../task-plan-panel";
import { MobileChangesPanel } from "./mobile-changes-panel";
import { ReviewDialog } from "@/components/review/review-dialog";
+import { WalkthroughOverlay } from "@/components/review/walkthrough-overlay";
import { useReviewDialog } from "../use-review-dialog";
import { TaskFilesPanel } from "../task-files-panel";
import { PassthroughToolbar } from "../passthrough-toolbar";
@@ -51,7 +52,7 @@ function MobileChatPanelContent({
activeTaskId: string | null;
isPassthroughMode: boolean;
effectiveSessionId: string | null;
- onOpenFile: (path: string) => void;
+ onOpenFile: (path: string, repo?: string) => void;
}) {
if (!activeTaskId) {
return (
@@ -233,23 +234,34 @@ function MobileTopBarSticky(props: MobileTopBarStickyProps) {
function MobileReviewDialogMount({
sessionId,
review,
+ activeTaskId,
+ onSelectWalkthroughFile,
}: {
sessionId: string | null;
review: ReturnType
;
+ activeTaskId: string | null;
+ onSelectWalkthroughFile: (path: string, repo?: string) => void;
}) {
if (!sessionId) return null;
return (
-
+ <>
+
+
+ >
);
}
@@ -264,6 +276,7 @@ export function useMobilePanelHandlers({
const [selectedFile, setSelectedFile] = useState(null);
const [trackedSessionId, setTrackedSessionId] = useState(effectiveSessionId);
const latestRequestIdRef = useRef(0);
+ const openFileAbortRef = useRef(null);
// Reset viewer when switching sessions — adjust state during render per
// https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
@@ -272,24 +285,44 @@ export function useMobilePanelHandlers({
setSelectedFile(null);
}
- useEffect(() => {
+ useLayoutEffect(() => {
latestRequestIdRef.current += 1;
+ openFileAbortRef.current?.abort();
+ openFileAbortRef.current = null;
}, [effectiveSessionId]);
+ useEffect(
+ () => () => {
+ openFileAbortRef.current?.abort();
+ openFileAbortRef.current = null;
+ },
+ [],
+ );
+
const handleOpenFileFromChat = useCallback(
- (path: string) => {
+ (path: string, repo?: string) => {
if (!effectiveSessionId) return;
const requestId = (latestRequestIdRef.current += 1);
- void fetchAndOpenFile(
- effectiveSessionId,
- path,
- (file) => {
- if (requestId !== latestRequestIdRef.current) return;
- setSelectedFile(file);
- handlePanelChange("files");
- },
- toast,
- );
+ openFileAbortRef.current?.abort();
+ const controller = new AbortController();
+ openFileAbortRef.current = controller;
+ void Promise.resolve(
+ fetchAndOpenFile(
+ effectiveSessionId,
+ path,
+ (file) => {
+ if (requestId !== latestRequestIdRef.current || controller.signal.aborted) return;
+ setSelectedFile(file);
+ handlePanelChange("files");
+ },
+ toast,
+ { repo, signal: controller.signal },
+ ),
+ ).finally(() => {
+ if (openFileAbortRef.current === controller) {
+ openFileAbortRef.current = null;
+ }
+ });
},
[effectiveSessionId, handlePanelChange, toast],
);
@@ -297,6 +330,8 @@ export function useMobilePanelHandlers({
const handleOpenFile = useCallback(
(file: OpenFileTab) => {
latestRequestIdRef.current += 1;
+ openFileAbortRef.current?.abort();
+ openFileAbortRef.current = null;
setSelectedFile(file);
handlePanelChange("files");
},
@@ -306,6 +341,8 @@ export function useMobilePanelHandlers({
const handlePanelChangeAndClearSheet = useCallback(
(panel: MobileSessionPanel) => {
latestRequestIdRef.current += 1;
+ openFileAbortRef.current?.abort();
+ openFileAbortRef.current = null;
setSelectedFile(null);
handlePanelChange(panel);
},
@@ -415,7 +452,12 @@ export const SessionMobileLayout = memo(function SessionMobileLayout({
workflowId={workflowId}
/>
-
+
);
});
diff --git a/apps/web/components/task/passthrough-chat-composer.test.ts b/apps/web/components/task/passthrough-chat-composer.test.ts
index e6ce47d45f..45b4f2e213 100644
--- a/apps/web/components/task/passthrough-chat-composer.test.ts
+++ b/apps/web/components/task/passthrough-chat-composer.test.ts
@@ -48,9 +48,11 @@ function panelState(overrides: Record