@@ -486,7 +566,10 @@ export function MessageListView({
context={contextFromProps}
readonly={readonly}
autoFocusMessageId={autoFocusMessageId}
- collapsed={collapsedMessageIdSet.has(row.message.id)}
+ collapsed={
+ row.kind === "message" &&
+ collapsedMessageIdSet.has(row.message.id)
+ }
runValidationIssue={runValidationIssue}
/>
);
@@ -496,13 +579,20 @@ export function MessageListView({
{displayRows.map((row, index) => (
))}
@@ -563,7 +653,7 @@ export function MessageListView({
{showNavigator ? (
) : null}
@@ -585,7 +675,7 @@ function MessageRow({
measureRef?: Ref
;
virtualized?: boolean;
index: number;
- row: DisplayMessage;
+ row: DisplayRow;
context?: ThreadContext;
readonly: boolean;
autoFocusMessageId: string | null;
@@ -602,7 +692,11 @@ function MessageRow({
data-index={index}
data-message-row-index={index}
>
- {row.streaming ? (
+ {row.kind === "processGroup" ? (
+
+ ) : row.streaming ? (
) : (
{
+ toggleProcessGroupExpanded(group.id);
+ }, [group.id, toggleProcessGroupExpanded]);
+ return (
+
+
+
+ );
+}
+
+export const ProcessGroupHeader = memo(_ProcessGroupHeader);
diff --git a/packages/ui/src/components/thread-playground/message/process-groups.ts b/packages/ui/src/components/thread-playground/message/process-groups.ts
new file mode 100644
index 00000000..95b04c9a
--- /dev/null
+++ b/packages/ui/src/components/thread-playground/message/process-groups.ts
@@ -0,0 +1,203 @@
+import type { Message } from "@llm-space/core";
+import { useSyncExternalStore } from "react";
+
+import {
+ LOCAL_STORAGE_KEYS,
+ readLocalStorage,
+ writeLocalStorage,
+} from "@llm-space/ui/lib/local-storage";
+
+/**
+ * Cross-message "process group" grouping, derived purely from the message
+ * list. A group is a consecutive run of assistant process steps (see
+ * `isProcessMessage`: tool-carrying messages, with or without commentary
+ * text, plus text-free thinking/provider-hosted-activity messages) closed by
+ * the run's result: an assistant message whose text answers the request. A
+ * user message ends the candidate span but does NOT qualify it — steps from an
+ * interrupted or abandoned run (no final answer was produced) stay expanded
+ * with their failure context visible, exactly like a trailing run at the end
+ * of the list.
+ *
+ * This is a view concept: grouping never touches the stored thread messages,
+ * drag-reorder indices, or persistence.
+ */
+export interface ProcessGroup {
+ /** Stable id: the first member message id. */
+ id: string;
+ /** Member messages, in display order. */
+ messages: Message[];
+ /** Total tool calls across all members. */
+ toolCallCount: number;
+ /** Name of the last tool called in the group, if any. */
+ lastToolName: string | null;
+ /** Tool calls across all members whose output is an error. */
+ errorCount: number;
+}
+
+/** A group located in the message list: members occupy `[start, end)`. */
+export interface ProcessGroupSpan {
+ start: number;
+ end: number;
+ group: ProcessGroup;
+}
+
+function _hasTextBody(message: Message): boolean {
+ return message.content.some(
+ (content) => content.type === "text" && content.text.length > 0
+ );
+}
+
+/**
+ * Whether the message is a "process" step of a run.
+ *
+ * Real provider traffic puts running commentary ("Let me check…") on the same
+ * assistant message as its tool calls, so a client-side tool call marks the
+ * whole message as process: the auto-run loop always continues after tool
+ * calls, so that text is never the run's result. Without tool calls, a
+ * message only counts as process when it has no text body at all (pure
+ * thinking, or provider-hosted activities) — an assistant message whose text
+ * is the answer, including the provider-hosted "activities + answer" shape,
+ * stays a result.
+ */
+export function isProcessMessage(message: Message): boolean {
+ if (message.role !== "assistant") {
+ return false;
+ }
+ if ((message.toolCalls?.length ?? 0) > 0) {
+ return true;
+ }
+ if (_hasTextBody(message)) {
+ return false;
+ }
+ return (
+ Boolean(message.thinking) ||
+ (message.providerHostedToolActivities?.length ?? 0) > 0
+ );
+}
+
+function _summarizeGroup(
+ messages: Message[],
+ start: number,
+ end: number
+): ProcessGroup {
+ const members = messages.slice(start, end);
+ let toolCallCount = 0;
+ let errorCount = 0;
+ let lastToolName: string | null = null;
+ for (const message of members) {
+ if (message.role !== "assistant") continue;
+ for (const toolCall of message.toolCalls ?? []) {
+ toolCallCount += 1;
+ if (toolCall.output?.isError) {
+ errorCount += 1;
+ }
+ lastToolName = toolCall.input.name;
+ }
+ }
+ return {
+ id: members[0].id,
+ messages: members,
+ toolCallCount,
+ lastToolName,
+ errorCount,
+ };
+}
+
+/**
+ * Find every groupable run of process messages. A run is only returned when
+ * it is closed by the run's result — an assistant message with a text body.
+ * Anything else (a user message, an empty assistant message) ends the
+ * candidate span without grouping it, and runs reaching the end of the list
+ * stay ungrouped.
+ */
+export function findProcessGroupSpans(
+ messages: readonly Message[]
+): ProcessGroupSpan[] {
+ const spans: ProcessGroupSpan[] = [];
+ let start = -1;
+ for (let index = 0; index < messages.length; index++) {
+ const message = messages[index];
+ if (isProcessMessage(message)) {
+ if (start === -1) {
+ start = index;
+ }
+ continue;
+ }
+ if (start === -1) {
+ continue;
+ }
+ // A non-member message closes the run. Only a result (assistant text)
+ // makes it eligible to collapse; a user message following unfinished
+ // steps keeps those steps expanded as failure context.
+ const isResult = message.role === "assistant" && _hasTextBody(message);
+ if (isResult) {
+ spans.push({
+ start,
+ end: index,
+ group: _summarizeGroup([...messages], start, index),
+ });
+ }
+ start = -1;
+ }
+ return spans;
+}
+
+// --- Display rows -----------------------------------------------------------
+
+export type DisplayRow =
+ | { kind: "message"; message: Message; streaming: boolean }
+ | { kind: "processGroup"; group: ProcessGroup; collapsed: boolean };
+
+/**
+ * The collapsed group hiding `messageId`, if any. Used to reveal a group
+ * before scrolling to (or focusing) a target inside it — autofocus and
+ * run-validation targets must never stay unmounted behind their header row.
+ */
+export function findCollapsedGroupIdForMessage(
+ rows: readonly DisplayRow[],
+ messageId: string
+): string | null {
+ for (const row of rows) {
+ if (
+ row.kind === "processGroup" &&
+ row.collapsed &&
+ row.group.messages.some((message) => message.id === messageId)
+ ) {
+ return row.group.id;
+ }
+ }
+ return null;
+}
+
+// --- User preference --------------------------------------------------------
+
+const listeners = new Set<() => void>();
+
+/**
+ * Whether cross-message process groups should collapse once their run
+ * finishes. Defaults to on ("result first"); persisted app-wide.
+ */
+export function getCollapseProcessGroups(): boolean {
+ return readLocalStorage(LOCAL_STORAGE_KEYS.collapseProcessGroups) !== "false";
+}
+
+export function setCollapseProcessGroups(value: boolean): void {
+ writeLocalStorage(
+ LOCAL_STORAGE_KEYS.collapseProcessGroups,
+ value ? "true" : "false"
+ );
+ for (const listener of listeners) {
+ listener();
+ }
+}
+
+function _subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+export function useCollapseProcessGroups(): boolean {
+ return useSyncExternalStore(_subscribe, getCollapseProcessGroups, () => true);
+}
diff --git a/packages/ui/src/components/thread-playground/playground-labels.tsx b/packages/ui/src/components/thread-playground/playground-labels.tsx
index 65977d2b..84192720 100644
--- a/packages/ui/src/components/thread-playground/playground-labels.tsx
+++ b/packages/ui/src/components/thread-playground/playground-labels.tsx
@@ -229,6 +229,11 @@ export interface PlaygroundLabels {
previewText: string;
noText: string;
};
+ /** Collapsed cross-message process group headers. */
+ processGroups: {
+ lastTool: (name: string) => string;
+ errors: (count: number) => string;
+ };
messages: {
user: string;
assistant: string;
@@ -633,6 +638,10 @@ export const DEFAULT_PLAYGROUND_LABELS: PlaygroundLabels = {
previewText: "Preview text content",
noText: "No text content",
},
+ processGroups: {
+ lastTool: (name) => `Last: ${name}`,
+ errors: (count) => `${count} failed`,
+ },
messages: {
user: "User",
assistant: "Assistant",
diff --git a/packages/ui/src/components/thread-playground/stores/thread-store.ts b/packages/ui/src/components/thread-playground/stores/thread-store.ts
index e60efac4..18d78118 100644
--- a/packages/ui/src/components/thread-playground/stores/thread-store.ts
+++ b/packages/ui/src/components/thread-playground/stores/thread-store.ts
@@ -116,6 +116,13 @@ export interface ThreadState {
/** Auto-executing tool calls for in-flight UI feedback; never persisted. */
executingToolCallIds: string[];
collapsedMessageIds: string[];
+ /**
+ * Cross-message process groups (see `process-groups.ts`) the user manually
+ * expanded; a group id is its first member message id. Groups are collapsed
+ * by default once their run finishes, so this only ever holds expanded ids.
+ * Store-only; never persisted.
+ */
+ expandedProcessGroupIds: string[];
runValidationIssue: RunValidationIssue | null;
/**
* Id of the message whose editor should grab focus on mount — set only by
@@ -196,6 +203,7 @@ export interface ThreadState {
removeTool(name: string): void;
toggleMessageRole(id: string): void;
toggleMessageCollapsed(id: string): void;
+ toggleProcessGroupExpanded(groupId: string): void;
abort(): void;
}
@@ -678,6 +686,7 @@ export function createThreadStore(
activeRunId: null,
executingToolCallIds: [],
collapsedMessageIds: [],
+ expandedProcessGroupIds: [],
runValidationIssue: null,
autoFocusMessageId: null,
changeHistory: createInitialHistory(normalizedInitialThread),
@@ -738,12 +747,20 @@ export function createThreadStore(
},
removeMessage(id: string) {
updateMessages((messages) => messages.filter((m) => m.id !== id));
- const { collapsedMessageIds } = get();
- if (collapsedMessageIds.includes(id)) {
+ const { collapsedMessageIds, expandedProcessGroupIds } = get();
+ const nextCollapsed = collapsedMessageIds.includes(id)
+ ? collapsedMessageIds.filter((cid) => cid !== id)
+ : collapsedMessageIds;
+ const nextExpanded = expandedProcessGroupIds.includes(id)
+ ? expandedProcessGroupIds.filter((gid) => gid !== id)
+ : expandedProcessGroupIds;
+ if (
+ nextCollapsed !== collapsedMessageIds ||
+ nextExpanded !== expandedProcessGroupIds
+ ) {
set({
- collapsedMessageIds: collapsedMessageIds.filter(
- (cid) => cid !== id
- ),
+ collapsedMessageIds: nextCollapsed,
+ expandedProcessGroupIds: nextExpanded,
});
}
},
@@ -1075,6 +1092,14 @@ export function createThreadStore(
: [...collapsedMessageIds, id],
});
},
+ toggleProcessGroupExpanded(groupId: string) {
+ const { expandedProcessGroupIds } = get();
+ set({
+ expandedProcessGroupIds: expandedProcessGroupIds.includes(groupId)
+ ? expandedProcessGroupIds.filter((id) => id !== groupId)
+ : [...expandedProcessGroupIds, groupId],
+ });
+ },
async run(fromMessageId?: string) {
if (get().status !== "idle") {
throw new Error("Thread is already running");
@@ -1862,6 +1887,7 @@ const selectActions = (s: ThreadState) => ({
removeTool: s.removeTool,
toggleMessageRole: s.toggleMessageRole,
toggleMessageCollapsed: s.toggleMessageCollapsed,
+ toggleProcessGroupExpanded: s.toggleProcessGroupExpanded,
});
export function useThreadStoreActions() {
return useStore(useThreadStoreApi(), useShallow(selectActions));
diff --git a/packages/ui/src/lib/local-storage.ts b/packages/ui/src/lib/local-storage.ts
index ea0dedd6..679ae9e2 100644
--- a/packages/ui/src/lib/local-storage.ts
+++ b/packages/ui/src/lib/local-storage.ts
@@ -12,6 +12,7 @@ export const LOCAL_STORAGE_KEYS = {
renderingFidelity: "llm-space-rendering-fidelity",
autoRunTools: "llm-space-auto-run-tools",
reactLoop: "llm-space-react-loop",
+ collapseProcessGroups: "llm-space-collapse-process-groups",
messageStatsSummaryMode: "llm-space-message-stats-summary-mode",
language: "llm-space-language",
landingLanguage: "llm-space-lang",
diff --git a/packages/ui/tests/components/thread-playground/message/process-groups.test.ts b/packages/ui/tests/components/thread-playground/message/process-groups.test.ts
new file mode 100644
index 00000000..645b4e97
--- /dev/null
+++ b/packages/ui/tests/components/thread-playground/message/process-groups.test.ts
@@ -0,0 +1,337 @@
+import { describe, expect, test } from "bun:test";
+
+import type { AssistantMessage, Message } from "@llm-space/core";
+
+import { resolveDisplayRows } from "../../../../src/components/thread-playground/message/display-messages";
+import {
+ findCollapsedGroupIdForMessage,
+ findProcessGroupSpans,
+ isProcessMessage,
+} from "../../../../src/components/thread-playground/message/process-groups";
+
+function user(id: string, text = "Go"): Message {
+ return { id, role: "user", content: [{ type: "text", text }] };
+}
+
+function assistantWithText(id: string, text = "Answer"): AssistantMessage {
+ return { id, role: "assistant", content: [{ type: "text", text }] };
+}
+
+function toolCall(id: string, name: string, isError?: boolean) {
+ return {
+ id,
+ input: { name, arguments: {} },
+ ...(isError === undefined
+ ? {}
+ : {
+ output: { content: [{ type: "text" as const, text: "" }], isError },
+ }),
+ };
+}
+
+function processMessage(
+ id: string,
+ options: {
+ thinking?: string;
+ toolCalls?: ReturnType[];
+ /** Running commentary text on the same message as the tool calls. */
+ text?: string;
+ providerHostedToolActivities?: { type: string }[];
+ } = {}
+): AssistantMessage {
+ return {
+ id,
+ role: "assistant",
+ content: options.text ? [{ type: "text", text: options.text }] : [],
+ ...(options.thinking ? { thinking: options.thinking } : {}),
+ ...(options.toolCalls ? { toolCalls: options.toolCalls } : {}),
+ ...(options.providerHostedToolActivities
+ ? {
+ providerHostedToolActivities:
+ options.providerHostedToolActivities as AssistantMessage["providerHostedToolActivities"],
+ }
+ : {}),
+ };
+}
+
+describe("isProcessMessage", () => {
+ test("matches assistant messages without a text body but with process", () => {
+ expect(isProcessMessage(processMessage("p", { thinking: "hmm" }))).toBe(
+ true
+ );
+ expect(
+ isProcessMessage(
+ processMessage("p", { toolCalls: [toolCall("t", "bash")] })
+ )
+ ).toBe(true);
+ });
+
+ test("rejects user messages and assistant messages with a text body", () => {
+ expect(isProcessMessage(user("u"))).toBe(false);
+ expect(isProcessMessage(assistantWithText("a"))).toBe(false);
+ expect(isProcessMessage({ id: "e", role: "assistant", content: [] })).toBe(
+ false
+ );
+ });
+});
+
+describe("findProcessGroupSpans", () => {
+ test("groups consecutive process messages closed by a result", () => {
+ const messages = [
+ user("u1"),
+ processMessage("p1", { thinking: "hmm" }),
+ processMessage("p2", { toolCalls: [toolCall("t1", "web_search")] }),
+ assistantWithText("result"),
+ ];
+ const spans = findProcessGroupSpans(messages);
+ expect(spans).toHaveLength(1);
+ expect(spans[0].start).toBe(1);
+ expect(spans[0].end).toBe(3);
+ expect(spans[0].group.id).toBe("p1");
+ expect(spans[0].group.toolCallCount).toBe(1);
+ expect(spans[0].group.lastToolName).toBe("web_search");
+ });
+
+ test("a user message ends the span without collapsing unfinished steps", () => {
+ // Interrupted run: the model called a tool, the run never produced a
+ // result, and the user moved on with a follow-up. The steps stay
+ // expanded as failure context.
+ const messages = [
+ processMessage("p1", { toolCalls: [toolCall("t1", "bash")] }),
+ user("u2"),
+ ];
+ expect(findProcessGroupSpans(messages)).toEqual([]);
+ });
+
+ test("a later successful run groups without pulling in the interrupted one", () => {
+ const messages = [
+ processMessage("p1", { toolCalls: [toolCall("t1", "bash")] }),
+ user("u2", "actually, try again"),
+ processMessage("p2", { toolCalls: [toolCall("t2", "bash")] }),
+ assistantWithText("result"),
+ ];
+ const spans = findProcessGroupSpans(messages);
+ expect(spans).toHaveLength(1);
+ expect(spans[0].start).toBe(2);
+ expect(spans[0].end).toBe(3);
+ expect(spans[0].group.id).toBe("p2");
+ });
+
+ test("never groups a trailing run without a result", () => {
+ const messages = [
+ user("u1"),
+ processMessage("p1", { thinking: "hmm" }),
+ processMessage("p2", { toolCalls: [toolCall("t1", "bash")] }),
+ ];
+ expect(findProcessGroupSpans(messages)).toEqual([]);
+ });
+
+ test("skips members with a text body and splits groups", () => {
+ const messages = [
+ processMessage("p1", { thinking: "hmm" }),
+ assistantWithText("result"),
+ processMessage("p2", { thinking: "more" }),
+ assistantWithText("result2"),
+ ];
+ const spans = findProcessGroupSpans(messages);
+ expect(spans).toHaveLength(2);
+ expect(spans.map((span) => span.group.id)).toEqual(["p1", "p2"]);
+ });
+
+ test("counts errors across members", () => {
+ const messages = [
+ processMessage("p1", {
+ toolCalls: [
+ toolCall("t1", "bash", true),
+ toolCall("t2", "bash", false),
+ ],
+ }),
+ processMessage("p2", { toolCalls: [toolCall("t3", "read", true)] }),
+ assistantWithText("result"),
+ ];
+ const spans = findProcessGroupSpans(messages);
+ expect(spans[0].group.errorCount).toBe(2);
+ expect(spans[0].group.toolCallCount).toBe(3);
+ expect(spans[0].group.lastToolName).toBe("read");
+ });
+});
+
+describe("resolveDisplayRows", () => {
+ const messages = [
+ user("u1"),
+ processMessage("p1", { thinking: "hmm" }),
+ processMessage("p2", { toolCalls: [toolCall("t1", "web_search")] }),
+ assistantWithText("result"),
+ ];
+
+ test("wraps a completed group into one collapsed header row", () => {
+ const rows = resolveDisplayRows(messages, null, false, {
+ groupingEnabled: true,
+ });
+ expect(rows).toHaveLength(3);
+ expect(rows[1]).toMatchObject({
+ kind: "processGroup",
+ collapsed: true,
+ group: { id: "p1", toolCallCount: 1 },
+ });
+ });
+
+ test("keeps expanded groups as header row plus member rows", () => {
+ const rows = resolveDisplayRows(messages, null, false, {
+ groupingEnabled: true,
+ expandedGroupIds: ["p1"],
+ });
+ expect(rows).toHaveLength(5);
+ expect(rows[1]).toMatchObject({ kind: "processGroup", collapsed: false });
+ expect(rows[2]).toMatchObject({ kind: "message" });
+ expect(rows[3]).toMatchObject({ kind: "message" });
+ expect(rows[4]).toMatchObject({ kind: "message" });
+ });
+
+ test("renders plain rows when grouping is disabled", () => {
+ for (const options of [{ groupingEnabled: false }, undefined] as const) {
+ const rows = resolveDisplayRows(messages, null, false, options);
+ expect(rows).toHaveLength(4);
+ expect(rows.every((row) => row.kind === "message")).toBe(true);
+ }
+ });
+
+ test("keeps older groups collapsed while a new run is in flight", () => {
+ // Regression (PR #166 review): starting a run must not re-expand the
+ // whole conversation. The completed first run collapses to its header;
+ // the new run's user message and streaming assistant render normally.
+ const conversation = [
+ user("u1"),
+ processMessage("p1", { toolCalls: [toolCall("t1", "web_search")] }),
+ assistantWithText("r1"),
+ user("u2", "and another thing"),
+ ];
+ const rows = resolveDisplayRows(conversation, null, true, {
+ groupingEnabled: true,
+ });
+ // user + collapsed group + result + user + live streaming row
+ expect(rows).toHaveLength(5);
+ expect(rows[1]).toMatchObject({ kind: "processGroup", collapsed: true });
+ expect(rows[2]).toMatchObject({ kind: "message" });
+ expect(rows[3]).toMatchObject({ kind: "message" });
+ expect(rows[4]).toMatchObject({ kind: "message", streaming: true });
+ });
+
+ test("never wraps the live streaming preview row", () => {
+ const rows = resolveDisplayRows(
+ [user("u1"), processMessage("p1", { thinking: "hmm" })],
+ "streaming",
+ true,
+ { groupingEnabled: true }
+ );
+ expect(rows.every((row) => row.kind === "message")).toBe(true);
+ });
+});
+
+describe("findCollapsedGroupIdForMessage", () => {
+ const conversation = [
+ user("u1"),
+ processMessage("p1", { toolCalls: [toolCall("t1", "bash")] }),
+ assistantWithText("r1"),
+ ];
+
+ test("finds the collapsed group hiding a member", () => {
+ const rows = resolveDisplayRows(conversation, null, false, {
+ groupingEnabled: true,
+ });
+ expect(findCollapsedGroupIdForMessage(rows, "p1")).toBe("p1");
+ });
+
+ test("returns null for visible rows and expanded groups", () => {
+ const rows = resolveDisplayRows(conversation, null, false, {
+ groupingEnabled: true,
+ });
+ expect(findCollapsedGroupIdForMessage(rows, "u1")).toBeNull();
+ expect(findCollapsedGroupIdForMessage(rows, "r1")).toBeNull();
+ const expanded = resolveDisplayRows(conversation, null, false, {
+ groupingEnabled: true,
+ expandedGroupIds: ["p1"],
+ });
+ expect(findCollapsedGroupIdForMessage(expanded, "p1")).toBeNull();
+ });
+});
+
+describe("real-provider process shapes", () => {
+ test("a tool call marks the message as process even with commentary text", () => {
+ expect(
+ isProcessMessage(
+ processMessage("p", {
+ text: "Let me check the repo first.",
+ toolCalls: [toolCall("t", "bash")],
+ })
+ )
+ ).toBe(true);
+ });
+
+ test("provider-hosted activities with the answer text stay a result", () => {
+ expect(
+ isProcessMessage(
+ processMessage("a", {
+ text: "Here is what I found.",
+ providerHostedToolActivities: [{ type: "web_search" }],
+ })
+ )
+ ).toBe(false);
+ });
+
+ test("real ReAct traffic groups: text+tools intermediates before a text result", () => {
+ // The shape every real thread produced: each intermediate assistant
+ // message carries commentary text AND tool calls, and only the last
+ // message of the segment is text-only.
+ const messages = [
+ user("u1", "Add rate limiting"),
+ processMessage("a1", {
+ text: "Locating the login route.",
+ toolCalls: [toolCall("t1", "bash")],
+ }),
+ processMessage("a2", {
+ text: "Reading the middleware.",
+ toolCalls: [toolCall("t2", "read")],
+ }),
+ processMessage("a3", {
+ text: "Adding the limiter.",
+ toolCalls: [toolCall("t3", "edit_file")],
+ }),
+ assistantWithText("a4", "Added the limiter and a test."),
+ ];
+ const spans = findProcessGroupSpans(messages);
+ expect(spans).toHaveLength(1);
+ expect(spans[0].start).toBe(1);
+ expect(spans[0].end).toBe(4);
+ expect(spans[0].group.id).toBe("a1");
+ expect(spans[0].group.toolCallCount).toBe(3);
+ expect(spans[0].group.lastToolName).toBe("edit_file");
+
+ const rows = resolveDisplayRows(messages, null, false, {
+ groupingEnabled: true,
+ });
+ // user + collapsed group + result
+ expect(rows).toHaveLength(3);
+ expect(rows[1]).toMatchObject({ kind: "processGroup", collapsed: true });
+ });
+
+ test("hand-authored teaching threads (text-only steps) never group", () => {
+ const messages = [
+ user("u1"),
+ assistantWithText("a1", "Expected partial answer"),
+ assistantWithText("a2", "Expected final answer"),
+ ];
+ expect(findProcessGroupSpans(messages)).toEqual([]);
+ });
+
+ test("a trailing text+tools message with no result stays ungrouped", () => {
+ const messages = [
+ user("u1"),
+ processMessage("a1", {
+ text: "Running the check.",
+ toolCalls: [toolCall("t1", "bash")],
+ }),
+ ];
+ expect(findProcessGroupSpans(messages)).toEqual([]);
+ });
+});
diff --git a/specs/pr-plans/0001-collapsible-process-groups.md b/specs/pr-plans/0001-collapsible-process-groups.md
new file mode 100644
index 00000000..3eb5506a
--- /dev/null
+++ b/specs/pr-plans/0001-collapsible-process-groups.md
@@ -0,0 +1,109 @@
+# PR 计划 0001:跨消息「过程块」自动折叠(结果优先)
+> 状态:**已实现(2026-09-08 核对)**:`packages/ui` 的 process-groups / run-timeline-marks 已落地(含 `general.collapseProcessGroups` 设置项),测试 `process-groups.test.ts`、`run-timeline-marks.test.ts` 通过。改动在工作树,未提交。
+
+> 状态:计划(未写代码)
+> 目标形态:agent 产品常见体验——跑一轮时中间过程(思维 + 工具调用)正常显示,最终结果输出后自动收起成一栏,默认只看到答案。
+
+## 1. 背景与目标
+
+当前一条 assistant 消息的渲染顺序(`packages/ui/src/components/thread-playground/message/message-list-item.tsx`):
+
+1. `thinking` 思维过程(263-265)
+2. `providerHostedToolActivities` 托管工具活动(266-272)
+3. 图片(273-277)
+4. **`content[text]` 正文,即最终结果**(278-301)
+5. 引用(302-304)
+6. **`toolCalls` 工具调用 + 结果**(305-327)
+
+一次带工具的多轮运行会在消息流里留下若干条「中间消息」(只有 thinking + toolCalls,没有正文),最后一条才是答案。现在这些中间消息全部平铺展开,用户要翻很久才能看到结果。
+
+**目标**:最终结果出现后,把本轮前面这段连续的中间消息自动折叠成一个可展开的栏,默认视图只留答案。折叠栏标题按约定显示 **「N 次工具调用」+ 最后一个工具名**。
+
+## 2. 现状(代码事实)
+
+| 关注点 | 位置 | 现状 |
+| --- | --- | --- |
+| 消息数据 | `stores/thread-store.ts` | `thread.context.messages`;`collapsedMessageIds: string[]`(声明 118,初值 680) |
+| 单条折叠 | `toggleMessageCollapsed(id)`(1070-1077) | 仅运行时,**不持久化**;`message-list-item-header.tsx` 111-116 触发 |
+| 折叠组件 | `packages/ui/src/ui/collapsible-content.tsx` | 现成可用 |
+| 虚拟化列表 | `message/message-list-view.tsx` | `@tanstack/react-virtual`,**动态测量**:`measureElement` 227-240、`MESSAGE_HEIGHT_CACHE` 63、`_estimateMessageHeight` 81-108(折叠态估高 56),超过 20 条才虚拟化(60) |
+| 派生显示层 | `display-messages.ts`(`resolveDisplayMessages`) | 视图派生的现成位置,**适合放分组逻辑** |
+| 运行状态 | `ThreadState.status` | `"idle" \| "preparing" \| "running"`(108/113);`finalizeActiveRun` 1219-1303 末尾置 `idle`(1295) |
+| 工具汇总 | `packages/core/src/thread/tool-call-status.ts` | `summarizeToolCalls` 40-63 → `{ totalCount, readyCount, errorCount, canContinue }` |
+| 工具名 / 失败 | 同上 | `toolCall.input.name`;`toolCall.output?.isError`(33-35) |
+
+## 3. 设计方案
+
+### 3.1 分组规则(核心判定)
+
+在**派生层**(`display-messages.ts`)分组,不动 store 里的规范化 messages——分组是视图概念,与拖拽、持久化、streaming 解耦。
+
+```
+过程组成员 = assistant 消息 且 无正文 text 且 (有 thinking 或 有 toolCalls)
+分组结束 = 遇到 user 消息,或遇到有正文的 assistant 消息(即结果)
+```
+
+这条规则不需要任何「轮次」元数据,也不依赖运行时侧的改动。
+
+### 3.2 折叠时机
+
+- **运行中**(`status === "running"` 或该组处于 streaming):保持展开,避免流式输出跳动。
+- **运行结束**(`status` 由 `running → idle`,即 `finalizeActiveRun` 收尾):把新完成的过程组标记为折叠。
+- 用户手动展开过的组,记住其状态(不因下一次运行被强制收起)。
+
+折叠状态放哪:**新增 `collapsedGroupIds`**(store,运行时)+ 可选 localStorage 持久化用户偏好。组的 id 建议用首条消息 id(稳定、可复现),避免随机 id 导致状态漂移。
+
+### 3.3 折叠栏(组头)
+
+```
+[›] 3 次工具调用 · 最后:web_search ← 成功态
+[›] 3 次工具调用 · 最后:bash · 1 步失败 ← 失败态(errorCount > 0,用警示色)
+```
+
+- N = 组内所有 `toolCalls` 累计(用 `summarizeToolCalls` 的 `totalCount`)
+- 最后工具名 = 组内最后一条 `toolCalls.at(-1).input.name`
+- 展开后恢复逐条渲染,与现在完全一致(含可编辑的工具结果)
+
+### 3.4 虚拟化适配(最大技术风险点)
+
+`@tanstack/react-virtual` 用动态测量。处理办法:
+
+- **折叠态:整个组只渲染一个组头行**,高度固定(复用折叠态估高 56 的量级),不渲染成员;
+- **展开态:组头 + 成员逐条渲染**,成员仍走现有测量逻辑。
+
+改动点:`display-messages.ts` 产出分组结构 → `message-list-view.tsx` 的 `estimateSize` / `measureElement` 区分「组头行」与「普通消息行」→ `MessageRow` 增加组头分支。
+
+**不要让折叠态的成员以 `height: 0` 留在 DOM 里**——那会污染虚拟化测量,也会让可编辑的 CodeEditor 仍然挂载。
+
+### 3.5 开关与文案
+
+- 新增 localStorage 键(`packages/ui/src/lib/local-storage.ts`):`collapseProcessGroups`
+- 设置入口:`settings/general-page.tsx` 用 `SettingsToggleRow`;默认开启
+- i18n:`apps/desktop/src/i18n/messages.ts` 的 en/zh 两棵树同步新增(有测试强制结构一致)
+
+## 4. 涉及文件
+
+- `packages/ui/src/components/thread-playground/message/display-messages.ts`(分组逻辑)
+- `packages/ui/src/components/thread-playground/message/message-list-view.tsx`(虚拟化适配)
+- `packages/ui/src/components/thread-playground/message/` 新增 `process-group-header.tsx`(组头组件)
+- `packages/ui/src/components/thread-playground/stores/thread-store.ts`(`collapsedGroupIds` + 折叠触发)
+- `packages/ui/src/lib/local-storage.ts`(开关键)
+- `apps/desktop/src/components/settings/general-page.tsx`(开关 UI)
+- `apps/desktop/src/i18n/messages.ts`(文案)
+- 新增测试:`packages/ui/tests/components/thread-playground/message/process-groups.test.ts`
+
+## 5. 风险与待定
+
+1. **虚拟化测量**:最可能出 bug 的地方,需要在长对话(>50 条、多组)下手动验证滚动位置与高度缓存。
+2. **可编辑的工具结果**:现在工具结果 inline 可编辑(CodeEditor,默认展开)。自动收起会打断「改参数重跑」的工作流 → 只在运行结束后折叠,且用户展开即恢复编辑能力。
+3. **消息拖拽 / 删除**:分组后若消息支持拖拽排序,组头与成员的操作需要明确(建议:组作为一个整体不可拆,删除组 = 删除全部成员)。
+4. **纯工具轮无结果**:若一轮跑完没有任何正文(例如被中断),该组是否也折叠?建议:**不折叠**(没有结果就无所谓「结果优先」,且用户需要看到失败现场)。
+
+## 6. 验证
+
+- 单元测试:分组规则(各种边界:连续 / 被 user 消息打断 / 混合正文 / 空组);折叠时机(running → idle)。
+- 手动:长对话滚动、展开/收起、运行中途不折叠、失败工具组的警示态。
+
+## 7. 工作量
+
+中(2-3 天)。逻辑本身不大,成本主要在虚拟化适配与边界态验证。