diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 6cbef09bd6..7e9ee84047 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2,12 +2,14 @@ import "../index.css"; import { + type EventId, ORCHESTRATION_WS_METHODS, type MessageId, type OrchestrationReadModel, type ProjectId, type ServerConfig, type ThreadId, + type TurnId, type WsWelcomePayload, WS_CHANNELS, WS_METHODS, @@ -122,6 +124,7 @@ function createUserMessage(options: { id: MessageId; text: string; offsetSeconds: number; + turnId?: TurnId | null; attachments?: Array<{ type: "image"; id: string; @@ -135,25 +138,51 @@ function createUserMessage(options: { role: "user" as const, text: options.text, ...(options.attachments ? { attachments: options.attachments } : {}), - turnId: null, + turnId: options.turnId ?? null, streaming: false, createdAt: isoAt(options.offsetSeconds), updatedAt: isoAt(options.offsetSeconds + 1), }; } -function createAssistantMessage(options: { id: MessageId; text: string; offsetSeconds: number }) { +function createAssistantMessage(options: { + id: MessageId; + text: string; + offsetSeconds: number; + turnId?: TurnId | null; +}) { return { id: options.id, role: "assistant" as const, text: options.text, - turnId: null, + turnId: options.turnId ?? null, streaming: false, createdAt: isoAt(options.offsetSeconds), updatedAt: isoAt(options.offsetSeconds + 1), }; } +function createThreadActivity(options: { + id: EventId; + offsetSeconds: number; + tone: "info" | "tool" | "approval" | "error"; + kind: string; + summary: string; + payload?: unknown; + turnId?: TurnId | null; + sequence?: number; +}) { + return { + id: options.id, + tone: options.tone, + kind: options.kind, + summary: options.summary, + payload: options.payload ?? {}, + turnId: options.turnId ?? null, + ...(options.sequence !== undefined ? { sequence: options.sequence } : {}), + createdAt: isoAt(options.offsetSeconds), + }; +} function createTerminalContext(input: { id: string; terminalLabel: string; @@ -172,7 +201,6 @@ function createTerminalContext(input: { createdAt: NOW_ISO, }; } - function createSnapshotForTargetUser(options: { targetMessageId: MessageId; targetText: string; @@ -381,6 +409,139 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } +function createSnapshotWithHistoricalToolRows(): OrchestrationReadModel { + const turnOneId = "turn-history-1" as TurnId; + const firstUserMessageId = "msg-user-history-1" as MessageId; + const firstAssistantMessageId = "msg-assistant-history-1" as MessageId; + + return { + snapshotSequence: 1, + projects: [ + { + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/repo/project", + defaultModel: "gpt-5", + scripts: [], + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + deletedAt: null, + }, + ], + threads: [ + { + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Historical tool rows thread", + model: "gpt-5", + interactionMode: "default", + runtimeMode: "full-access", + branch: "main", + worktreePath: null, + latestTurn: { + turnId: turnOneId, + state: "completed", + requestedAt: isoAt(0), + startedAt: isoAt(1), + completedAt: isoAt(6), + assistantMessageId: firstAssistantMessageId, + }, + createdAt: NOW_ISO, + updatedAt: isoAt(6), + deletedAt: null, + messages: [ + createUserMessage({ + id: firstUserMessageId, + text: "initial request", + offsetSeconds: 0, + turnId: turnOneId, + }), + createAssistantMessage({ + id: firstAssistantMessageId, + text: "initial response", + offsetSeconds: 6, + turnId: turnOneId, + }), + ], + activities: [ + createThreadActivity({ + id: "activity-history-tool" as EventId, + offsetSeconds: 2, + tone: "tool", + kind: "tool.completed", + summary: "Run lint complete", + turnId: turnOneId, + sequence: 1, + payload: { + itemType: "command_execution", + data: { + item: { + command: ["bun", "run", "lint"], + }, + }, + }, + }), + ], + proposedPlans: [], + checkpoints: [], + session: { + threadId: THREAD_ID, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: isoAt(6), + }, + }, + ], + updatedAt: isoAt(6), + }; +} + +function addNewLatestTurnToSnapshot(snapshot: OrchestrationReadModel): OrchestrationReadModel { + const nextTurnId = "turn-history-2" as TurnId; + + return { + ...snapshot, + snapshotSequence: snapshot.snapshotSequence + 1, + threads: snapshot.threads.map((thread) => + thread.id === THREAD_ID + ? { + ...thread, + latestTurn: { + turnId: nextTurnId, + state: "running", + requestedAt: isoAt(10), + startedAt: isoAt(10), + completedAt: null, + assistantMessageId: null, + }, + updatedAt: isoAt(10), + messages: [ + ...thread.messages, + createUserMessage({ + id: "msg-user-history-2" as MessageId, + text: "follow-up request", + offsetSeconds: 10, + turnId: nextTurnId, + }), + ], + session: { + threadId: THREAD_ID, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: nextTurnId, + lastError: null, + updatedAt: isoAt(10), + }, + } + : thread, + ), + updatedAt: isoAt(10), + }; +} function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { const tag = body._tag; if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { @@ -1439,4 +1600,48 @@ describe("ChatView timeline estimator parity (full app)", () => { await mounted.cleanup(); } }); + + it("preserves historical tool rows after a new user turn becomes latest", async () => { + const initialSnapshot = createSnapshotWithHistoricalToolRows(); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: initialSnapshot, + }); + + try { + const initialCommand = await waitForElement( + () => + Array.from(document.querySelectorAll("pre")).find((element) => + element.textContent?.includes("bun run lint"), + ) as HTMLPreElement | null, + "Unable to find the historical tool command before the new turn starts.", + ); + expect(initialCommand.textContent).toContain("bun run lint"); + + const nextSnapshot = addNewLatestTurnToSnapshot(initialSnapshot); + fixture.snapshot = nextSnapshot; + useStore.getState().syncServerReadModel(nextSnapshot); + await waitForLayout(); + + const preservedCommand = await waitForElement( + () => + Array.from(document.querySelectorAll("pre")).find((element) => + element.textContent?.includes("bun run lint"), + ) as HTMLPreElement | null, + "Historical tool command disappeared after the next turn became latest.", + ); + expect(preservedCommand.textContent).toContain("bun run lint"); + + const followUpMessage = await waitForElement( + () => + Array.from(document.querySelectorAll("pre")).find((element) => + element.textContent?.includes("follow-up request"), + ) as HTMLPreElement | null, + "Unable to find the follow-up user message after syncing the next snapshot.", + ); + expect(followUpMessage.textContent).toContain("follow-up request"); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index eaead424fb..3b117fb10e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -712,9 +712,10 @@ export default function ChatView({ threadId }: ChatViewProps) { sendStartedAt, ); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; - const workLogEntries = useMemo( - () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined), - [activeLatestTurn?.turnId, threadActivities], + const timelineWorkLogEntries = useMemo( + // Historical tool/work cards should remain visible after later turns become the latest turn. + () => deriveWorkLogEntries(threadActivities), + [threadActivities], ); const latestTurnHasToolActivity = useMemo( () => hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId), @@ -956,8 +957,12 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); const timelineEntries = useMemo( () => - deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), - [activeThread?.proposedPlans, timelineMessages, workLogEntries], + deriveTimelineEntries( + timelineMessages, + activeThread?.proposedPlans ?? [], + timelineWorkLogEntries, + ), + [activeThread?.proposedPlans, timelineMessages, timelineWorkLogEntries], ); const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 4a113adebe..e61e209ef9 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -592,6 +592,28 @@ describe("deriveWorkLogEntries", () => { expect(entries.map((entry) => entry.id)).toEqual(["task-progress"]); }); + it("includes historical work entries when no turn filter is provided", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "turn-1-complete", + createdAt: "2026-02-23T00:00:01.000Z", + turnId: "turn-1", + summary: "Turn 1 tool complete", + kind: "tool.completed", + }), + makeActivity({ + id: "turn-2-complete", + createdAt: "2026-02-23T00:00:02.000Z", + turnId: "turn-2", + summary: "Turn 2 tool complete", + kind: "tool.completed", + }), + ]; + + const entries = deriveWorkLogEntries(activities); + expect(entries.map((entry) => entry.id)).toEqual(["turn-1-complete", "turn-2-complete"]); + }); + it("filters by turn id when provided", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ id: "turn-1", turnId: "turn-1", summary: "Tool call", kind: "tool.started" }), diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 7c3ea96e65..f7d76ec9de 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -456,11 +456,11 @@ export function hasActionableProposedPlan( export function deriveWorkLogEntries( activities: ReadonlyArray, - latestTurnId: TurnId | undefined, + turnId?: TurnId, ): WorkLogEntry[] { const ordered = [...activities].toSorted(compareActivitiesByOrder); const entries = ordered - .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true)) + .filter((activity) => (turnId ? activity.turnId === turnId : true)) .filter((activity) => activity.kind !== "tool.started") .filter((activity) => activity.kind !== "task.started" && activity.kind !== "task.completed") .filter((activity) => activity.summary !== "Checkpoint captured")