diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index cc0c7dd35753..bf8d32f1daf3 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1786,6 +1786,26 @@ describe("proactive completed diff guard", () => { }), ).toBe("ignore"); }); + + it("leaves an already open diff and its chosen scope alone", () => { + const largeCheckpoint = { + status: "ready", + files: Array.from({ length: 3 }, (_, index) => ({ + path: `src/app-${index}.ts`, + kind: "modified" as const, + additions: 20, + deletions: 0, + })), + } satisfies Pick; + + expect( + resolveProactiveTurnDiffAction({ + checkpoint: largeCheckpoint, + isGitRepo: true, + activeSurfaceKind: "diff", + }), + ).toBe("ignore"); + }); }); describe("shouldRefocusComposerOnWindowFocus", () => { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index dcbd4ab2f343..f36e54d334fe 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -185,7 +185,10 @@ export function resolveProactiveTurnDiffAction(input: { isGitRepo: boolean | undefined; activeSurfaceKind: RightPanelSurface["kind"] | null; }): "defer" | "ignore" | "open" { - if (input.activeSurfaceKind === "pull-request") return "ignore"; + // An open diff already shows the work; reopening it would reset the chosen scope. + if (input.activeSurfaceKind === "pull-request" || input.activeSurfaceKind === "diff") { + return "ignore"; + } if (input.checkpoint === undefined || input.checkpoint.status === "missing") return "defer"; if (input.isGitRepo === undefined) return "defer"; if ( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 068b69ce0632..89b74c063da4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -173,6 +173,7 @@ import { AsyncResult } from "effect/reactivity"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; import { useDiffPanelStore } from "../diffPanelStore"; +import { useActiveThreadRef } from "../hooks/useActiveThreadRef"; import { type ComposerSubmissionIntent, collapseExpandedComposerCursor, @@ -2192,10 +2193,7 @@ export default function ChatView(props: ChatViewProps) { } return labels; }, [activeThreadKnownSessions]); - const activeThreadRef = useMemo( - () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), - [activeThread], - ); + const activeThreadRef = useActiveThreadRef(activeThread); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const activeEnvironmentServerBrowser = useEnvironmentSupportsServerBrowser( activeThreadRef?.environmentId ?? null, diff --git a/apps/web/src/hooks/useActiveThreadRef.test.tsx b/apps/web/src/hooks/useActiveThreadRef.test.tsx new file mode 100644 index 000000000000..4afc4071f1bd --- /dev/null +++ b/apps/web/src/hooks/useActiveThreadRef.test.tsx @@ -0,0 +1,61 @@ +import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { useActiveThreadRef } from "./useActiveThreadRef"; + +type ThreadLike = { environmentId: EnvironmentId; id: ThreadId; status: string } | null; + +let renderer: ReactTestRenderer | null = null; +let observed: Array = []; + +function Probe({ thread }: { thread: ThreadLike }) { + observed.push(useActiveThreadRef(thread)); + return null; +} + +const thread = (id: string, status = "idle"): ThreadLike => ({ + environmentId: "env-1" as EnvironmentId, + id: id as ThreadId, + status, +}); + +async function render(value: ThreadLike) { + await act(() => { + if (renderer) renderer.update(); + else renderer = create(); + }); + return observed.at(-1); +} + +beforeEach(() => { + observed = []; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = null; + vi.unstubAllGlobals(); +}); + +describe("useActiveThreadRef", () => { + it("keeps the same ref while the thread object is replaced during a run", async () => { + const first = await render(thread("t1", "running")); + const afterUpdate = await render(thread("t1", "waiting")); + + expect(first).toEqual({ environmentId: "env-1", threadId: "t1" }); + expect(afterUpdate).toBe(first); + }); + + it("returns a new ref when the thread changes or goes away", async () => { + const first = await render(thread("t1")); + const switched = await render(thread("t2")); + const gone = await render(null); + + expect(switched).not.toBe(first); + expect(switched).toEqual({ environmentId: "env-1", threadId: "t2" }); + expect(gone).toBeNull(); + }); +}); diff --git a/apps/web/src/hooks/useActiveThreadRef.ts b/apps/web/src/hooks/useActiveThreadRef.ts new file mode 100644 index 000000000000..9333f6f1ea4d --- /dev/null +++ b/apps/web/src/hooks/useActiveThreadRef.ts @@ -0,0 +1,20 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { useMemo } from "react"; + +/** + * The scoped ref of the thread a view shows, stable for as long as it shows that thread. + * Takes the thread object but keys on its ids: the shell changes identity on every update + * during a run, and effects keyed on this ref must not re-run for that. + */ +export function useActiveThreadRef( + thread: { readonly environmentId: EnvironmentId; readonly id: ThreadId } | null | undefined, +): ScopedThreadRef | null { + const environmentId = thread?.environmentId ?? null; + const threadId = thread?.id ?? null; + return useMemo( + () => + environmentId !== null && threadId !== null ? scopeThreadRef(environmentId, threadId) : null, + [environmentId, threadId], + ); +}