{expanded ? (
-
-
+
+ {row.messages.map((message) => (
+
+ ))}
) : null}
);
-});
+}
function CompactingLabel() {
return (
@@ -2906,43 +2773,29 @@ function WorkingTimer({ createdAt }: { createdAt: string }) {
// re-render only the affected row, not the entire list.
// ---------------------------------------------------------------------------
-/** Renders standalone activity or one bounded, virtualized expanded tool group. */
+/** Renders standalone activity; expanded entries are separate outer-list rows. */
const WorkGroupSection = memo(function WorkGroupSection({
anchorKey,
disclosureAnchorKey = anchorKey,
groupedEntries,
- isExpandedToolGroup,
displayLabel,
}: {
anchorKey: string;
disclosureAnchorKey?: string;
groupedEntries: Extract
["groupedEntries"];
- isExpandedToolGroup: boolean;
displayLabel?: string | undefined;
}) {
- const { workspaceRoot, routeThreadKey, onToggleWorkEntry } = use(TimelineRowCtx);
+ const { workspaceRoot, onToggleWorkEntry } = use(TimelineRowCtx);
const onToggleStandaloneEntry = useCallback(
(collapsed: boolean) => onToggleWorkEntry(disclosureAnchorKey, collapsed),
[disclosureAnchorKey, onToggleWorkEntry],
);
const nonEmptyEntries = useMemo(
- () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroup)),
- [groupedEntries, isExpandedToolGroup],
+ () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry)),
+ [groupedEntries],
);
if (nonEmptyEntries.length === 0) return null;
- if (isExpandedToolGroup) {
- return (
-
- );
- }
-
return (
@@ -3236,39 +3089,50 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract
ctx.onToggleWorkGroup(row.groupId, row.id)}
- >
-
- {label}
-
- {getQuestionAnswerPreview(row.entry.questionAnswer)}
+
+
+ ) : (
+ label
+ )
+ }
+ iconName={workEntryIconName(row.entry)}
+ toolIcon={row.entry.toolIcon ?? row.entry.toolSource?.icon}
+ failed={failed}
+ active={row.active}
+ />
+
+ {row.expanded ? (
+
+ ) : null}
+
);
}
@@ -3316,26 +3180,37 @@ function WorkGroupToggleTimelineRow({
}) {
const ctx = use(TimelineRowCtx);
return (
-
+ ) : null}
+
);
}
diff --git a/docs/user/activity-groups.md b/docs/user/activity-groups.md
new file mode 100644
index 000000000000..4e3a4ab4e0d0
--- /dev/null
+++ b/docs/user/activity-groups.md
@@ -0,0 +1,10 @@
+# Activity groups
+
+On web and desktop, expand a completed turn in chat to see the agent's reasoning
+and tool calls in order. Reasoning summaries read like the agent's other
+text. When a provider also exposes a full reasoning trace, open Thought
+to read it. Expand a tool summary to see its calls in a bounded list.
+
+Open a tool call to inspect its details. Those details stay open when you scroll
+away and back, or collapse and reopen the turn in the same thread view.
+Select the tool call again to close its details.
diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json
index dd6113ad6426..40bf8cb08e8c 100644
--- a/packages/client-runtime/package.json
+++ b/packages/client-runtime/package.json
@@ -3,6 +3,10 @@
"private": true,
"type": "module",
"exports": {
+ "./thread-message-presentation": {
+ "types": "./src/threadMessagePresentation.ts",
+ "default": "./src/threadMessagePresentation.ts"
+ },
"./device/stream": {
"types": "./src/device/stream.ts",
"default": "./src/device/stream.ts"
diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts
index dbee48d7808c..7c96d6f8a5da 100644
--- a/packages/client-runtime/src/state/threadReducer.test.ts
+++ b/packages/client-runtime/src/state/threadReducer.test.ts
@@ -575,6 +575,71 @@ describe("applyThreadDetailEvent", () => {
}
});
+ it("updates a streamed assistant message to a progress summary", () => {
+ const messageId = MessageId.make("assistant:progress-part");
+ const thread = {
+ ...baseThread,
+ latestTurn: {
+ turnId: TurnId.make("turn-progress"),
+ state: "running" as const,
+ requestedAt: baseThread.createdAt,
+ startedAt: baseThread.createdAt,
+ completedAt: null,
+ assistantMessageId: messageId,
+ },
+ checkpoints: [
+ {
+ turnId: TurnId.make("turn-progress"),
+ checkpointTurnCount: 1,
+ checkpointRef: CheckpointRef.make("ref-progress"),
+ status: "ready" as const,
+ files: [],
+ assistantMessageId: messageId,
+ completedAt: baseThread.updatedAt,
+ },
+ ],
+ messages: [
+ {
+ id: messageId,
+ role: "assistant" as const,
+ text: "Checking the reviews.",
+ turnId: TurnId.make("turn-progress"),
+ streaming: false,
+ createdAt: baseThread.createdAt,
+ updatedAt: baseThread.updatedAt,
+ },
+ ],
+ };
+ const result = applyThreadDetailEvent(thread, {
+ ...baseEventFields,
+ sequence: 6,
+ occurredAt: baseThread.updatedAt,
+ aggregateKind: "thread",
+ aggregateId: baseThread.id,
+ type: "thread.message-sent",
+ payload: {
+ threadId: baseThread.id,
+ messageId,
+ role: "reasoning",
+ text: "",
+ turnId: TurnId.make("turn-progress"),
+ streaming: false,
+ createdAt: baseThread.createdAt,
+ updatedAt: baseThread.updatedAt,
+ },
+ });
+
+ expect(result.kind).toBe("updated");
+ if (result.kind !== "updated") return;
+ expect(result.thread.messages[0]).toMatchObject({
+ id: messageId,
+ role: "reasoning",
+ text: "Checking the reviews.",
+ });
+ expect(result.thread.latestTurn?.assistantMessageId).toBeNull();
+ expect(result.thread.checkpoints[0]?.assistantMessageId).toBeNull();
+ });
+
it("appends a new message", () => {
const result = applyThreadDetailEvent(baseThread, {
...baseEventFields,
diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts
index 101bb34fba91..8a4515be2d00 100644
--- a/packages/client-runtime/src/state/threadReducer.ts
+++ b/packages/client-runtime/src/state/threadReducer.ts
@@ -396,6 +396,7 @@ export function applyThreadDetailEvent(
found = true;
return {
...entry,
+ role: message.role,
text: message.streaming
? `${entry.text}${message.text}`
: message.text.length > 0
@@ -409,6 +410,15 @@ export function applyThreadDetailEvent(
};
});
if (!found) messages.push(message);
+ const reclassifiedAssistant =
+ message.role === "reasoning" &&
+ thread.messages.some((entry) => entry.id === message.id && entry.role === "assistant");
+ const replacementAssistantMessageId =
+ reclassifiedAssistant && message.turnId !== null
+ ? (messages.findLast(
+ (entry) => entry.role === "assistant" && entry.turnId === message.turnId,
+ )?.id ?? null)
+ : null;
// Update latestTurn for assistant messages bound to a turn. A completed
// assistant message only settles the turn once the session is no longer
// running it — providers may emit several assistant messages per turn
@@ -420,7 +430,7 @@ export function applyThreadDetailEvent(
thread.session?.status === "running" &&
thread.session.activeTurnId === event.payload.turnId;
const settlesTurn = !event.payload.streaming && !turnStillRunning;
- const latestTurn = reuseLatestTurn(
+ const projectedLatestTurn = reuseLatestTurn(
thread.latestTurn,
event.payload.role === "assistant" &&
event.payload.turnId !== null &&
@@ -454,7 +464,7 @@ export function applyThreadDetailEvent(
// Rebind checkpoint assistant message IDs for assistant messages. The
// helper hands back the same array when the entry is already bound.
- const checkpoints =
+ const projectedCheckpoints =
event.payload.role === "assistant" && event.payload.turnId !== null
? rebindCheckpointAssistantMessage(
thread.checkpoints,
@@ -462,6 +472,17 @@ export function applyThreadDetailEvent(
event.payload.messageId,
)
: thread.checkpoints;
+ const latestTurn =
+ reclassifiedAssistant && projectedLatestTurn?.assistantMessageId === message.id
+ ? { ...projectedLatestTurn, assistantMessageId: replacementAssistantMessageId }
+ : projectedLatestTurn;
+ const checkpoints = reclassifiedAssistant
+ ? Arr.map(projectedCheckpoints, (entry) =>
+ entry.assistantMessageId === message.id
+ ? { ...entry, assistantMessageId: replacementAssistantMessageId }
+ : entry,
+ )
+ : projectedCheckpoints;
return {
kind: "updated",
diff --git a/packages/client-runtime/src/threadMessagePresentation.ts b/packages/client-runtime/src/threadMessagePresentation.ts
new file mode 100644
index 000000000000..9859911975fe
--- /dev/null
+++ b/packages/client-runtime/src/threadMessagePresentation.ts
@@ -0,0 +1,36 @@
+import { TurnId } from "@t3tools/contracts";
+
+export function unkeyedResponseTurnId(messageId: string): TurnId {
+ return TurnId.make(`unkeyed-response:${messageId}`);
+}
+
+export function isUnkeyedResponseTurnId(turnId: TurnId): boolean {
+ return turnId.startsWith("unkeyed-response:");
+}
+
+export function unsettledTurnId(
+ latestTurn: { turnId: TurnId; completedAt: string | null; state: string } | null,
+ runningTurnId: TurnId | null = null,
+): TurnId | null {
+ if (runningTurnId !== null) return runningTurnId;
+ if (!latestTurn) return null;
+ return latestTurn.completedAt !== null && latestTurn.state !== "running"
+ ? null
+ : latestTurn.turnId;
+}
+
+export function reasoningDisplayKind(
+ message: { id: string; text: string },
+ hasSummary: boolean,
+): "summary" | "raw" {
+ if (isReasoningSummaryMessage(message)) {
+ return "summary";
+ }
+ return hasSummary || message.text.length > 2_000 || message.text.split("\n", 26).length > 25
+ ? "raw"
+ : "summary";
+}
+
+export function isReasoningSummaryMessage(message: { id: string }): boolean {
+ return message.id.startsWith("reasoning:summary:") || message.id.startsWith("assistant:");
+}
diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts
index 3e323e4964d5..b223eeaf9cbe 100644
--- a/packages/contracts/src/orchestration.ts
+++ b/packages/contracts/src/orchestration.ts
@@ -560,9 +560,8 @@ export const OrchestrationProject = Schema.Struct({
});
export type OrchestrationProject = typeof OrchestrationProject.Type;
-/** `reasoning` carries a provider's thinking trace: a reasoning summary, or
- * the raw chain of thought when the model exposes one. It is a sibling of the
- * assistant text it precedes, not a replacement for it. */
+/** `reasoning` carries a provider's thinking summary, raw trace, or OpenCode
+ * continuation text. It sits alongside the assistant answer. */
export const OrchestrationMessageRole = Schema.Literals([
"user",
"assistant",
diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts
index 309b61935485..4e817cb2879d 100644
--- a/packages/contracts/src/providerRuntime.ts
+++ b/packages/contracts/src/providerRuntime.ts
@@ -82,6 +82,7 @@ export type RuntimeItemStatus = typeof RuntimeItemStatus.Type;
const RuntimeContentStreamKind = Schema.Literals([
"assistant_text",
+ "assistant_progress_text",
"reasoning_text",
"reasoning_summary_text",
"plan_text",
@@ -434,6 +435,7 @@ export const ItemLifecyclePayload = Schema.Struct({
status: Schema.optional(RuntimeItemStatus),
title: Schema.optional(TrimmedNonEmptyStringSchema),
detail: Schema.optional(TrimmedNonEmptyStringSchema),
+ presentation: Schema.optional(Schema.Literal("progress")),
toolSurface: Schema.optional(ToolActivitySurface),
toolIcon: Schema.optional(ToolActivityIcon),
toolSource: Schema.optional(ToolActivitySource),