diff --git a/apps/desktop/src/components/settings/general-page.tsx b/apps/desktop/src/components/settings/general-page.tsx index 50aab764..93b0e3fd 100644 --- a/apps/desktop/src/components/settings/general-page.tsx +++ b/apps/desktop/src/components/settings/general-page.tsx @@ -14,6 +14,10 @@ import { type RenderingFidelity, type Theme, } from "@llm-space/ui/components/theme-provider"; +import { + setCollapseProcessGroups, + useCollapseProcessGroups, +} from "@llm-space/ui/components/thread-playground/message/process-groups"; import { ModelAvatar } from "@llm-space/ui/components/thread-playground/model-avatar"; import { Button } from "@llm-space/ui/ui/button"; import { @@ -47,6 +51,7 @@ import { DEFAULT_UPDATE_MODE, type UpdateMode } from "@/shared/updates"; import { PrimaryColorPicker } from "./primary-color-picker"; import { SettingsPage } from "./settings-page"; +import { SettingsToggleRow } from "./settings-toggle-row"; /** Sentinel value for the "Automatic (first available model)" option. */ const AUTO_DEFAULT_MODEL = "__auto__"; @@ -298,6 +303,23 @@ function useUpdateMode(): [UpdateMode, (mode: UpdateMode) => void] { return [mode, change]; } +/** + * Opt into collapsing each finished run's intermediate steps (thinking, tool + * calls) into one expandable row once the result arrives. On by default. + */ +function CollapseProcessGroupsRow() { + const { t } = useI18n(); + const collapse = useCollapseProcessGroups(); + return ( + + ); +} + export function GeneralPage() { const { lang, setLang, t } = useI18n(); const { theme, setTheme } = useTheme(); @@ -427,6 +449,10 @@ export function GeneralPage() { + + + + = { previewText: "Preview text content", noText: "No text content", }, + processGroups: { + lastTool: (name) => `Last: ${name}`, + errors: (count) => `${count} failed`, + }, messages: { user: "User", assistant: "Assistant", @@ -664,6 +668,10 @@ export const PLAYGROUND_LABELS: Record = { previewText: "预览文本内容", noText: "没有文本内容", }, + processGroups: { + lastTool: (name) => `最后:${name}`, + errors: (count) => `${count} 步失败`, + }, messages: { user: "用户", assistant: "助手", diff --git a/packages/ui/package.json b/packages/ui/package.json index ca614545..17c2ac21 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -11,6 +11,7 @@ "./components/thread-playground": "./src/components/thread-playground/index.tsx", "./components/thread-playground/playground-labels": "./src/components/thread-playground/playground-labels.tsx", "./components/thread-playground/examples/prompts": "./src/components/thread-playground/examples/prompts.ts", + "./components/thread-playground/message/process-groups": "./src/components/thread-playground/message/process-groups.ts", "./components/code-editor": "./src/components/code-editor/index.tsx", "./components/*": "./src/components/*.tsx", "./styles/globals.css": "./src/styles/globals.css", diff --git a/packages/ui/src/components/thread-playground/message/display-messages.ts b/packages/ui/src/components/thread-playground/message/display-messages.ts index 9ab1a052..604794c0 100644 --- a/packages/ui/src/components/thread-playground/message/display-messages.ts +++ b/packages/ui/src/components/thread-playground/message/display-messages.ts @@ -1,5 +1,8 @@ import type { AssistantMessage, Message } from "@llm-space/core"; +import type { DisplayRow, ProcessGroupSpan } from "./process-groups"; +import { findProcessGroupSpans } from "./process-groups"; + export interface DisplayMessage { message: Message; streaming: boolean; @@ -43,3 +46,57 @@ export function resolveDisplayMessages( }); return rows; } + +/** + * Fold the display rows into a render sequence where completed cross-message + * process groups collapse into a single header row (or render header-first + * when the user expanded the group). Grouping only wraps runs that ended with + * a result; trailing member runs — including the live streaming message — + * always render as plain rows. + */ +export function resolveDisplayRows( + messages: readonly Message[], + streamingMessageId: string | null, + running: boolean, + options: { + /** Master switch (setting + only while the thread is idle). */ + groupingEnabled?: boolean; + /** Groups the user manually expanded; ids are first-member message ids. */ + expandedGroupIds?: readonly string[]; + } = {} +): DisplayRow[] { + const base = resolveDisplayMessages(messages, streamingMessageId, running); + if (!options.groupingEnabled) { + return base.map((row) => ({ kind: "message" as const, ...row })); + } + const expanded = new Set(options.expandedGroupIds ?? []); + const spansByStart = new Map( + findProcessGroupSpans(base.map((row) => row.message)).map((span) => [ + span.start, + span, + ]) + ); + const rows: DisplayRow[] = []; + let index = 0; + while (index < base.length) { + const span = spansByStart.get(index); + // Never wrap a group that contains a live streaming row. + if ( + span && + !base.slice(span.start, span.end).some((row) => row.streaming) + ) { + const collapsed = !expanded.has(span.group.id); + rows.push({ kind: "processGroup", group: span.group, collapsed }); + if (!collapsed) { + for (const row of base.slice(span.start, span.end)) { + rows.push({ kind: "message", ...row }); + } + } + index = span.end; + continue; + } + rows.push({ kind: "message", ...base[index] }); + index += 1; + } + return rows; +} 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 2e52b23d..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 @@ -42,10 +42,7 @@ import { useThreadStoreActions, } from "../stores"; -import { - type DisplayMessage, - resolveDisplayMessages, -} from "./display-messages"; +import { resolveDisplayRows } from "./display-messages"; import { ImageDisplayProvider, type ImageDisplayContextValue, @@ -54,6 +51,12 @@ import { MessageListItem } from "./message-list-item"; 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, + findCollapsedGroupIdForMessage, + useCollapseProcessGroups, +} from "./process-groups"; import { findCenteredVirtualItemIndex } from "./virtual-item-center"; import { measureVirtualRowHeight } from "./virtual-row-measurement"; @@ -133,6 +136,10 @@ export function MessageListView({ const collapsedMessageIds = useThreadStore( (state) => state.collapsedMessageIds ); + const expandedProcessGroupIds = useThreadStore( + (state) => state.expandedProcessGroupIds + ); + const collapseProcessGroups = useCollapseProcessGroups(); const autoFocusMessageId = useThreadStore( (state) => state.autoFocusMessageId ); @@ -147,6 +154,7 @@ export function MessageListView({ consumeAutoFocusMessage, moveMessage, resolveRunValidationIssue, + toggleProcessGroupExpanded, } = useThreadStoreActions(); const [dragging, setDragging] = useState(false); const [activeMessageId, setActiveMessageId] = useState(null); @@ -160,31 +168,63 @@ export function MessageListView({ [messagesFromProps, storeMessages] ); const readonly = readonlyFromProps || isSnapshotView; + // 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( () => - isSnapshotView - ? messages.map((message) => ({ message, streaming: false })) - : resolveDisplayMessages( - messages, - streamingMessageId, - status === "running" - ), - [isSnapshotView, messages, status, streamingMessageId] + resolveDisplayRows( + messages, + isSnapshotView ? null : streamingMessageId, + isSnapshotView ? false : status === "running", + { + groupingEnabled, + expandedGroupIds: expandedProcessGroupIds, + } + ), + [ + expandedProcessGroupIds, + groupingEnabled, + isSnapshotView, + messages, + status, + streamingMessageId, + ] ); - const displayMessages = useMemo( - () => displayRows.map((row) => row.message), + // Navigator anchors: a collapsed group is represented by its first member. + const navigatorMessages = useMemo( + () => + displayRows.map((row) => + row.kind === "processGroup" ? row.group.messages[0] : row.message + ), [displayRows] ); const messageIds = useMemo( () => messages.map((message) => message.id), [messages] ); + // 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); + } + }); + return map; + }, [displayRows]); const autoFocusMessageIndex = autoFocusMessageId - ? messages.findIndex((message) => message.id === autoFocusMessageId) + ? (displayIndexByMessageId.get(autoFocusMessageId) ?? -1) : -1; const validationMessageId = runValidationIssue?.messageId ?? null; const validationMessageIndex = validationMessageId - ? messages.findIndex((message) => message.id === validationMessageId) + ? (displayIndexByMessageId.get(validationMessageId) ?? -1) : -1; const collapsedMessageIdSet = useMemo( () => new Set(collapsedMessageIds), @@ -193,8 +233,16 @@ export function MessageListView({ const shouldVirtualize = displayRows.length > MESSAGE_VIRTUALIZATION_THRESHOLD; const getMessageKey = useCallback( - (index: number) => displayMessages[index]?.id ?? index, - [displayMessages] + (index: number) => { + const row = displayRows[index]; + if (!row) { + return index; + } + return row.kind === "processGroup" + ? `group:${row.group.id}` + : row.message.id; + }, + [displayRows] ); const getScrollElement = useCallback( () => @@ -203,20 +251,29 @@ export function MessageListView({ ) ?? null, [] ); - const estimateMessageSize = useCallback( + const estimateRowSize = useCallback( (index: number) => { - const message = displayMessages[index]; - return message - ? _estimateMessageHeight(message, collapsedMessageIdSet.has(message.id)) - : 240; + const row = displayRows[index]; + if (!row) { + return 240; + } + if (row.kind === "processGroup") { + // A collapsed group is a single fixed header row; an expanded group's + // header is measured like any other row. + return MESSAGE_HEIGHT_CACHE.get(`${row.group.id}:group`) ?? 56; + } + return _estimateMessageHeight( + row.message, + collapsedMessageIdSet.has(row.message.id) + ); }, - [collapsedMessageIdSet, displayMessages] + [collapsedMessageIdSet, displayRows] ); // TanStack Virtual exposes a mutable imperative controller by design. // eslint-disable-next-line react-hooks/incompatible-library const virtualizer = useVirtualizer({ count: shouldVirtualize ? displayRows.length : 0, - estimateSize: estimateMessageSize, + estimateSize: estimateRowSize, getItemKey: getMessageKey, getScrollElement, overscan: MESSAGE_OVERSCAN, @@ -227,13 +284,15 @@ export function MessageListView({ measureElement: (element, entry) => { const height = measureVirtualRowHeight(element, entry); const index = Number(element.getAttribute("data-index")); - const message = displayMessages[index]; - if (message && height > 0) { + const row = displayRows[index]; + if (row && height > 0) { _rememberMessageHeight( - _messageHeightCacheKey( - message, - collapsedMessageIdSet.has(message.id) - ), + row.kind === "processGroup" + ? `${row.group.id}:group` + : _messageHeightCacheKey( + row.message, + collapsedMessageIdSet.has(row.message.id) + ), height ); } @@ -417,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; @@ -437,7 +517,7 @@ export function MessageListView({ }, [validationMessageId, validationMessageIndex, scrollToMessageIndex]); const virtualItems = virtualizer.getVirtualItems(); - const showNavigator = displayMessages.length > 1; + const showNavigator = navigatorMessages.length > 1; return (
@@ -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 天)。逻辑本身不大,成本主要在虚拟化适配与边界态验证。