diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 20816d6935..503dc48e99 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2,6 +2,7 @@ import "../index.css"; import { + CheckpointRef, EventId, ORCHESTRATION_WS_METHODS, ORCHESTRATION_WS_CHANNELS, @@ -11,6 +12,7 @@ import { type ProjectId, type ServerConfig, type ThreadId, + type TurnId, type WsWelcomePayload, WS_CHANNELS, WS_METHODS, @@ -284,6 +286,85 @@ function createSnapshotForTargetUser(options: { }; } +function createSnapshotWithTurnDiffCheckpoints(): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-diff-panel-latest-chip-test" as MessageId, + targetText: "diff panel latest chip test", + }); + const threads = [...snapshot.threads]; + const threadIndex = threads.findIndex((thread) => thread.id === THREAD_ID); + + if (threadIndex < 0) { + return snapshot; + } + + const thread = threads[threadIndex]; + if (!thread) { + return snapshot; + } + + threads[threadIndex] = { + ...thread, + checkpoints: [ + { + turnId: "turn-1" as TurnId, + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/checkpoint-1"), + status: "ready" as const, + files: [], + assistantMessageId: null, + completedAt: isoAt(120), + }, + { + turnId: "turn-2" as TurnId, + checkpointTurnCount: 2, + checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/checkpoint-2"), + status: "ready" as const, + files: [], + assistantMessageId: null, + completedAt: isoAt(240), + }, + ], + }; + + return { + ...snapshot, + threads, + }; +} + +function appendCheckpointToStore( + state: ReturnType, + checkpoint: { + turnId: TurnId; + checkpointTurnCount: number; + checkpointRef: string; + completedAt: string; + }, +): ReturnType { + return { + ...state, + threads: state.threads.map((thread) => + thread.id === THREAD_ID + ? { + ...thread, + turnDiffSummaries: [ + ...thread.turnDiffSummaries, + { + turnId: checkpoint.turnId, + checkpointTurnCount: checkpoint.checkpointTurnCount, + checkpointRef: CheckpointRef.makeUnsafe(checkpoint.checkpointRef), + status: "ready" as const, + files: [], + completedAt: checkpoint.completedAt, + }, + ], + } + : thread, + ), + }; +} + function buildFixture(snapshot: OrchestrationReadModel): TestFixture { return { snapshot, @@ -687,6 +768,22 @@ async function waitForURL( return pathname; } +async function waitForSearch( + router: ReturnType, + predicate: (search: Record) => boolean, + errorMessage: string, +): Promise> { + let search: Record = {}; + await vi.waitFor( + () => { + search = router.state.location.search as Record; + expect(predicate(search), errorMessage).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); + return search; +} + async function waitForComposerEditor(): Promise { return waitForElement( () => document.querySelector('[contenteditable="true"]'), @@ -854,6 +951,7 @@ async function mountChatView(options: { viewport: ViewportSpec; snapshot: OrchestrationReadModel; configureFixture?: (fixture: TestFixture) => void; + initialEntry?: string; resolveRpc?: (body: WsRequestEnvelope["body"]) => unknown | undefined; }): Promise { fixture = buildFixture(options.snapshot); @@ -873,7 +971,7 @@ async function mountChatView(options: { const router = getRouter( createMemoryHistory({ - initialEntries: [`/${THREAD_ID}`], + initialEntries: [options.initialEntry ?? `/${THREAD_ID}`], }), ); @@ -964,6 +1062,112 @@ describe("ChatView timeline estimator parity (full app)", () => { document.body.innerHTML = ""; }); + it("selects the newest checkpoint when the Latest diff chip is clicked", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithTurnDiffCheckpoints(), + initialEntry: `/${THREAD_ID}?diff=1`, + }); + + try { + const latestTurnButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Latest", + ) as HTMLButtonElement | null, + "Unable to find the Latest diff chip.", + ); + + await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find((button) => + button.textContent?.includes("Turn 2"), + ) as HTMLButtonElement | null, + "Unable to find the grouped latest turn chip.", + ); + + expect(mounted.router.state.location.search.diffSelection).toBeUndefined(); + expect(mounted.router.state.location.search.diffTurnId).toBeUndefined(); + + latestTurnButton.click(); + + await waitForSearch( + mounted.router, + (search) => + search.diff === "1" && + search.diffSelection === "latest" && + search.diffTurnId === undefined, + "Latest diff chip should switch the diff panel into follow-latest mode.", + ); + + expect( + document.querySelectorAll("[data-turn-chip-selected='true']"), + "Latest mode should only mark a single chip as selected.", + ).toHaveLength(1); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps following the newest checkpoint while Latest mode is selected", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithTurnDiffCheckpoints(), + initialEntry: `/${THREAD_ID}?diff=1&diffSelection=latest`, + }); + + try { + await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Latest", + ) as HTMLButtonElement | null, + "Unable to find the initial Latest diff chip state.", + ); + + await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find((button) => + button.textContent?.includes("Turn 2"), + ) as HTMLButtonElement | null, + "Unable to find the initial grouped latest turn chip.", + ); + + useStore.setState((state) => + appendCheckpointToStore(state, { + turnId: "turn-3" as TurnId, + checkpointTurnCount: 3, + checkpointRef: "refs/t3/checkpoints/checkpoint-3", + completedAt: isoAt(360), + }), + ); + + await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find((button) => + button.textContent?.includes("Turn 3"), + ) as HTMLButtonElement | null, + "Latest diff chip should update to the newest checkpoint turn.", + ); + + await waitForSearch( + mounted.router, + (search) => + search.diff === "1" && + search.diffSelection === "latest" && + search.diffTurnId === undefined, + "Latest mode should keep routing state in follow-latest mode after new checkpoints arrive.", + ); + + expect( + document.querySelectorAll("[data-turn-chip-selected='true']"), + "Latest mode should still have exactly one selected chip after new checkpoints arrive.", + ).toHaveLength(1); + } finally { + await mounted.cleanup(); + } + }); + it.each(TEXT_VIEWPORT_MATRIX)( "keeps long user message estimate close at the $name viewport", async (viewport) => { diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index fadb8cb69d..139661170d 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -207,11 +207,17 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { }), [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], ); + const latestTurnSummary = orderedTurnDiffSummaries[0] ?? null; + const olderTurnDiffSummaries = latestTurnSummary + ? orderedTurnDiffSummaries.slice(1) + : orderedTurnDiffSummaries; - const selectedTurnId = diffSearch.diffTurnId ?? null; + const isLatestTurnSelected = diffSearch.diffSelection === "latest"; + const selectedTurnId = isLatestTurnSelected ? null : (diffSearch.diffTurnId ?? null); const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null; - const selectedTurn = - selectedTurnId === null + const selectedTurn = isLatestTurnSelected + ? (latestTurnSummary ?? undefined) + : selectedTurnId === null ? undefined : (orderedTurnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? orderedTurnDiffSummaries[0]); @@ -284,6 +290,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { : null; const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff; + const isPinnedLatestTurnSelected = + !isLatestTurnSelected && + latestTurnSummary !== null && + selectedTurn?.turnId === latestTurnSummary.turnId; const hasResolvedPatch = typeof selectedPatch === "string"; const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0; const renderablePatch = useMemo( @@ -353,6 +363,17 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { }, }); }; + const selectLatestTurn = () => { + if (!activeThread || !latestTurnSummary) return; + void navigate({ + to: "/$threadId", + params: { threadId: activeThread.id }, + search: (previous) => { + const rest = stripDiffSearchParams(previous); + return { ...rest, diff: "1", diffSelection: "latest" }; + }, + }); + }; const updateTurnStripScrollState = useCallback(() => { const element = turnStripRef.current; if (!element) { @@ -404,7 +425,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { return () => { window.cancelAnimationFrame(frameId); }; - }, [orderedTurnDiffSummaries, selectedTurnId, updateTurnStripScrollState]); + }, [isLatestTurnSelected, orderedTurnDiffSummaries, selectedTurnId, updateTurnStripScrollState]); useEffect(() => { const element = turnStripRef.current; @@ -412,7 +433,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const selectedChip = element.querySelector("[data-turn-chip-selected='true']"); selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); - }, [selectedTurn?.turnId, selectedTurnId]); + }, [isLatestTurnSelected, selectedTurn?.turnId, selectedTurnId]); const headerRow = ( <> @@ -460,12 +481,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { type="button" className="shrink-0 rounded-md" onClick={selectWholeConversation} - data-turn-chip-selected={selectedTurnId === null} + data-turn-chip-selected={selectedTurnId === null && !isLatestTurnSelected} >
All turns
- {orderedTurnDiffSummaries.map((summary) => ( + {latestTurnSummary ? ( +
+ +
+ +
+ ) : null} + {olderTurnDiffSummaries.map((summary) => (