Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -1056,6 +1056,7 @@ describe("MessagesTimeline citations", () => {
attemptOrdinal: ordinal,
rootNodeId: `node-${id}` as never,
status,
startedAt: null,
completedAt: null,
});
const superseded = attempt("attempt-1", 1, "superseded");
Expand Down
79 changes: 75 additions & 4 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,7 @@ describe("deriveMessagesTimelineRows", () => {
status: "superseded" as const,
attemptOrdinal: 0,
rootNodeId: "old-root" as never,
startedAt: null,
completedAt: null,
},
}
Expand Down Expand Up @@ -3038,6 +3039,7 @@ describe("v2 run and attempt history", () => {
attemptOrdinal: 1,
rootNodeId: "node-attempt-1" as never,
status: "superseded" as const,
startedAt: DateTime.makeUnsafe("2026-01-01T00:00:00Z"),
completedAt: null,
};
const activeAttempt = {
Expand All @@ -3046,6 +3048,7 @@ describe("v2 run and attempt history", () => {
attemptOrdinal: 2,
rootNodeId: "node-attempt-2" as never,
status: "running" as const,
startedAt: DateTime.makeUnsafe("2026-01-01T00:00:05Z"),
completedAt: null,
};
const timelineEntries = [
Expand Down Expand Up @@ -3165,6 +3168,28 @@ describe("v2 run and attempt history", () => {
supportsConversationRollback: false,
};

const completedInput = {
...common,
latestRun: {
...common.latestRun,
status: "completed" as const,
startedAt: "2026-01-01T00:00:03Z",
completedAt: "2026-01-01T00:00:08Z",
},
timelineEntries: timelineEntries.map((entry) =>
entry.kind === "message" && entry.id === "active-assistant-entry"
? {
...entry,
message: { ...entry.message, streaming: false, updatedAt: "2026-01-01T00:00:08Z" },
}
: entry,
),
};
const completedRows = deriveMessagesTimelineRows(completedInput);
expect(completedRows.find((row) => row.kind === "turn-fold")).toMatchObject({
label: "Worked for 8s",
});

const collapsedRows = deriveMessagesTimelineRows(common);
expect(collapsedRows.map((row) => row.id)).toEqual([
"context-handoff-entry",
Expand Down Expand Up @@ -3253,6 +3278,7 @@ describe("v2 run and attempt history", () => {
attemptOrdinal: 1,
rootNodeId: NodeId.make("node-attempt-1"),
status: "superseded" as const,
startedAt: null,
completedAt: DateTime.makeUnsafe(at(10)),
};
const nextAttempt = {
Expand All @@ -3261,6 +3287,7 @@ describe("v2 run and attempt history", () => {
attemptOrdinal: 2,
rootNodeId: NodeId.make("node-attempt-2"),
status: "completed" as const,
startedAt: null,
completedAt: DateTime.makeUnsafe(at(14)),
};
const assistantEntry = (
Expand Down Expand Up @@ -4411,6 +4438,7 @@ describe("failed turn transcript", () => {
attemptOrdinal: 1,
rootNodeId: NodeId.make("superseded-root"),
status: "superseded" as const,
startedAt: null,
completedAt: null,
},
},
Expand Down Expand Up @@ -4512,13 +4540,13 @@ describe("working window", () => {
createdAt: time(second),
entry: { id, createdAt: time(second), runId, tone: "tool" as const, ...entry },
});
const run = (entries: ReadonlyArray<unknown>) => {
const run = (entries: ReadonlyArray<unknown>, activeTurnStartedAt = time(0)) => {
const input = {
timelineEntries: entries as never,
latestRun: { runId, status: "running" as const, startedAt: time(0), completedAt: null },
runningRunId: runId,
isWorking: true,
activeTurnStartedAt: time(0),
activeTurnStartedAt,
turnDiffSummaries: [],
supportsConversationRollback: false,
};
Expand All @@ -4530,7 +4558,7 @@ describe("working window", () => {
);

it("moves the running turn's work under the bar and keeps steers and replies above it", () => {
const rows = run([
const entries = [
prompt("prompt", 0),
work("edit", 2, {
label: "Edited a.ts",
Expand Down Expand Up @@ -4561,7 +4589,8 @@ describe("working window", () => {
itemType: "command_execution",
toolLifecycleStatus: "inProgress",
}),
]);
];
const rows = run(entries);
expect(rows.map((row) => row.id)).toEqual([
"prompt",
"reply",
Expand All @@ -4574,6 +4603,47 @@ describe("working window", () => {
// Sending a steer re-keys the window, which closes an open history.
expect(window?.key).toBe("steer");
expect(window?.historyOpen).toBe(false);

const [promptEntry, edit, reply, , command] = entries;
const pendingSteer = prompt("steer", 3);
const pendingSteerEntry = {
...pendingSteer,
message: { ...pendingSteer.message, inputIntent: "steer" as const, runId: null },
};
const pendingRows = run([promptEntry, edit, reply, pendingSteerEntry, command]);
expect(pendingRows.map((row) => row.id)).toEqual(["prompt", "steer", "working-indicator-row"]);
expect(workingRow(pendingRows)?.window?.items.map((item) => item.id)).toEqual([
"edit",
"reply",
"command",
]);

const attempt = (ordinal: number, status: "superseded" | "running", second: number) => ({
id: RunAttemptId.make(`attempt-${ordinal}`),
runId,
attemptOrdinal: ordinal,
rootNodeId: NodeId.make(`node-${ordinal}`),
status,
startedAt: DateTime.makeUnsafe(time(second)),
completedAt: status === "superseded" ? DateTime.makeUnsafe(time(3)) : null,
});
const restarted = run(
entries.map((entry, index) => ({
...entry,
attempt: index < 3 ? attempt(1, "superseded", 0) : attempt(2, "running", 3),
})),
time(3),
);
expect(restarted.map((row) => row.id)).toEqual([
"prompt",
"steer",
"attempt-fold:attempt-1",
"working-indicator-row",
]);
expect(workingRow(restarted)).toMatchObject({
createdAt: time(0),
window: { diff: { additions: 84, deletions: 22 } },
});
});

it("groups a steer with its prompt once the bar moves below both", () => {
Expand Down Expand Up @@ -5366,6 +5436,7 @@ describe("live subagents after their parent turn settles", () => {
attemptOrdinal: 1,
rootNodeId: NodeId.make("superseded-root"),
status: "superseded" as const,
startedAt: null,
completedAt: null,
};
const entries = deriveTimelineEntriesFromVisibleTurnItems({
Expand Down
69 changes: 58 additions & 11 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,24 @@ function maxIsoTimestamp(a: string | null, b: string | null): string | null {
return bMs > aMs ? b : a;
}

function minIsoTimestamp(a: string | null, b: string | null): string | null {
return a === null || b === null ? (a ?? b) : maxIsoTimestamp(a, b) === a ? b : a;
}

function runFirstAttemptStartedAt(
entries: ReadonlyArray<TimelineEntry>,
runId: RunId | null,
): string | null {
let first: DateTime.Utc | null = null;
for (const entry of entries) {
const startedAt = entry.attempt?.runId === runId ? entry.attempt.startedAt : null;
if (startedAt != null && (first === null || DateTime.isLessThan(startedAt, first))) {
first = startedAt;
}
}
return first === null ? null : DateTime.formatIso(first);
}

export type TimelineLatestRun = Pick<
ThreadRunSummary,
"runId" | "status" | "startedAt" | "completedAt" | "turnEnded"
Expand Down Expand Up @@ -974,6 +992,7 @@ function steerReplyEntryIds(entries: ReadonlyArray<TimelineEntry>): ReadonlySet<
if (timelineEntryStartsResponse(entry)) {
lastReply = null;
} else if (timelineEntryIsSteer(entry)) {
if (entry.kind !== "message" || entry.message.runId == null) continue;
if (lastReply !== null) ids.add(lastReply);
lastReply = null;
} else if (
Expand Down Expand Up @@ -1391,7 +1410,13 @@ function deriveTurnFolds(input: {
const lastEntryEnd = timelineEntryEnd(lastEntry);
const elapsedMs =
input.latestRun?.runId === runId && input.latestRun.startedAt && input.latestRun.completedAt
? computeElapsedMs(input.latestRun.startedAt, input.latestRun.completedAt)
? computeElapsedMs(
minIsoTimestamp(
runFirstAttemptStartedAt(input.timelineEntries, runId),
input.latestRun.startedAt,
) ?? input.latestRun.startedAt,
input.latestRun.completedAt,
)
: computeElapsedMs(
group.startBoundary ?? firstEntry.createdAt,
maxIsoTimestamp(group.terminalEntry?.message.updatedAt ?? null, lastEntryEnd) ??
Expand Down Expand Up @@ -1794,6 +1819,10 @@ export function deriveMessagesTimelineRows(input: {
}
}
const unsettledRunId = deriveUnsettledRunId(input.latestRun ?? null, input.runningRunId ?? null);
const activeTurnStartedAt = minIsoTimestamp(
input.activeTurnStartedAt ?? null,
runFirstAttemptStartedAt(timelineEntries, unsettledRunId),
);
const revertTurnCountByUserMessageId = input.supportsConversationRollback
? deriveRevertTurnCountByUserMessageId({
timelineEntries: input.timelineEntries,
Expand Down Expand Up @@ -2059,8 +2088,7 @@ export function deriveMessagesTimelineRows(input: {
id: "working-indicator-row",
createdAt:
(compactsOnReturn ? undefined : resumeCompactionEntry?.entry.createdAt) ??
input.activeTurnStartedAt ??
null,
activeTurnStartedAt,
...(activeCompactionRunId !== null ? { compacting: true as const } : {}),
});
};
Expand Down Expand Up @@ -2572,7 +2600,7 @@ export function deriveMessagesTimelineRows(input: {
nextRows.push({
kind: "thinking",
id: LIVE_ACTIVITY_ROW_ID,
createdAt: input.activeTurnStartedAt ?? null,
createdAt: activeTurnStartedAt,
groupId,
expanded,
});
Expand All @@ -2589,7 +2617,7 @@ export function deriveMessagesTimelineRows(input: {
nextRows.push({
kind: "thinking",
id: LIVE_ACTIVITY_ROW_ID,
createdAt: input.activeTurnStartedAt ?? null,
createdAt: activeTurnStartedAt,
});
}
}
Expand Down Expand Up @@ -2909,17 +2937,24 @@ export function applyWorkingWindow(
);
let interruptRequested = false;
let interruptSettled = false;
let itemsBeforeLatestPrompt = 0;
const supersededWork: WorkLogEntry[] = [];
for (let index = boundaryIndex + 1; index < entries.length; index += 1) {
const entry = entries[index]!;
if (entry.kind === "message" && entry.message.role === "user") {
key = entry.id;
itemsBeforeLatestPrompt = items.length;
continue;
}
if (entry.attempt?.status === "superseded" || replyIds.has(entry.id)) continue;
const runId =
entry.kind === "event" ? entry.projectedItem.item.runId : timelineEntryRunId(entry);
// A provider-native subagent thread's live work has no app run.
if (runId === null ? input.runlessWorkActive !== true : !runIds.has(runId)) continue;
if (entry.attempt?.status === "superseded") {
if (entry.kind === "work") supersededWork.push(entry.entry);
continue;
}
if (replyIds.has(entry.id)) continue;
if (entry.kind === "work") {
const item = entry.entry.projectedItem?.item;
if (
Expand Down Expand Up @@ -2982,23 +3017,34 @@ export function applyWorkingWindow(
}
}

const answer = items.at(-1)?.kind === "text" ? items.pop() : undefined;
const answer =
items.at(-1)?.kind === "text" && items.length > itemsBeforeLatestPrompt
? items.pop()
: undefined;
const ids = collectWindowIds(items);
const windowStartIndex = Math.max(
workingIndex,
rows.findLastIndex((row) => row.kind === "message" && row.message.role === "user"),
);
const keptRows = (start: number, end?: number) =>
rows.slice(start, end).filter((row) => !rowIsReplacedByWindow(row, ids));
const before = keptRows(workingIndex + 1, windowStartIndex + 1);
const before: MessagesTimelineRow[] = [];
const supersededAttempts: MessagesTimelineRow[] = [];
let inSupersededAttempt = false;
for (const row of keptRows(workingIndex + 1, windowStartIndex + 1)) {
if (row.kind === "attempt-fold") inSupersededAttempt = runIds.has(row.runId);
else if (row.kind === "message" && row.message.role === "user") inSupersededAttempt = false;
(inSupersededAttempt ? supersededAttempts : before).push(row);
}
const after = keptRows(windowStartIndex + 1);
const window: WorkingWindow = {
key,
items,
compactCommand: compactCommandRunIds.has(unsettledRunId),
diff: sumFileChangeLineStat(
items.flatMap((item) => (item.kind === "work" ? [item.entry] : [])),
),
diff: sumFileChangeLineStat([
...supersededWork,
...items.flatMap((item) => (item.kind === "work" ? [item.entry] : [])),
]),
failedTests: deriveFailedTests(items),
retryAttempt: deriveRetryAttempt(items),
stopping: interruptRequested && !interruptSettled,
Expand All @@ -3009,6 +3055,7 @@ export function applyWorkingWindow(
return withPromptGrouping([
...rows.slice(0, workingIndex),
...before,
...supersededAttempts,
{ ...workingRow, window },
...after.map((row) => (row.continuesWorkLog ? { ...row, continuesWorkLog: false } : row)),
]);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/session-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export type PendingUserInput = ThreadPendingUserInput;

export type TimelineAttempt = Pick<
OrchestrationV2RunAttempt,
"id" | "runId" | "attemptOrdinal" | "rootNodeId" | "status" | "completedAt"
"id" | "runId" | "attemptOrdinal" | "rootNodeId" | "status" | "startedAt" | "completedAt"
>;

export type TimelineEntry = (
Expand Down
Loading