| author | |
| committer | |
| log | 3dfe373fddb966101371805b0b102ae5af75b15b |
| tree | d5d425757bdcafb90e1de890e3f0f93dfa134304 |
| parent | 1eae9c2efec43b1d27e60d967001f5721c4a0429 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | }); |
| 1790 | 1810 | ||
| 1791 | describe("shouldRefocusComposerOnWindowFocus", () => { | 1811 | describe("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"; |
| 173 | import { isElectron } from "../env"; | 173 | import { isElectron } from "../env"; |
| 174 | import { readLocalApi } from "../localApi"; | 174 | import { readLocalApi } from "../localApi"; |
| 175 | import { useDiffPanelStore } from "../diffPanelStore"; | 175 | import { useDiffPanelStore } from "../diffPanelStore"; |
| 176 | import { useActiveThreadRef } from "../hooks/useActiveThreadRef"; | ||
| 176 | import { | 177 | import { |
| 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 @@ | ||
| 1 | import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { act } from "react"; | ||
| 3 | import { create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import { useActiveThreadRef } from "./useActiveThreadRef"; | ||
| 7 | |||
| 8 | type ThreadLike = { environmentId: EnvironmentId; id: ThreadId; status: string } | null; | ||
| 9 | |||
| 10 | let renderer: ReactTestRenderer | null = null; | ||
| 11 | let observed: Array<ScopedThreadRef | null> = []; | ||
| 12 | |||
| 13 | function Probe({ thread }: { thread: ThreadLike }) { | ||
| 14 | observed.push(useActiveThreadRef(thread)); | ||
| 15 | return null; | ||
| 16 | } | ||
| 17 | |||
| 18 | const thread = (id: string, status = "idle"): ThreadLike => ({ | ||
| 19 | environmentId: "env-1" as EnvironmentId, | ||
| 20 | id: id as ThreadId, | ||
| 21 | status, | ||
| 22 | }); | ||
| 23 | |||
| 24 | async 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 | |||
| 32 | beforeEach(() => { | ||
| 33 | observed = []; | ||
| 34 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 35 | }); | ||
| 36 | |||
| 37 | afterEach(async () => { | ||
| 38 | await act(() => renderer?.unmount()); | ||
| 39 | renderer = null; | ||
| 40 | vi.unstubAllGlobals(); | ||
| 41 | }); | ||
| 42 | |||
| 43 | describe("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 @@ | ||
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | ||
| 2 | import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; | ||
| 3 | import { 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 | */ | ||
| 10 | export 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 | } | ||