authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-06 13:16:26-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 13:16:26-07:00
log3dfe373fddb966101371805b0b102ae5af75b15b
treed5d425757bdcafb90e1de890e3f0f93dfa134304
parent1eae9c2efec43b1d27e60d967001f5721c4a0429
signature Signed by PGP key B5690EEEBB952194

fix(web): diff panel keeps the chosen scope while a turn runs (#16571)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

5 files changed, 107 insertions(+), 5 deletions(-)

apps/web/src/components/ChatView.logic.test.ts+20
...@@ -1786,6 +1786,26 @@ describe("proactive completed diff guard", () => {...@@ -1786,6 +1786,26 @@ describe("proactive completed diff guard", () => {
1786 }),1786 }),
1787 ).toBe("ignore");1787 ).toBe("ignore");
1788 });1788 });
1789
1790 it("leaves an already open diff and its chosen scope alone", () => {
1791 const largeCheckpoint = {
1792 status: "ready",
1793 files: Array.from({ length: 3 }, (_, index) => ({
1794 path: `src/app-${index}.ts`,
1795 kind: "modified" as const,
1796 additions: 20,
1797 deletions: 0,
1798 })),
1799 } satisfies Pick<TurnDiffSummary, "status" | "files">;
1800
1801 expect(
1802 resolveProactiveTurnDiffAction({
1803 checkpoint: largeCheckpoint,
1804 isGitRepo: true,
1805 activeSurfaceKind: "diff",
1806 }),
1807 ).toBe("ignore");
1808 });
1789});1809});
17901810
1791describe("shouldRefocusComposerOnWindowFocus", () => {1811describe("shouldRefocusComposerOnWindowFocus", () => {
apps/web/src/components/ChatView.logic.ts+4-1
...@@ -185,7 +185,10 @@ export function resolveProactiveTurnDiffAction(input: {...@@ -185,7 +185,10 @@ export function resolveProactiveTurnDiffAction(input: {
185 isGitRepo: boolean | undefined;185 isGitRepo: boolean | undefined;
186 activeSurfaceKind: RightPanelSurface["kind"] | null;186 activeSurfaceKind: RightPanelSurface["kind"] | null;
187}): "defer" | "ignore" | "open" {187}): "defer" | "ignore" | "open" {
188 if (input.activeSurfaceKind === "pull-request") return "ignore";188 // An open diff already shows the work; reopening it would reset the chosen scope.
189 if (input.activeSurfaceKind === "pull-request" || input.activeSurfaceKind === "diff") {
190 return "ignore";
191 }
189 if (input.checkpoint === undefined || input.checkpoint.status === "missing") return "defer";192 if (input.checkpoint === undefined || input.checkpoint.status === "missing") return "defer";
190 if (input.isGitRepo === undefined) return "defer";193 if (input.isGitRepo === undefined) return "defer";
191 if (194 if (
apps/web/src/components/ChatView.tsx+2-4
...@@ -173,6 +173,7 @@ import { AsyncResult } from "effect/reactivity";...@@ -173,6 +173,7 @@ import { AsyncResult } from "effect/reactivity";
173import { isElectron } from "../env";173import { isElectron } from "../env";
174import { readLocalApi } from "../localApi";174import { readLocalApi } from "../localApi";
175import { useDiffPanelStore } from "../diffPanelStore";175import { useDiffPanelStore } from "../diffPanelStore";
176import { useActiveThreadRef } from "../hooks/useActiveThreadRef";
176import {177import {
177 type ComposerSubmissionIntent,178 type ComposerSubmissionIntent,
178 collapseExpandedComposerCursor,179 collapseExpandedComposerCursor,
...@@ -2192,10 +2193,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -2192,10 +2193,7 @@ export default function ChatView(props: ChatViewProps) {
2192 }2193 }
2193 return labels;2194 return labels;
2194 }, [activeThreadKnownSessions]);2195 }, [activeThreadKnownSessions]);
2195 const activeThreadRef = useMemo(2196 const activeThreadRef = useActiveThreadRef(activeThread);
2196 () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null),
2197 [activeThread],
2198 );
2199 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;2197 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
2200 const activeEnvironmentServerBrowser = useEnvironmentSupportsServerBrowser(2198 const activeEnvironmentServerBrowser = useEnvironmentSupportsServerBrowser(
2201 activeThreadRef?.environmentId ?? null,2199 activeThreadRef?.environmentId ?? null,
apps/web/src/hooks/useActiveThreadRef.test.tsx created+61
...@@ -0,0 +1,61 @@
1import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts";
2import { act } from "react";
3import { create, type ReactTestRenderer } from "react-test-renderer";
4import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
5
6import { useActiveThreadRef } from "./useActiveThreadRef";
7
8type ThreadLike = { environmentId: EnvironmentId; id: ThreadId; status: string } | null;
9
10let renderer: ReactTestRenderer | null = null;
11let observed: Array<ScopedThreadRef | null> = [];
12
13function Probe({ thread }: { thread: ThreadLike }) {
14 observed.push(useActiveThreadRef(thread));
15 return null;
16}
17
18const thread = (id: string, status = "idle"): ThreadLike => ({
19 environmentId: "env-1" as EnvironmentId,
20 id: id as ThreadId,
21 status,
22});
23
24async function render(value: ThreadLike) {
25 await act(() => {
26 if (renderer) renderer.update(<Probe thread={value} />);
27 else renderer = create(<Probe thread={value} />);
28 });
29 return observed.at(-1);
30}
31
32beforeEach(() => {
33 observed = [];
34 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
35});
36
37afterEach(async () => {
38 await act(() => renderer?.unmount());
39 renderer = null;
40 vi.unstubAllGlobals();
41});
42
43describe("useActiveThreadRef", () => {
44 it("keeps the same ref while the thread object is replaced during a run", async () => {
45 const first = await render(thread("t1", "running"));
46 const afterUpdate = await render(thread("t1", "waiting"));
47
48 expect(first).toEqual({ environmentId: "env-1", threadId: "t1" });
49 expect(afterUpdate).toBe(first);
50 });
51
52 it("returns a new ref when the thread changes or goes away", async () => {
53 const first = await render(thread("t1"));
54 const switched = await render(thread("t2"));
55 const gone = await render(null);
56
57 expect(switched).not.toBe(first);
58 expect(switched).toEqual({ environmentId: "env-1", threadId: "t2" });
59 expect(gone).toBeNull();
60 });
61});
apps/web/src/hooks/useActiveThreadRef.ts created+20
...@@ -0,0 +1,20 @@
1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts";
3import { useMemo } from "react";
4
5/**
6 * The scoped ref of the thread a view shows, stable for as long as it shows that thread.
7 * Takes the thread object but keys on its ids: the shell changes identity on every update
8 * during a run, and effects keyed on this ref must not re-run for that.
9 */
10export function useActiveThreadRef(
11 thread: { readonly environmentId: EnvironmentId; readonly id: ThreadId } | null | undefined,
12): ScopedThreadRef | null {
13 const environmentId = thread?.environmentId ?? null;
14 const threadId = thread?.id ?? null;
15 return useMemo(
16 () =>
17 environmentId !== null && threadId !== null ? scopeThreadRef(environmentId, threadId) : null,
18 [environmentId, threadId],
19 );
20}