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
61 changes: 46 additions & 15 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2137,7 +2137,7 @@ describe("deriveMessagesTimelineRows", () => {
expect(rows[1]).toMatchObject({ entries: [thought] });
});

it("shows each tool once across expanded activity histories separated by a failed tool", () => {
it("keeps thoughts and tools in one activity row across a failed tool", () => {
const thought = reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1");
const tools = ["a", "b", "c"].map((id, index) => {
const entry = toolEntry(id, `2026-01-01T00:00:0${index + 2}Z`, "turn-1");
Expand All @@ -2161,21 +2161,52 @@ describe("deriveMessagesTimelineRows", () => {
supportsConversationRollback: false,
} satisfies Parameters<typeof deriveMessagesTimelineRows>[0];
const rows = deriveMessagesTimelineRows(input);
const expanded = deriveMessagesTimelineRows({
expect(rows.map((row) => row.kind)).toEqual(["working", "activity-group"]);
expect(rows.at(-1)).toMatchObject({
id: "live-activity-row",
entries: [thought, ...tools],
active: true,
});
const settled = deriveMessagesTimelineRows({
...input,
expandedWorkGroupIds: new Set(rows.flatMap((row) => ("groupId" in row ? [row.groupId] : []))),
});
const visibleTools = expanded.flatMap((row) =>
row.kind === "activity-group" && row.expanded
? row.entries.flatMap((entry) => (entry.kind === "work" ? [entry.entry.id] : []))
: row.kind === "work"
? row.groupedEntries.map((entry) => entry.id)
: [],
);
expect(visibleTools).toEqual(["a", "b", "c"]);
expect(expanded.filter((row) => row.id === "live-activity-row")).toMatchObject([
{ kind: "work-live", entry: { id: "c" } },
]);
timelineEntries: [
thought,
...tools,
reasoningEntry("reasoning-next", "2026-01-01T00:00:05Z", "turn-1"),
{ ...tools[1]!, id: "d", entry: { ...tools[1]!.entry, id: "d", toolCallId: "d" } },
],
isWorking: false,
activeTurnStartedAt: null,
});
expect(settled.map((row) => row.kind)).toEqual(["activity-group"]);
});

it("settles the activity row while the latest tool has failed", () => {
const thought = reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1");
const failedTool = toolEntry("failed-tool", "2026-01-01T00:00:02Z", "turn-1");
const rows = deriveMessagesTimelineRows({
timelineEntries: [
thought,
{
...failedTool,
entry: {
...failedTool.entry,
command: "echo nope",
toolCallId: "failed-tool",
toolLifecycleStatus: "failed" as const,
sourceActivityKind: "tool.completed" as const,
},
},
],
runningTurnId: TurnId.make("turn-1"),
isWorking: true,
activeTurnStartedAt: "2026-01-01T00:00:00Z",
turnDiffSummaries: [],
supportsConversationRollback: false,
});
expect(rows.map((row) => row.kind)).toEqual(["working", "activity-group", "thinking"]);
expect(rows[1]).toMatchObject({ id: "activity-group:reasoning-entry", active: false });
expect(rows[2]).toMatchObject({ id: "live-activity-row" });
});

it("folds mixed activity under worked-for and restores ordered details when expanded", () => {
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,8 +323,7 @@ function isActivityEntry(entry: TimelineEntry): entry is ActivityEntry {
entry.entry.agentSpawn === undefined &&
entry.entry.questionAnswer === undefined &&
entry.entry.sourceActivityKind !== "context-compaction" &&
entry.entry.tone !== "error" &&
!workEntryDisplayIndicatesToolFailure(entry.entry);
entry.entry.tone !== "error";
}

export type MessagesTimelineRow =
Expand Down Expand Up @@ -1158,7 +1157,8 @@ export function deriveMessagesTimelineRows(input: {
const active =
input.isWorking &&
activityTurnId === unsettledTurnId &&
cursor === input.timelineEntries.length;
cursor === input.timelineEntries.length &&
!latestToolFailed;
const groupId =
timelineEntry.kind === "work"
? workGroupId(timelineEntry.id, timelineEntry.entry)
Expand Down
88 changes: 74 additions & 14 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2606,6 +2606,7 @@ function ActivityGroupTimelineRow({
const liveWork = trailingWork.findLast(workEntryIsActiveTurnActivity) ?? trailingWork.at(-1);
const thinking = row.active && liveWork === undefined;
const iconWork = row.active ? liveWork : work.at(-1);
const failed = iconWork !== undefined && workEntryDisplayIndicatesToolFailure(iconWork);
const label = row.active
? liveWork
? liveWorkEntryLabel(liveWork, ctx.workspaceRoot, true)
Expand Down Expand Up @@ -2639,20 +2640,11 @@ function ActivityGroupTimelineRow({
if (next.kind === "message") messages.push(next.message);
}
details.push(
<ReasoningTimelineRow
<ReasoningTraceBlock
key={entry.id}
disclosureAnchorKey={row.id}
row={{
kind: "message",
id: row.active && index === row.entries.length - 1 ? LIVE_ACTIVITY_ROW_ID : entry.id,
createdAt: entry.createdAt,
message: entry.message,
reasoningMessages: messages,
durationStart: entry.createdAt,
showAssistantMeta: false,
showAssistantCopyButton: false,
assistantCopyStreaming: false,
}}
messages={messages}
live={row.active && index === row.entries.length - 1}
showHeader={work.length > 0}
/>,
);
}
Expand All @@ -2663,13 +2655,15 @@ function ActivityGroupTimelineRow({
<button
type="button"
className="group/live-work flex min-h-6 w-full max-w-full cursor-pointer items-center rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
aria-label={failed ? `${label}, tool call failed` : undefined}
aria-expanded={row.expanded}
onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
>
<LiveActivityRow
label={label}
iconName={iconWork ? workEntryIconName(iconWork) : "brain"}
toolIcon={iconWork?.toolIcon ?? iconWork?.toolSource?.icon}
failed={failed}
active={row.active}
shimmer={thinking}
/>
Expand All @@ -2691,6 +2685,72 @@ function ThinkingTimelineRow() {
);
}

/**
* Thinking inside an expanded activity group: the trace is already one click
* deep, so the text renders under its "Thought" header without another toggle.
* A thought-only group already reads "Thought" on its row, so it skips the header.
*/
function ReasoningTraceBlock({
Comment thread
maria-rcks marked this conversation as resolved.
messages,
live,
showHeader,
}: {
messages: ReadonlyArray<ChatMessage>;
live: boolean;
showHeader: boolean;
}) {
const ctx = use(TimelineRowCtx);
const { isWorking, unsettledTurnId } = use(TimelineRowActivityCtx);
const first = messages[0]!;
const streaming =
live &&
messages.some((reasoningMessage) => reasoningMessage.streaming) &&
isWorking &&
first.turnId !== null &&
first.turnId === unsettledTurnId;
if (
messages.every((reasoningMessage) => reasoningMessage.text.trim().length === 0) &&
!streaming
) {
return null;
}
const label = streaming ? "Thinking" : "Thought";
return (
<div className="flex flex-col">
{showHeader ? (
<div className="flex min-h-6 select-none items-center gap-1.5 px-0.5 py-0.5 text-sm leading-relaxed">
<span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
<BrainIcon aria-hidden className="block size-4 shrink-0 stroke-[1.8] opacity-70" />
</span>
<span
ref={streaming ? observeVisibleAnimation : undefined}
className="relative min-w-0 flex-1 truncate text-secondary-label"
>
{label}
{streaming ? <ActivityShimmerOverlay>{label}</ActivityShimmerOverlay> : null}
</span>
</div>
) : null}
<div className="ms-7 flex flex-col gap-3 px-0.5 py-1 text-foreground select-text">
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
{messages.map((reasoningMessage) => (
<ChatMarkdown
key={reasoningMessage.id}
text={reasoningMessage.text}
cwd={ctx.markdownCwd}
threadRef={ctx.threadRef ?? undefined}
isStreaming={streaming && reasoningMessage.streaming}
lineBreaks
skills={ctx.skills}
headingLevelOffset={MESSAGE_HEADING_LEVEL}
onUseArtifactTemplate={ctx.onUseArtifactTemplate}
onImageExpand={ctx.onImageExpand}
/>
))}
</div>
</div>
);
}

/**
* A provider's thinking trace. Collapsed by default: reasoning is context for
* the answer, not the answer. The open/closed flag lives on the list so it
Expand Down Expand Up @@ -2760,7 +2820,7 @@ const ReasoningTimelineRow = memo(function ReasoningTimelineRow({
</span>
</button>
{expanded ? (
<div className="mt-1 ms-7 flex max-h-96 flex-col gap-3 overflow-auto rounded-md bg-muted/40 px-3 py-2 text-secondary-label select-text">
<div className="mt-1 ms-7 flex max-h-96 flex-col gap-3 overflow-auto px-0.5 py-1 text-foreground select-text">
{messages.map((reasoningMessage) => (
<ChatMarkdown
key={reasoningMessage.id}
Expand Down
Loading