@@ -486,7 +541,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 +554,20 @@ export function MessageListView({
{displayRows.map((row, index) => (
))}
@@ -563,7 +628,7 @@ export function MessageListView({
{showNavigator ? (
) : null}
@@ -585,7 +650,7 @@ function MessageRow({
measureRef?: Ref
;
virtualized?: boolean;
index: number;
- row: DisplayMessage;
+ row: DisplayRow;
context?: ThreadContext;
readonly: boolean;
autoFocusMessageId: string | null;
@@ -602,7 +667,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..d8f803f6
--- /dev/null
+++ b/packages/ui/src/components/thread-playground/message/process-groups.ts
@@ -0,0 +1,180 @@
+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
+ * a terminator: a user message or an assistant message whose text is the
+ * result. A trailing member run with no result after it (e.g. an aborted
+ * run) is deliberately never grouped: there is no result to prioritise and
+ * the user needs to see the failure site.
+ *
+ * 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 a terminator — a user message or an assistant message with a
+ * text body (the result). Runs ending at the list's end 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; the group is only kept when the
+ // closer is a user message or an assistant message with a result body.
+ const isTerminator =
+ message.role === "user" ||
+ (message.role === "assistant" && _hasTextBody(message));
+ if (isTerminator) {
+ 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 };
+
+// --- 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..be8dbcad
--- /dev/null
+++ b/packages/ui/tests/components/thread-playground/message/process-groups.test.ts
@@ -0,0 +1,276 @@
+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 {
+ 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("closes a group on a following user message", () => {
+ const messages = [
+ processMessage("p1", { toolCalls: [toolCall("t1", "bash")] }),
+ user("u2"),
+ ];
+ const spans = findProcessGroupSpans(messages);
+ expect(spans).toHaveLength(1);
+ expect(spans[0].group.id).toBe("p1");
+ });
+
+ 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 while running or 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);
+ }
+ const running = resolveDisplayRows(messages, "p2", true, {
+ groupingEnabled: true,
+ });
+ expect(running.every((row) => row.kind === "message")).toBe(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("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 天)。逻辑本身不大,成本主要在虚拟化适配与边界态验证。
From c0517dea24997138a2489f2597bd1049a9a97061 Mon Sep 17 00:00:00 2001
From: zcai7675-bot <233973883+.zcai7675-bot@users.noreply.github.com>
Date: Wed, 9 Sep 2026 12:58:08 +0800
Subject: [PATCH 2/4] fix(ui): address PR #166 review
- Keep historical process groups collapsed while a new run is in flight:
grouping is now purely structural instead of disabled for the whole
conversation while preparing/running, so a long thread's list height and
reading position no longer shift mid-run. A new run's steps stay expanded
because they form a trailing, unterminated span.
- Reveal a collapsed group before scrolling/focusing a run-validation or
autofocus target inside it. Hidden members no longer map to their header
row (they map to nothing), and a small effect expands the owning group so
the target row mounts before the scroll/focus effects run.
- A user message now only ends a candidate span instead of qualifying it:
only an assistant message with a result body makes a group collapsible, so
steps from an interrupted run (followed by a user message) stay expanded
as failure context, matching the stated goal.
Tests: interrupted-run grouping, older-group stability during a run, and
findCollapsedGroupIdForMessage lookup.
---
.../message/message-list-view.tsx | 49 +++++++++----
.../message/process-groups.ts | 47 ++++++++----
.../message/process-groups.test.ts | 71 +++++++++++++++++--
3 files changed, 138 insertions(+), 29 deletions(-)
diff --git a/packages/ui/src/components/thread-playground/message/message-list-view.tsx b/packages/ui/src/components/thread-playground/message/message-list-view.tsx
index 6f5679ac..1792df4a 100644
--- a/packages/ui/src/components/thread-playground/message/message-list-view.tsx
+++ b/packages/ui/src/components/thread-playground/message/message-list-view.tsx
@@ -52,7 +52,11 @@ import { resolveMessageMove } from "./message-move";
import { MessageNavigator } from "./message-navigator";
import { followMessageViewportBottom } from "./message-scroll-stability";
import { ProcessGroupHeader } from "./process-group-header";
-import { type DisplayRow, useCollapseProcessGroups } from "./process-groups";
+import {
+ type DisplayRow,
+ findCollapsedGroupIdForMessage,
+ useCollapseProcessGroups,
+} from "./process-groups";
import { findCenteredVirtualItemIndex } from "./virtual-item-center";
import { measureVirtualRowHeight } from "./virtual-row-measurement";
@@ -150,6 +154,7 @@ export function MessageListView({
consumeAutoFocusMessage,
moveMessage,
resolveRunValidationIssue,
+ toggleProcessGroupExpanded,
} = useThreadStoreActions();
const [dragging, setDragging] = useState(false);
const [activeMessageId, setActiveMessageId] = useState(null);
@@ -163,9 +168,12 @@ export function MessageListView({
[messagesFromProps, storeMessages]
);
const readonly = readonlyFromProps || isSnapshotView;
- // Groups wrap only once a run has settled: while running/preparing the
- // in-flight process steps stay expanded so streaming doesn't jump.
- const groupingEnabled = collapseProcessGroups && status === "idle";
+ // Grouping is purely structural: a group only wraps steps that ended with a
+ // result, so a new run's in-flight steps (a trailing, unterminated span)
+ // never collapse, while older completed groups stay collapsed across the
+ // run. Disabling grouping wholesale while running would re-expand every
+ // historical group and shift the reading position mid-conversation.
+ const groupingEnabled = collapseProcessGroups;
const displayRows = useMemo(
() =>
resolveDisplayRows(
@@ -198,19 +206,15 @@ export function MessageListView({
() => messages.map((message) => message.id),
[messages]
);
- // Map a message id to its displayed row index — collapsed groups remove
- // member rows, so raw message indices no longer match display indices.
+ // Map a message id to its displayed row index. Only rows that are actually
+ // rendered are mapped: a member hidden inside a collapsed group has no row
+ // index, and its targets are revealed by the expansion effect below before
+ // anything scrolls to them.
const displayIndexByMessageId = useMemo(() => {
const map = new Map();
displayRows.forEach((row, index) => {
if (row.kind === "message") {
map.set(row.message.id, index);
- } else {
- for (const member of row.group.messages) {
- if (!map.has(member.id)) {
- map.set(member.id, index);
- }
- }
}
});
return map;
@@ -472,6 +476,27 @@ export function MessageListView({
}
return followMessageViewportBottom(viewport, content);
}, [getScrollElement, status]);
+ // A validation error or a freshly-inserted message can target a member
+ // hidden inside a collapsed group. Reveal the group first; the scroll /
+ // autofocus effects below then run once the target row actually mounts
+ // (its display index resolves), instead of stopping at the header.
+ useEffect(() => {
+ const groupIds = new Set();
+ for (const messageId of [validationMessageId, autoFocusMessageId]) {
+ if (!messageId) {
+ continue;
+ }
+ const groupId = findCollapsedGroupIdForMessage(displayRows, messageId);
+ if (groupId) {
+ groupIds.add(groupId);
+ }
+ }
+ // Toggle once per group: the two targets can hide in the same group, and
+ // a double toggle would cancel out and loop.
+ for (const groupId of groupIds) {
+ toggleProcessGroupExpanded(groupId);
+ }
+ }, [autoFocusMessageId, displayRows, toggleProcessGroupExpanded, validationMessageId]);
useEffect(() => {
if (!autoFocusMessageId || autoFocusMessageIndex < 0) {
return;
diff --git a/packages/ui/src/components/thread-playground/message/process-groups.ts b/packages/ui/src/components/thread-playground/message/process-groups.ts
index d8f803f6..95b04c9a 100644
--- a/packages/ui/src/components/thread-playground/message/process-groups.ts
+++ b/packages/ui/src/components/thread-playground/message/process-groups.ts
@@ -12,10 +12,11 @@ import {
* 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
- * a terminator: a user message or an assistant message whose text is the
- * result. A trailing member run with no result after it (e.g. an aborted
- * run) is deliberately never grouped: there is no result to prioritise and
- * the user needs to see the failure site.
+ * 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.
@@ -104,8 +105,10 @@ function _summarizeGroup(
/**
* Find every groupable run of process messages. A run is only returned when
- * it is closed by a terminator — a user message or an assistant message with a
- * text body (the result). Runs ending at the list's end stay ungrouped.
+ * 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[]
@@ -123,12 +126,11 @@ export function findProcessGroupSpans(
if (start === -1) {
continue;
}
- // A non-member message closes the run; the group is only kept when the
- // closer is a user message or an assistant message with a result body.
- const isTerminator =
- message.role === "user" ||
- (message.role === "assistant" && _hasTextBody(message));
- if (isTerminator) {
+ // 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,
@@ -146,6 +148,27 @@ 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>();
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
index be8dbcad..645b4e97 100644
--- a/packages/ui/tests/components/thread-playground/message/process-groups.test.ts
+++ b/packages/ui/tests/components/thread-playground/message/process-groups.test.ts
@@ -4,6 +4,7 @@ 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";
@@ -91,14 +92,29 @@ describe("findProcessGroupSpans", () => {
expect(spans[0].group.lastToolName).toBe("web_search");
});
- test("closes a group on a following user message", () => {
+ 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].group.id).toBe("p1");
+ 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", () => {
@@ -172,16 +188,33 @@ describe("resolveDisplayRows", () => {
expect(rows[4]).toMatchObject({ kind: "message" });
});
- test("renders plain rows while running or when grouping is disabled", () => {
+ 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);
}
- const running = resolveDisplayRows(messages, "p2", 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,
});
- expect(running.every((row) => row.kind === "message")).toBe(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", () => {
@@ -195,6 +228,34 @@ describe("resolveDisplayRows", () => {
});
});
+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(
From 30037bffb59af38ee1aba8b1881c687400cf56bd Mon Sep 17 00:00:00 2001
From: zcai7675-bot <233973883+.zcai7675-bot@users.noreply.github.com>
Date: Wed, 9 Sep 2026 13:05:07 +0800
Subject: [PATCH 3/4] chore: no-op to re-run CI (flaky deep-link inbox timing
test)
From ab99903833299de7c5ab46db50358ec05053fed8 Mon Sep 17 00:00:00 2001
From: tokg-venv <233973883@users.noreply.github.com>
Date: Sun, 13 Sep 2026 15:20:20 +0800
Subject: [PATCH 4/4] chore: no-op to re-run CI (flaky plugin-installer
extraction test)