From 6d3f72f3126b6186d31363943e2b4025b8c843f7 Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Sun, 14 Jun 2026 16:17:06 +0200 Subject: [PATCH 1/2] Improve turn start feedback --- .../web/src/components/ChatView.logic.test.ts | 4 +- apps/web/src/components/ChatView.logic.ts | 6 +-- .../chat/MessagesTimeline.browser.tsx | 42 +++++++++++++++++++ .../chat/MessagesTimeline.logic.test.ts | 12 ++++++ .../components/chat/MessagesTimeline.logic.ts | 19 ++++++++- .../src/components/chat/MessagesTimeline.tsx | 6 ++- 6 files changed, 78 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index bbb59fd6bb8..ca83ec281bc 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -823,7 +823,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(true); }); - it("clears local dispatch when the session changes without an observed running phase", () => { + it("keeps local dispatch when session metadata changes before the new turn starts", () => { const localDispatch = createLocalDispatchSnapshot({ id: ThreadId.make("thread-1"), environmentId: localEnvironmentId, @@ -860,6 +860,6 @@ describe("hasServerAcknowledgedLocalDispatch", () => { hasPendingUserInput: false, threadError: null, }), - ).toBe(true); + ).toBe(false); }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 0012bee256b..0a12b03d138 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -442,9 +442,5 @@ export function hasServerAcknowledgedLocalDispatch(input: { return true; } - return ( - latestTurnChanged || - input.localDispatch.sessionOrchestrationStatus !== (session?.orchestrationStatus ?? null) || - input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null) - ); + return latestTurnChanged && latestTurn !== null && latestTurn.startedAt !== null; } diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.tsx index 1d633f51d8a..b73048597d4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.tsx @@ -238,6 +238,48 @@ describe("MessagesTimeline", () => { } }); + it("labels locally dispatched work as starting until the provider turn begins", async () => { + const props = buildProps(); + const startedAt = new Date().toISOString(); + const screen = await render( + , + ); + + try { + await expect.element(page.getByText("Starting request for", { exact: false })).toBeVisible(); + + await screen.rerender( + , + ); + + await expect.element(page.getByText("Working for", { exact: false })).toBeVisible(); + } finally { + await screen.unmount(); + } + }); + it("starts long user messages collapsed by default", async () => { const screen = await render( { ]); const finalRow = rows.find((row) => row.id === "assistant-final-entry"); expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); + const workingRow = rows.find((row) => row.id === "working-indicator-row"); + expect(workingRow).toMatchObject({ + kind: "working", + createdAt: "2026-01-01T00:01:00Z", + phase: "starting", + }); }); it("does not fold the active in-progress turn", () => { @@ -727,6 +733,12 @@ describe("deriveMessagesTimelineRows", () => { "work-entry-1", "working-indicator-row", ]); + const workingRow = rows.find((row) => row.id === "working-indicator-row"); + expect(workingRow).toMatchObject({ + kind: "working", + createdAt: "2026-01-01T00:00:00Z", + phase: "running", + }); }); it("only shows assistant metadata on the terminal assistant message", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 416b37e4f51..4ff81fc45f8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -67,7 +67,12 @@ export type MessagesTimelineRow = createdAt: string; proposedPlan: ProposedPlan; } - | { kind: "working"; id: string; createdAt: string | null }; + | { + kind: "working"; + id: string; + createdAt: string | null; + phase: "starting" | "running"; + }; export interface StableMessagesTimelineRowsState { byId: Map; @@ -162,6 +167,15 @@ function deriveUnsettledTurnId(latestTurn: TimelineLatestTurn | null): TurnId | return isSettled ? null : latestTurn.turnId; } +function deriveWorkingPhase( + latestTurn: TimelineLatestTurn | null | undefined, +): "starting" | "running" { + if (latestTurn?.startedAt && latestTurn.completedAt === null && latestTurn.state === "running") { + return "running"; + } + return "starting"; +} + /** * Settled turns fold their commentary and tool activity behind a * "Worked for ..." row anchored at the turn's first foldable entry; the @@ -418,6 +432,7 @@ export function deriveMessagesTimelineRows(input: { kind: "working", id: "working-indicator-row", createdAt: input.activeTurnStartedAt, + phase: deriveWorkingPhase(input.latestTurn), }); } @@ -450,7 +465,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean switch (a.kind) { case "working": - return a.createdAt === (b as typeof a).createdAt; + return a.createdAt === (b as typeof a).createdAt && a.phase === (b as typeof a).phase; case "turn-fold": { const bf = b as typeof a; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 250eee4d698..0a46e96bc43 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -661,6 +661,8 @@ function ProposedPlanTimelineRow({ } function WorkingTimelineRow({ row }: { row: Extract }) { + const label = row.phase === "running" ? "Working" : "Starting request"; + return (
@@ -672,10 +674,10 @@ function WorkingTimelineRow({ row }: { row: Extract {row.createdAt ? ( <> - Working for + {label} for ) : ( - "Working..." + `${label}...` )}
From 74d6dc158fb4a022e7653823605d1e5356ed7f2c Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Sun, 14 Jun 2026 16:59:35 +0200 Subject: [PATCH 2/2] Fix turn start feedback continuity --- apps/web/src/components/ChatView.browser.tsx | 40 ++++++++++++ apps/web/src/components/ChatView.tsx | 42 ++++++++----- apps/web/src/localDispatchStore.test.ts | 64 ++++++++++++++++++++ apps/web/src/localDispatchStore.ts | 55 +++++++++++++++++ apps/web/src/session-logic.test.ts | 15 ++++- apps/web/src/session-logic.ts | 3 + 6 files changed, 202 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/localDispatchStore.test.ts create mode 100644 apps/web/src/localDispatchStore.ts diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 81b9c74231c..13ccf162d1a 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -61,6 +61,7 @@ import { } from "../lib/terminalContext"; import { isMacPlatform } from "../lib/utils"; import { __resetLocalApiForTests } from "../localApi"; +import { useLocalDispatchStore } from "../localDispatchStore"; import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; @@ -675,6 +676,34 @@ async function materializePromotedDraftThreadViaDomainEvent(threadId: ThreadId): sendShellThreadUpsert(threadId, { session: null }); } +async function materializePromotedDraftThreadWithUserMessageViaDomainEvent( + threadId: ThreadId, +): Promise { + await waitForWsClient(); + fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId); + fixture.snapshot = { + ...fixture.snapshot, + snapshotSequence: fixture.snapshot.snapshotSequence + 1, + threads: fixture.snapshot.threads.map((thread) => + thread.id === threadId + ? { + ...thread, + session: null, + messages: [ + createUserMessage({ + id: "msg-promoted-draft-pending-start" as MessageId, + text: "Ship it", + offsetSeconds: 1, + }), + ], + updatedAt: NOW_ISO, + } + : thread, + ), + }; + sendShellThreadUpsert(threadId, { session: null }); +} + async function startPromotedServerThreadViaDomainEvent(threadId: ThreadId): Promise { fixture.snapshot = updateThreadSessionInSnapshot(fixture.snapshot, threadId, { threadId, @@ -1814,6 +1843,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); useRightPanelStore.persist.clearStorage(); useRightPanelStore.setState({ byThreadKey: {} }); + useLocalDispatchStore.setState({ byThreadKey: {} }); }); afterEach(() => { @@ -2789,6 +2819,8 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(sendButton.disabled).toBe(false); sendButton.click(); + await expect.element(page.getByText("Starting request for", { exact: false })).toBeVisible(); + await vi.waitFor( () => { const dispatchRequest = wsRequests.find( @@ -2823,6 +2855,14 @@ describe("ChatView timeline estimator parity (full app)", () => { { timeout: 8_000, interval: 16 }, ); + await materializePromotedDraftThreadWithUserMessageViaDomainEvent(THREAD_ID); + await waitForURL( + mounted.router, + (path) => path === serverThreadPath(THREAD_ID), + "Promoted draft should canonicalize after the user message is persisted.", + ); + await expect.element(page.getByText("Starting request for", { exact: false })).toBeVisible(); + expect(wsRequests.some((request) => request._tag === WS_METHODS.vcsCreateWorktree)).toBe( false, ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 82254b70970..d3423811ab6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -155,6 +155,7 @@ import { type TerminalContextSelection, } from "../lib/terminalContext"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { selectLocalDispatchSnapshot, useLocalDispatchStore } from "../localDispatchStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../terminalSessionState"; import { appendElementContextsToPrompt, @@ -183,7 +184,6 @@ import { getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, - type LocalDispatchSnapshot, PullRequestDialogState, cloneComposerImageForRetry, deriveLockedProvider, @@ -429,6 +429,7 @@ interface TerminalLaunchContext { type PersistentTerminalLaunchContext = Pick; function useLocalDispatchState(input: { + activeThreadRef: ScopedThreadRef | null; activeThread: Thread | undefined; activeLatestTurn: Thread["latestTurn"] | null; phase: SessionPhase; @@ -436,26 +437,27 @@ function useLocalDispatchState(input: { activePendingUserInput: ApprovalRequestId | null; threadError: string | null | undefined; }) { - const [localDispatch, setLocalDispatch] = useState(null); + const localDispatch = useLocalDispatchStore((state) => + selectLocalDispatchSnapshot(state.byThreadKey, input.activeThreadRef), + ); + const beginDispatch = useLocalDispatchStore((state) => state.begin); + const clearDispatch = useLocalDispatchStore((state) => state.clear); const beginLocalDispatch = useCallback( (options?: { preparingWorktree?: boolean }) => { - const preparingWorktree = Boolean(options?.preparingWorktree); - setLocalDispatch((current) => { - if (current) { - return current.preparingWorktree === preparingWorktree - ? current - : { ...current, preparingWorktree }; - } - return createLocalDispatchSnapshot(input.activeThread, options); - }); + if (!input.activeThreadRef) return; + beginDispatch( + input.activeThreadRef, + createLocalDispatchSnapshot(input.activeThread, options), + ); }, - [input.activeThread], + [beginDispatch, input.activeThread, input.activeThreadRef], ); const resetLocalDispatch = useCallback(() => { - setLocalDispatch(null); - }, []); + if (!input.activeThreadRef) return; + clearDispatch(input.activeThreadRef); + }, [clearDispatch, input.activeThreadRef]); const serverAcknowledgedLocalDispatch = useMemo( () => @@ -1846,6 +1848,7 @@ export default function ChatView(props: ChatViewProps) { isPreparingWorktree, isSendBusy, } = useLocalDispatchState({ + activeThreadRef, activeThread, activeLatestTurn, phase, @@ -3218,16 +3221,23 @@ export default function ChatView(props: ChatViewProps) { }; }, [activeThread?.id, activeThread?.messages, handoffAttachmentPreviews, optimisticUserMessages]); + const previousCleanupThreadIdRef = useRef(null); useEffect(() => { + const nextThreadId = activeThread?.id ?? null; + const previousThreadId = previousCleanupThreadIdRef.current; + previousCleanupThreadIdRef.current = nextThreadId; + if (previousThreadId === nextThreadId) { + return; + } + setOptimisticUserMessages((existing) => { for (const message of existing) { revokeUserMessagePreviewUrls(message); } return []; }); - resetLocalDispatch(); setExpandedImage(null); - }, [draftId, resetLocalDispatch, threadId]); + }, [activeThread?.id]); const closeExpandedImage = useCallback(() => { setExpandedImage(null); diff --git a/apps/web/src/localDispatchStore.test.ts b/apps/web/src/localDispatchStore.test.ts new file mode 100644 index 00000000000..c27e9aaec5d --- /dev/null +++ b/apps/web/src/localDispatchStore.test.ts @@ -0,0 +1,64 @@ +import { scopeThreadRef } from "@t3tools/client-runtime"; +import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { selectLocalDispatchSnapshot, useLocalDispatchStore } from "./localDispatchStore"; +import type { LocalDispatchSnapshot } from "./components/ChatView.logic"; + +const environmentId = "env-1" as EnvironmentId; +const refA = scopeThreadRef(environmentId, ThreadId.make("thread-A")); +const refB = scopeThreadRef(environmentId, ThreadId.make("thread-B")); + +function snapshot(overrides: Partial = {}): LocalDispatchSnapshot { + return { + startedAt: "2026-01-01T00:00:00.000Z", + preparingWorktree: false, + latestTurnTurnId: null, + latestTurnRequestedAt: null, + latestTurnStartedAt: null, + latestTurnCompletedAt: null, + sessionOrchestrationStatus: null, + sessionUpdatedAt: null, + ...overrides, + }; +} + +beforeEach(() => { + useLocalDispatchStore.setState({ byThreadKey: {} }); +}); + +describe("localDispatchStore", () => { + it("preserves the original start timestamp when begin is called again", () => { + useLocalDispatchStore.getState().begin(refA, snapshot()); + useLocalDispatchStore.getState().begin( + refA, + snapshot({ + startedAt: "2026-01-01T00:00:05.000Z", + preparingWorktree: true, + }), + ); + + expect( + selectLocalDispatchSnapshot(useLocalDispatchStore.getState().byThreadKey, refA), + ).toMatchObject({ + startedAt: "2026-01-01T00:00:00.000Z", + preparingWorktree: true, + }); + }); + + it("clears only the selected thread", () => { + useLocalDispatchStore.getState().begin(refA, snapshot({ startedAt: "a" })); + useLocalDispatchStore.getState().begin(refB, snapshot({ startedAt: "b" })); + + useLocalDispatchStore.getState().clear(refA); + + expect(selectLocalDispatchSnapshot(useLocalDispatchStore.getState().byThreadKey, refA)).toBe( + null, + ); + expect( + selectLocalDispatchSnapshot(useLocalDispatchStore.getState().byThreadKey, refB), + ).toMatchObject({ + startedAt: "b", + }); + }); +}); diff --git a/apps/web/src/localDispatchStore.ts b/apps/web/src/localDispatchStore.ts new file mode 100644 index 00000000000..defcda8e5b1 --- /dev/null +++ b/apps/web/src/localDispatchStore.ts @@ -0,0 +1,55 @@ +import { scopedThreadKey } from "@t3tools/client-runtime"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { create } from "zustand"; + +import type { LocalDispatchSnapshot } from "./components/ChatView.logic"; + +export interface LocalDispatchStoreState { + byThreadKey: Record; + begin: (ref: ScopedThreadRef, snapshot: LocalDispatchSnapshot) => void; + clear: (ref: ScopedThreadRef) => void; +} + +const removeThreadKey = ( + byThreadKey: Record, + threadKey: string, +): Record => { + if (!(threadKey in byThreadKey)) return byThreadKey; + const { [threadKey]: _removed, ...rest } = byThreadKey; + return rest; +}; + +export const useLocalDispatchStore = create()((set) => ({ + byThreadKey: {}, + begin: (ref, snapshot) => + set((state) => { + const threadKey = scopedThreadKey(ref); + const current = state.byThreadKey[threadKey]; + const next = + current === undefined + ? snapshot + : current.preparingWorktree === snapshot.preparingWorktree + ? current + : { ...current, preparingWorktree: snapshot.preparingWorktree }; + if (next === current) return state; + return { + byThreadKey: { + ...state.byThreadKey, + [threadKey]: next, + }, + }; + }), + clear: (ref) => + set((state) => { + const nextByThreadKey = removeThreadKey(state.byThreadKey, scopedThreadKey(ref)); + return nextByThreadKey === state.byThreadKey ? state : { byThreadKey: nextByThreadKey }; + }), +})); + +export function selectLocalDispatchSnapshot( + byThreadKey: Record, + ref: ScopedThreadRef | null, +): LocalDispatchSnapshot | null { + if (!ref) return null; + return byThreadKey[scopedThreadKey(ref)] ?? null; +} diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index beb40aadff9..d953b9ca6b9 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1631,7 +1631,7 @@ describe("deriveActiveWorkStartedAt", () => { completedAt: "2026-02-27T21:10:06.000Z", } as const; - it("prefers the in-flight turn start when the latest turn is not settled", () => { + it("prefers the local send start when the latest turn is running", () => { expect( deriveActiveWorkStartedAt( latestTurn, @@ -1641,6 +1641,19 @@ describe("deriveActiveWorkStartedAt", () => { }, "2026-02-27T21:11:00.000Z", ), + ).toBe("2026-02-27T21:11:00.000Z"); + }); + + it("uses the in-flight turn start for recovered running sessions without a local send start", () => { + expect( + deriveActiveWorkStartedAt( + latestTurn, + { + orchestrationStatus: "running", + activeTurnId: TurnId.make("turn-1"), + }, + null, + ), ).toBe("2026-02-27T21:10:00.000Z"); }); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5576ebeffc1..72d4a4dd72e 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -307,6 +307,9 @@ export function deriveActiveWorkStartedAt( session: SessionActivityState | null, sendStartedAt: string | null, ): string | null { + if (sendStartedAt) { + return sendStartedAt; + } const runningTurnId = session?.orchestrationStatus === "running" ? (session.activeTurnId ?? null) : null; if (runningTurnId !== null) {