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
110 changes: 110 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,73 @@ describe("deriveMessagesTimelineRows", () => {
},
);

it("splits a settled turn's work at each steer and keeps the total time on top", () => {
const runId = RunId.make("steered-run");
const time = (second: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, second)).toISOString();
const message = (
id: string,
second: number,
role: "user" | "assistant",
inputIntent?: "turn_start" | "steer",
): TimelineEntry => ({
kind: "message",
id,
createdAt: time(second),
message: {
id: MessageId.make(id),
role,
text: id,
runId,
...(inputIntent ? { inputIntent } : {}),
createdAt: time(second),
updatedAt: time(second),
streaming: false,
},
});
const work = (id: string, second: number): TimelineEntry => ({
kind: "work",
id,
createdAt: time(second),
entry: { id, createdAt: time(second), runId, label: "Ran command", tone: "tool" },
});
const rows = (expandedRunIds: ReadonlySet<RunId>) =>
deriveMessagesTimelineRows({
timelineEntries: [
message("prompt", 0, "user", "turn_start"),
work("work-before-steer", 4),
message("steer", 10, "user", "steer"),
work("work-after-steer", 15),
message("final", 30, "assistant"),
],
latestRun: { runId, status: "completed", startedAt: time(0), completedAt: time(30) },
isWorking: false,
expandedRunIds,
turnDiffSummaries: [],
supportsConversationRollback: false,
});

const segmentKey = RunId.make(`${runId}:steer:steer`);
const collapsed = rows(new Set());
expect(collapsed.map((row) => row.id)).toEqual([
"prompt",
`turn-fold:${runId}`,
"steer",
`turn-fold:${segmentKey}`,
"final",
]);
expect(collapsed[1]).toMatchObject({ label: "Worked for 30s" });
expect(collapsed[3]).toMatchObject({ label: "Continued for 20s", createdAt: time(10) });

expect(rows(new Set([segmentKey])).map((row) => row.id)).toEqual([
"prompt",
`turn-fold:${runId}`,
"steer",
`turn-fold:${segmentKey}`,
"work-after-steer",
"final",
]);
});

it("keeps the previous turn folded while a newly sent message awaits its turn", () => {
const rows = deriveMessagesTimelineRows({
timelineEntries: [
Expand Down Expand Up @@ -3160,6 +3227,49 @@ describe("linked timeline resources", () => {
"assistant-meta:final",
]);
});

it("shows a created chat after a steer only when that steer's segment is open", () => {
const message = (id: string, role: "user" | "assistant", second: number) => ({
kind: "message" as const,
id,
createdAt: `2026-09-08T10:00:0${second}Z`,
message: {
id: MessageId.make(id),
role,
text: id,
runId,
...(role === "user" ? { inputIntent: "steer" as const } : {}),
streaming: false,
createdAt: `2026-09-08T10:00:0${second}Z`,
updatedAt: `2026-09-08T10:00:0${second}Z`,
},
});
const timelineEntries = [
message("intro", "assistant", 0),
message("steer", "user", 1),
event("created", "thread_created"),
message("after-steer", "assistant", 3),
message("final", "assistant", 4),
];
const segmentKey = RunId.make(`${runId}:steer:steer`);
const rowIds = (expanded: RunId[]) =>
deriveMessagesTimelineRows({
...common,
timelineEntries,
expandedRunIds: new Set(expanded),
}).map((row) => row.id);
expect(rowIds([runId])).not.toContain("created");
expect(rowIds([segmentKey])).toEqual([
`turn-fold:${runId}`,
"steer",
`turn-fold:${segmentKey}`,
"created",
"after-steer",
"final",
"summary:created",
"assistant-meta:final",
]);
});
});

it.each([true, false])(
Expand Down
102 changes: 81 additions & 21 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,8 @@ interface TurnFold {
anchorEntryId: string;
createdAt: string;
hiddenEntryIds: ReadonlySet<string>;
/** Every entry in the segment, including the cards a fold never hides. */
entryIds: ReadonlySet<string>;
label: string;
}

Expand Down Expand Up @@ -903,16 +905,30 @@ function timelineEntryFoldRunId(entry: TimelineEntry, runlessKey: RunId | null):
return null;
}

function timelineEntryIsSteer(entry: TimelineEntry): boolean {
return (
entry.kind === "message" &&
entry.message.role === "user" &&
(entry.message.inputIntent === "steer" ||
entry.message.inputIntent === "promoted_queued_to_steer")
);
}

function timelineEntryStartsResponse(entry: TimelineEntry): boolean {
return (
(entry.kind === "message" &&
entry.message.role === "user" &&
entry.message.inputIntent !== "steer" &&
entry.message.inputIntent !== "promoted_queued_to_steer") ||
(entry.kind === "message" && entry.message.role === "user" && !timelineEntryIsSteer(entry)) ||
(entry.kind === "work" && entry.entry.itemType === "notification")
);
}

function timelineEntryEnd(entry: TimelineEntry): string {
return entry.kind === "message" ? entry.message.updatedAt : entry.createdAt;
}

function timelineEntryIsCompaction(entry: TimelineEntry): boolean {
return entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction";
}

function responseBoundaryRunId(entry: TimelineEntry): RunId | null {
if (entry.kind === "message") return entry.message.runId ?? null;
if (entry.kind === "work") return entry.entry.runId ?? null;
Expand Down Expand Up @@ -1019,6 +1035,11 @@ function deriveTurnFolds(input: {
*/
startBoundary: string | null;
anchorEntryId: string;
/**
* Each steer after the turn has produced output starts a new segment at
* this index into `entries`, so the steer sits between the work it split.
*/
steerSegments: Array<{ startIndex: number; steer: TimelineEntry }>;
}
const groupsByRunId = new Map<RunId, TurnGroup>();
const runlessFailedKeys = new Set<RunId>();
Expand All @@ -1040,6 +1061,18 @@ function deriveTurnFolds(input: {
responseKey = boundaryRunId ?? runlessKey;
continue;
}
if (entry.kind === "message" && timelineEntryIsSteer(entry)) {
const steerRunId = entry.message.runId ?? responseKey;
const group = steerRunId === null ? undefined : groupsByRunId.get(steerRunId);
if (group !== undefined && group.entries.length > 0) {
// Back-to-back steers split once, after the last of them.
if (group.steerSegments.at(-1)?.startIndex === group.entries.length) {
group.steerSegments.pop();
}
group.steerSegments.push({ startIndex: group.entries.length, steer: entry });
}
continue;
}
const ownRunId = timelineEntryFoldRunId(entry, runlessKey);
if (!ownRunId) {
continue;
Expand All @@ -1062,6 +1095,7 @@ function deriveTurnFolds(input: {
hasStreamingMessage: false,
startBoundary: pendingBoundary?.createdAt ?? null,
anchorEntryId: pendingBoundary?.anchorEntryId ?? entry.id,
steerSegments: [],
};
pendingBoundary = null;
groupsByRunId.set(runId, group);
Expand Down Expand Up @@ -1098,8 +1132,7 @@ function deriveTurnFolds(input: {
if (entry.id === group.terminalEntry?.id) {
continue;
}
const isCompaction =
entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction";
const isCompaction = timelineEntryIsCompaction(entry);
const isFoldableTrailingActivity =
entry.kind === "work" &&
entry.entry.toolLifecycleStatus !== "inProgress" &&
Expand All @@ -1122,12 +1155,9 @@ function deriveTurnFolds(input: {
if (hiddenEntryIds.size === 0) {
continue;
}
const hidesNonCompactionWork = group.entries.some(
(entry) =>
hiddenEntryIds.has(entry.id) &&
!(entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction"),
);
if (!hidesNonCompactionWork) {
const hidesNonCompactionWork = (entries: ReadonlyArray<TimelineEntry>) =>
entries.some((entry) => hiddenEntryIds.has(entry.id) && !timelineEntryIsCompaction(entry));
if (!hidesNonCompactionWork(group.entries)) {
continue;
}

Expand All @@ -1141,8 +1171,7 @@ function deriveTurnFolds(input: {
input.latestRun?.runId === runId && input.latestRun.status === "interrupted";
// A turn cut short by a steer leaves trailing work entries behind its
// terminal message — take whichever ended last.
const lastEntryEnd =
lastEntry.kind === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt;
const lastEntryEnd = timelineEntryEnd(lastEntry);
const elapsedMs =
input.latestRun?.runId === runId && input.latestRun.startedAt && input.latestRun.completedAt
? computeElapsedMs(input.latestRun.startedAt, input.latestRun.completedAt)
Expand All @@ -1160,13 +1189,38 @@ function deriveTurnFolds(input: {
? `Worked for ${duration}`
: "Worked";

// The top row keeps the whole turn's time, steers included. Work after a
// steer folds under its own row below that steer, keyed apart from the run
// so each segment opens on its own.
const segmentHiddenIds = (entries: ReadonlyArray<TimelineEntry>) =>
new Set(entries.flatMap((entry) => (hiddenEntryIds.has(entry.id) ? [entry.id] : [])));
const firstSegment = group.entries.slice(0, group.steerSegments[0]?.startIndex);
foldsByAnchorEntryId.set(group.anchorEntryId, {
runId,
anchorEntryId: group.anchorEntryId,
createdAt: group.startBoundary ?? firstEntry.createdAt,
hiddenEntryIds,
hiddenEntryIds: segmentHiddenIds(firstSegment),
entryIds: new Set(firstSegment.map((entry) => entry.id)),
label,
});
for (const [index, { startIndex, steer }] of group.steerSegments.entries()) {
const entries = group.entries.slice(startIndex, group.steerSegments[index + 1]?.startIndex);
const anchorEntry = entries[0];
if (anchorEntry === undefined || !hidesNonCompactionWork(entries)) continue;
const segmentMs = computeElapsedMs(
steer.createdAt,
entries.map(timelineEntryEnd).reduce<string | null>(maxIsoTimestamp, null) ??
steer.createdAt,
);
foldsByAnchorEntryId.set(anchorEntry.id, {
runId: RunId.make(`${runId}:steer:${steer.id}`),
anchorEntryId: anchorEntry.id,
createdAt: steer.createdAt,
hiddenEntryIds: segmentHiddenIds(entries),
entryIds: new Set(entries.map((entry) => entry.id)),
label: segmentMs !== null ? `Continued for ${formatDuration(segmentMs)}` : "Continued",
});
}
}
return foldsByAnchorEntryId;
}
Expand Down Expand Up @@ -1414,11 +1468,15 @@ export function deriveMessagesTimelineRows(input: {
completeSupersededReplyEntryIds: completeSupersededReplyIds,
});
const collapsedEntryIds = new Set<string>();
const collapsedFoldMemberIds = new Set<string>();
for (const fold of foldsByAnchorEntryId.values()) {
if (!input.expandedRunIds?.has(fold.runId)) {
for (const entryId of fold.hiddenEntryIds) {
collapsedEntryIds.add(entryId);
}
for (const entryId of fold.entryIds) {
collapsedFoldMemberIds.add(entryId);
}
}
}
const collapsedSupersededEntryIds = new Set<string>();
Expand Down Expand Up @@ -1943,7 +2001,7 @@ export function deriveMessagesTimelineRows(input: {
}

const result = attachTrailingToolGroupsToAssistant(
attachCreatedThreadSummaries(nextRows, timelineEntries),
attachCreatedThreadSummaries(nextRows, timelineEntries, collapsedFoldMemberIds),
);
return result.map((row, index) =>
timelineRowIsWorkLog(row) && timelineRowIsWorkLog(result[index + 1])
Expand All @@ -1965,15 +2023,14 @@ function timelineRowIsWorkLog(row: MessagesTimelineRow | undefined): boolean {
function attachCreatedThreadSummaries(
rows: MessagesTimelineRow[],
timelineEntries: ReadonlyArray<TimelineEntry>,
collapsedFoldMemberIds: ReadonlySet<string>,
): MessagesTimelineRow[] {
const terminalIndexes = new Map<RunId, number>();
const collapsedRuns = new Set<RunId>();
const createdByRun = new Map<RunId, Array<Extract<MessagesTimelineRow, { kind: "event" }>>>();
for (const [index, row] of rows.entries()) {
if (row.kind === "message" && row.showAssistantMeta && row.message.runId) {
terminalIndexes.set(row.message.runId, index);
}
if (row.kind === "turn-fold" && !row.expanded) collapsedRuns.add(row.runId);
}
for (const entry of timelineEntries) {
const projectedItem =
Expand All @@ -1993,7 +2050,10 @@ function attachCreatedThreadSummaries(
if (row.kind === "event" && row.projectedItem.item.type === "thread_created") {
const runId = row.projectedItem.item.runId;
const terminalIndex = runId === null ? undefined : terminalIndexes.get(runId);
if (terminalIndex !== undefined && (collapsedRuns.has(runId!) || index > terminalIndex))
if (
terminalIndex !== undefined &&
(collapsedFoldMemberIds.has(row.id) || index > terminalIndex)
)
return [];
}
if (row.kind === "message" && row.showAssistantMeta && row.message.runId) {
Expand All @@ -2020,8 +2080,8 @@ export interface MessagesTimelineRowsProjection {
}

/**
* Runless (imported V1) turns fold under a synthetic key, so the entry's own
* run id is not enough to open them.
* Runless (imported V1) turns and work after a steer fold under synthetic
* keys, so the entry's own run id is not enough to open them.
*/
export function timelineEntryTurnFoldRunId(
input: Pick<
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
});
}, [latestRun, listIdentityKey]);

// Imported turns fold under synthetic keys rather than the entries' own run ids.
// Imported turns and post-steer work fold under synthetic keys, not the entries' run ids.
const findFoldRunIds = findActive
? timelineTurnFoldRunIdsByEntryId({
timelineEntries,
Expand Down
Loading