Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 209 additions & 4 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -122,6 +124,7 @@
id: MessageId;
text: string;
offsetSeconds: number;
turnId?: TurnId | null;
attachments?: Array<{
type: "image";
id: string;
Expand All @@ -135,25 +138,51 @@
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;
Expand All @@ -172,7 +201,6 @@
createdAt: NOW_ISO,
};
}

function createSnapshotForTargetUser(options: {
targetMessageId: MessageId;
targetText: string;
Expand Down Expand Up @@ -381,6 +409,139 @@
};
}

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) {
Expand Down Expand Up @@ -520,7 +681,7 @@
await vi.waitFor(
() => {
element = query();
expect(element, errorMessage).toBeTruthy();

Check failure on line 684 in apps/web/src/components/ChatView.browser.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

[chromium] src/components/ChatView.browser.tsx > ChatView timeline estimator parity (full app) > preserves historical tool rows after a new user turn becomes latest

AssertionError: Unable to find the historical tool command before the new turn starts.: expected undefined to be truthy - Expected: true + Received: undefined ❯ toBeTruthy src/components/ChatView.browser.tsx:684:36
},
{
timeout: 8_000,
Expand Down Expand Up @@ -1439,4 +1600,48 @@
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();
}
});
});
15 changes: 10 additions & 5 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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);
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/session-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/session-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,11 +456,11 @@ export function hasActionableProposedPlan(

export function deriveWorkLogEntries(
activities: ReadonlyArray<OrchestrationThreadActivity>,
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")
Expand Down
Loading