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
214 changes: 211 additions & 3 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 @@ -116,6 +118,7 @@ function createUserMessage(options: {
id: MessageId;
text: string;
offsetSeconds: number;
turnId?: TurnId | null;
attachments?: Array<{
type: "image";
id: string;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
});
});
15 changes: 10 additions & 5 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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);
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 @@ -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" }),
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 @@ -408,11 +408,11 @@ export function findLatestProposedPlan(

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