Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
29 changes: 14 additions & 15 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -826,10 +826,11 @@ describe("deriveMessagesTimelineRows", () => {
"assistant-final-entry",
"user-followup-entry",
"working-indicator-row",
"thinking-indicator-row",
]);
const finalRow = rows.find((row) => row.id === "assistant-final-entry");
expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true);
expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: true });
expect(rows.at(-1)).toMatchObject({ kind: "thinking" });
});

it("does not fold the active in-progress turn", () => {
Expand Down Expand Up @@ -944,7 +945,7 @@ describe("deriveMessagesTimelineRows", () => {
});

expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);
expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false });
expect(rows.some((row) => row.kind === "thinking")).toBe(false);
expect(rows.find((row) => row.kind === "work-live")).toMatchObject({
entry: { id: "running-command" },
groupedEntries: [
Expand Down Expand Up @@ -1195,7 +1196,7 @@ describe("deriveMessagesTimelineRows", () => {
]);
});

it("keeps the latest completed tool call live while the turn is running", () => {
it("shows thinking after the latest tool call completes while the turn is running", () => {
const rows = deriveMessagesTimelineRows({
timelineEntries: [
{
Expand Down Expand Up @@ -1226,11 +1227,9 @@ describe("deriveMessagesTimelineRows", () => {
revertTurnCountByUserMessageId: new Map(),
});

expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);
expect(rows.find((row) => row.kind === "work-live")).toMatchObject({
entry: { id: "latest-command" },
groupedEntries: [{ id: "latest-command" }],
});
expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "thinking"]);
expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ active: false });
expect(rows.at(-1)).toMatchObject({ kind: "thinking" });
});

it("does not fold the session's running turn when latestTurn regresses", () => {
Expand Down Expand Up @@ -1378,7 +1377,7 @@ describe("deriveMessagesTimelineRows", () => {

expect(assistantRow?.showAssistantMeta).toBe(false);
expect(assistantRow?.showAssistantCopyButton).toBe(false);
expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false });
expect(rows.at(-1)).toMatchObject({ kind: "thinking" });
});

it.each([
Expand Down Expand Up @@ -1547,7 +1546,7 @@ describe("deriveMessagesTimelineRows", () => {
});

describe("computeStableMessagesTimelineRows", () => {
it.each(["", " \n"])("replaces Thinking when assistant content grows from %j", (text) => {
it.each(["", " \n"])("keeps Thinking after assistant content grows from %j", (text) => {
const startedAt = "2026-01-01T00:00:00Z";
const turnId = TurnId.make("turn-1");
const input = {
Expand Down Expand Up @@ -1588,11 +1587,11 @@ describe("computeStableMessagesTimelineRows", () => {
initial,
);

const initialWorking = initial.byId.get("working-indicator-row");
const updatedWorking = updated.byId.get("working-indicator-row");
expect(initialWorking).toMatchObject({ showThinking: true });
expect(updatedWorking).toMatchObject({ showThinking: false });
expect(updatedWorking).not.toBe(initialWorking);
const initialThinking = initial.byId.get("thinking-indicator-row");
const updatedThinking = updated.byId.get("thinking-indicator-row");
expect(initialThinking).toMatchObject({ kind: "thinking" });
expect(updatedThinking).toBe(initialThinking);
expect(updated.result.at(-1)).toBe(updatedThinking);
});

it("returns the previous result when row order and content are unchanged", () => {
Expand Down
56 changes: 32 additions & 24 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ export type MessagesTimelineRow =
groupedEntries: WorkLogEntry[];
groupId: string;
expanded: boolean;
active: boolean;
}
| {
kind: "work-toggle";
Expand Down Expand Up @@ -235,7 +236,11 @@ export type MessagesTimelineRow =
kind: "working";
id: string;
createdAt: string | null;
showThinking: boolean;
}
| {
kind: "thinking";
id: string;
createdAt: string | null;
};

export interface StableMessagesTimelineRowsState {
Expand Down Expand Up @@ -512,6 +517,14 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null {
return entry.kind === "work" ? (entry.entry.turnId ?? null) : null;
}

function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean {
return (
entry.toolLifecycleStatus === "inProgress" ||
entry.sourceActivityKind === "task.progress" ||
(entry.toolLifecycleStatus === undefined && workLogEntryIsToolLike(entry))
);
}

/**
* Settled turns keep only their terminal assistant message visible.
* Everything before it folds behind a "Worked for ..." row anchored at the
Expand Down Expand Up @@ -702,24 +715,6 @@ export function deriveMessagesTimelineRows(input: {
unsettledTurnId !== null &&
entry.toolLifecycleStatus === "inProgress" &&
entry.turnId === unsettledTurnId;
const activeEntries = input.isWorking
? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index))
: [];
const activeTurnHasVisibleContent = activeEntries.some((entry) => {
if (entry.kind === "message") {
return entry.message.role === "assistant" && (entry.message.text?.trim().length ?? 0) > 0;
}
if (entry.kind === "work") {
return (
entry.entry.agentSpawn === undefined &&
workLogEntryIsToolLike(entry.entry) &&
entry.entry.toolLifecycleStatus === "inProgress"
);
}
if (entry.kind === "proposed-plan") return true;
return false;
});

const activeToolEntries: Array<Extract<TimelineEntry, { kind: "work" }>> = [];
for (let index = input.timelineEntries.length - 1; index >= activeTurnHeaderIndex; index -= 1) {
const entry = input.timelineEntries[index]!;
Expand All @@ -733,7 +728,6 @@ export function deriveMessagesTimelineRows(input: {
}
activeToolEntries.unshift(entry);
}
const activeWorkEntryIds = new Set(activeToolEntries.map((entry) => entry.id));
const visibleActiveToolEntries = omitSupersededLifecycleMarkers(
activeToolEntries.filter((entry) => workEntryIsVisibleInGroup(entry.entry, true)),
(entry) => entry.entry,
Expand All @@ -753,20 +747,25 @@ export function deriveMessagesTimelineRows(input: {
groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry),
groupId,
expanded: input.expandedWorkGroupIds?.has(groupId) ?? false,
active: workEntryIsActiveTurnActivity(latestActiveToolEntry.entry),
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
};
})()
: null;
const activeWorkEntryIds = new Set(
activeWorkRow === null ? [] : activeToolEntries.map((entry) => entry.id),
);
const appendWorkingRow = () => {
nextRows.push({
kind: "working",
id: "working-indicator-row",
createdAt: input.activeTurnStartedAt,
showThinking: activeWorkRow === null && !activeTurnHasVisibleContent,
});
};
let hasLiveWorkRow = false;
const appendActiveWorkRows = () => {
if (activeWorkRow === null) return;
nextRows.push(activeWorkRow);
hasLiveWorkRow ||= activeWorkRow.active;
Comment thread
maria-rcks marked this conversation as resolved.
Resolved
if (!activeWorkRow.expanded) return;
for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) {
nextRows.push({
Expand Down Expand Up @@ -864,7 +863,9 @@ export function deriveMessagesTimelineRows(input: {
groupedEntries: visibleGroupedEntries,
groupId,
expanded,
active: true,
});
hasLiveWorkRow = true;
if (expanded) {
for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {
nextRows.push({
Expand Down Expand Up @@ -966,6 +967,13 @@ export function deriveMessagesTimelineRows(input: {
if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {
appendWorkingRow();
}
if (input.isWorking && !hasLiveWorkRow) {
nextRows.push({
kind: "thinking",
id: "thinking-indicator-row",
createdAt: input.activeTurnStartedAt,
});
}

return nextRows;
}
Expand Down Expand Up @@ -996,9 +1004,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean

switch (a.kind) {
case "working":
return (
a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking
);
case "thinking":
return a.createdAt === (b as typeof a).createdAt;

case "turn-fold": {
const bf = b as typeof a;
Expand All @@ -1023,6 +1030,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
a.createdAt === bw.createdAt &&
a.groupId === bw.groupId &&
a.expanded === bw.expanded &&
a.active === bw.active &&
Equal.equals(a.entry, bw.entry) &&
Equal.equals(a.groupedEntries, bw.groupedEntries)
);
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1308,7 +1308,7 @@ describe("MessagesTimeline", () => {
expect(markup).not.toContain("tool call failed");
});

it("keeps terminal command copy live while the parent turn is active", () => {
it("keeps terminal command copy visible while thinking continues", () => {
const turnId = TurnId.make("turn-live");
const markup = renderToStaticMarkup(
<MessagesTimeline
Expand Down Expand Up @@ -1343,7 +1343,8 @@ describe("MessagesTimeline", () => {
/>,
);

expect(markup).toContain("Running pnpm");
expect(markup).toContain("Ran pnpm");
expect(markup).toContain("Thinking");
expect(markup).toContain("tool call failed");
});

Expand Down
81 changes: 47 additions & 34 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -981,7 +981,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
: "pb-0"
: isExpandedToolGroupHeader
? "pb-0"
: row.kind === "turn-fold" || row.kind === "working"
: row.kind === "turn-fold" || row.kind === "working" || row.kind === "thinking"
? "pb-1.5"
: (row.kind === "message" &&
row.message.role === "assistant" &&
Expand Down Expand Up @@ -1013,6 +1013,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
) : null}
{row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
{row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
{row.kind === "thinking" ? <ThinkingTimelineRow /> : null}
</div>
);
});
Expand Down Expand Up @@ -1330,34 +1331,36 @@ function ProposedPlanTimelineRow({
function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
const { isPreparingWorktree } = use(TimelineRowActivityCtx);
return (
<div>
<div className="border-b border-border/60 pb-2 pt-1">
<div className="flex h-6 min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
<span
key={isPreparingWorktree ? "setup" : "working"}
className="relative shrink-0 overflow-hidden whitespace-nowrap transition-opacity duration-150 starting:opacity-0 motion-reduce:transition-none"
>
{isPreparingWorktree ? (
<>
Setting up worktree…
<ActivityShimmerOverlay>Setting up worktree…</ActivityShimmerOverlay>
</>
) : row.createdAt ? (
<>
Working for <WorkingTimer createdAt={row.createdAt} />
</>
) : (
"Working..."
)}
</span>
</div>
<div className="border-b border-border/60 pb-2 pt-1">
<div className="flex h-6 min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
<span
key={isPreparingWorktree ? "setup" : "working"}
className="relative shrink-0 overflow-hidden whitespace-nowrap transition-opacity duration-150 starting:opacity-0 motion-reduce:transition-none"
>
{isPreparingWorktree ? (
<>
Setting up worktree…
<ActivityShimmerOverlay>Setting up worktree…</ActivityShimmerOverlay>
</>
) : row.createdAt ? (
<>
Working for <WorkingTimer createdAt={row.createdAt} />
</>
) : (
"Working..."
)}
</span>
</div>
{row.showThinking ? (
// Reserve the activity row during setup so the handoff keeps the same height.
<div className="mt-1 min-h-7">
{isPreparingWorktree ? null : <LiveActivityRow label="Thinking" />}
</div>
) : null}
</div>
);
}

function ThinkingTimelineRow() {
const { isPreparingWorktree } = use(TimelineRowActivityCtx);
// Reserve the activity row during setup so the handoff keeps the same height.
return (
<div className="min-h-7">
{isPreparingWorktree ? null : <LiveActivityRow label="Thinking" />}
</div>
);
}
Expand Down Expand Up @@ -1514,7 +1517,7 @@ function LiveActivityContent({

function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {
const ctx = use(TimelineRowCtx);
const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot);
const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active);
const failed = workEntryDisplayIndicatesToolFailure(row.entry);

return (
Expand All @@ -1525,7 +1528,18 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "
aria-expanded={row.expanded}
onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
>
<LiveActivityRow label={label} iconName={workEntryIconName(row.entry)} failed={failed} />
{row.active ? (
<LiveActivityRow label={label} iconName={workEntryIconName(row.entry)} failed={failed} />
) : (
<div className="min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed">
<LiveActivityContent
label={label}
iconName={workEntryIconName(row.entry)}
failed={failed}
announceFailure={failed}
/>
</div>
)}
</button>
);
}
Expand Down Expand Up @@ -2205,14 +2219,13 @@ function workEntryRawCommand(
function liveWorkEntryLabel(
workEntry: TimelineWorkEntry,
workspaceRoot: string | undefined,
active: boolean,
): string {
const command = workEntry.command?.trim();
if (command) {
// This row describes the active parent turn, not the command lifecycle.
// Keep its live "Running" copy until the turn or contiguous tool run settles.
const program = commandProgramName(command);
if (program) return `Running ${program}`;
return "Running command";
if (program) return `${active ? "Running" : "Ran"} ${program}`;
return active ? "Running command" : "Ran command";
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
}

return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry);
Expand Down
Loading