diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index d8b74c8cc..ffd17708e 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, @@ -116,6 +118,7 @@ function createUserMessage(options: { id: MessageId; text: string; offsetSeconds: number; + turnId?: TurnId | null; attachments?: Array<{ type: "image"; id: string; @@ -129,25 +132,52 @@ 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 createSnapshotForTargetUser(options: { targetMessageId: MessageId; targetText: string; @@ -353,6 +383,140 @@ 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(tag: string): unknown { if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { return fixture.snapshot; @@ -1084,4 +1248,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 3c8a0a152..fb4ce0a86 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -956,9 +956,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), @@ -1166,8 +1167,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 3d1d269d4..6a9c79a0b 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -373,6 +373,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 aa8f3ffc3..966868c3a 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -408,11 +408,11 @@ export function findLatestProposedPlan( export function deriveWorkLogEntries( activities: ReadonlyArray, - latestTurnId: TurnId | undefined, + turnId?: TurnId, ): WorkLogEntry[] { const ordered = [...activities].toSorted(compareActivitiesByOrder); return 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")