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
8 changes: 5 additions & 3 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2513,7 +2513,7 @@ describe("buildThreadFeed", () => {
});

it.each(["tool", "failed-tool", "assistant", "turn", "unknown-turn"] as const)(
"preserves a %s boundary in expanded activity history",
"keeps thoughts in order across a %s in expanded activity history",
(boundary) => {
const turnId = TurnId.make("reasoning-boundary");
const messages: OrchestrationThread["messages"] = [1, 3].map((second) => ({
Expand Down Expand Up @@ -2576,8 +2576,10 @@ describe("buildThreadFeed", () => {
(entry) => entry.type === "message" && entry.message.role === "reasoning",
);
if (boundary === "failed-tool") {
expect(rows.filter((entry) => entry.type === "work-toggle")).toHaveLength(3);
expect(rows.some((entry) => entry.type === "work-toggle" && entry.hasFailure)).toBe(true);
// A failed call stays inside the run instead of splitting it.
expect(rows.filter((entry) => entry.type === "work-toggle")).toMatchObject([
{ hasFailure: true, hiddenCount: 3 },
]);
}
expect(reasoningRows).toEqual(
messages.map((message) => ({
Expand Down
5 changes: 1 addition & 4 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1901,10 +1901,7 @@ function activityRunTurnId(entry: ThreadFeedEntry): TurnId | null {
!isContextCompactionActivityGroup(entry) &&
!isUserInputActivityGroup(entry) &&
entry.activities.every(
(activity) =>
!activity.workEntry.agentSpawn &&
activity.workEntry.tone !== "error" &&
!workEntryIndicatesToolFailure(activity.workEntry),
(activity) => !activity.workEntry.agentSpawn && activity.workEntry.tone !== "error",
)
) {
return entry.turnId;
Expand Down
64 changes: 49 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,23 +2161,57 @@ 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.each(["failed", "declined"] as const)(
"settles the activity row while the latest tool is %s",
(status) => {
const thought = reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1");
const tool = toolEntry("last-tool", "2026-01-01T00:00:02Z", "turn-1");
const rows = deriveMessagesTimelineRows({
timelineEntries: [
thought,
{
...tool,
entry: {
...tool.entry,
command: "echo nope",
toolCallId: "last-tool",
toolLifecycleStatus: status,
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", () => {
const entries = [
reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"),
Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ export type TimelineLatestTurn = Pick<
"turnId" | "state" | "startedAt" | "completedAt"
>;

export const LIVE_ACTIVITY_ROW_ID = "live-activity-row";
const LIVE_ACTIVITY_ROW_ID = "live-activity-row";

type ActivityEntry = Extract<TimelineEntry, { kind: "message" | "work" }>;

Expand All @@ -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 @@ -391,7 +390,6 @@ export type MessagesTimelineRow =
createdAt: string;
message: ChatMessage;
durationStart: string;
reasoningMessages?: ReadonlyArray<ChatMessage>;
showAssistantMeta: boolean;
showAssistantCopyButton: boolean;
assistantCopyStreaming: boolean;
Expand Down Expand Up @@ -1158,7 +1156,9 @@ export function deriveMessagesTimelineRows(input: {
const active =
input.isWorking &&
activityTurnId === unsettledTurnId &&
cursor === input.timelineEntries.length;
cursor === input.timelineEntries.length &&
!latestToolFailed &&
(latestVisibleToolEntry === undefined || latestToolKeepsActivityLive);
const groupId =
timelineEntry.kind === "work"
? workGroupId(timelineEntry.id, timelineEntry.entry)
Expand Down
146 changes: 91 additions & 55 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,6 @@ import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssis
import {
computeStableMessagesTimelineRows,
deriveMessagesTimelineRowsWithState,
LIVE_ACTIVITY_ROW_ID,
deriveUnsettledTurnId,
type MessagesTimelineRowsProjection,
liveWorkEntryLabel,
Expand Down Expand Up @@ -2606,6 +2605,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 +2639,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 +2654,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,42 +2684,92 @@ 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 group whose row already reads "Thought" (no visible tool) 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 max-h-96 flex-col gap-3 overflow-auto px-0.5 py-1 select-text">
{messages.map((reasoningMessage) => (
<ChatMarkdown
key={reasoningMessage.id}
className="text-foreground"
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
* survives row recycling in the virtualizer.
*/
const ReasoningTimelineRow = memo(function ReasoningTimelineRow({
row,
disclosureAnchorKey = row.id,
}: {
row: Extract<TimelineRow, { kind: "message" }>;
disclosureAnchorKey?: string;
}) {
const ctx = use(TimelineRowCtx);
const { isWorking, unsettledTurnId } = use(TimelineRowActivityCtx);
const { message } = row;
const messages = row.reasoningMessages ?? [message];
// A block left open by a crashed provider or a restarted server never gets
// its completion. Only the live turn may claim to still be thinking, so a
// settled turn cannot shimmer "Thinking" at the user forever.
const streaming =
row.id === LIVE_ACTIVITY_ROW_ID &&
messages.some((reasoningMessage) => reasoningMessage.streaming) &&
isWorking &&
message.turnId !== null &&
message.turnId === unsettledTurnId;
const expanded = ctx.expandedReasoningMessageIds.has(message.id);
const { onToggleReasoning } = ctx;
const toggle = useCallback(() => {
onToggleReasoning(message.id, !expanded, disclosureAnchorKey);
}, [expanded, message.id, disclosureAnchorKey, onToggleReasoning]);
const label = `${streaming ? "Thinking" : "Thought"}${messages.length > 1 ? ` (×${messages.length})` : ""}`;
onToggleReasoning(message.id, !expanded, row.id);
}, [expanded, message.id, row.id, onToggleReasoning]);

if (
messages.every((reasoningMessage) => reasoningMessage.text.trim().length === 0) &&
!streaming
) {
if (message.text.trim().length === 0) {
return null;
}

Expand All @@ -2742,12 +2785,8 @@ const ReasoningTimelineRow = memo(function ReasoningTimelineRow({
<BrainIcon aria-hidden className="block size-4 shrink-0 stroke-[1.8] opacity-70" />
</span>
<span className="flex min-w-0 flex-1 items-center gap-1.5">
<span
ref={streaming ? observeVisibleAnimation : undefined}
className="relative min-w-0 flex-1 truncate text-secondary-label text-sm leading-relaxed"
>
{label}
{streaming ? <ActivityShimmerOverlay>{label}</ActivityShimmerOverlay> : null}
<span className="relative min-w-0 flex-1 truncate text-secondary-label text-sm leading-relaxed">
Thought
</span>
<span className="flex size-4 shrink-0 items-center justify-center" aria-hidden>
<ChevronRightIcon
Expand All @@ -2760,21 +2799,18 @@ 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">
{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 className="mt-1 ms-7 flex max-h-96 flex-col gap-3 overflow-auto px-0.5 py-1 select-text">
<ChatMarkdown
className="text-foreground"
text={message.text}
cwd={ctx.markdownCwd}
threadRef={ctx.threadRef ?? undefined}
lineBreaks
skills={ctx.skills}
headingLevelOffset={MESSAGE_HEADING_LEVEL}
onUseArtifactTemplate={ctx.onUseArtifactTemplate}
onImageExpand={ctx.onImageExpand}
/>
</div>
) : null}
</div>
Expand Down
Loading