diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 74eb8df8661..bece9b49152 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -19,7 +19,13 @@ import { ErrorState } from '@/components/ui/error-state' import { getGlobalModelOptions, type HermesGateway } from '@/hermes' import { useI18n } from '@/i18n' import type { ChatMessage } from '@/lib/chat-messages' -import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime' +import { + coalesceToolOnlyAssistants, + createToolMergeCache, + quickModelOptions, + sessionTitle, + toRuntimeMessage +} from '@/lib/chat-runtime' import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime' import { cn } from '@/lib/utils' import type { ComposerAttachment } from '@/store/composer' @@ -201,6 +207,7 @@ function ChatRuntimeBoundary({ const storeMessages = useStore($messages) const messages = suppressMessages ? NO_MESSAGES : storeMessages const runtimeMessageCacheRef = useRef(new WeakMap()) + const toolMergeCacheRef = useRef(createToolMergeCache()) const runtimeMessageRepository = useMemo(() => { const items: { message: ThreadMessage; parentId: string | null }[] = [] @@ -208,7 +215,7 @@ function ChatRuntimeBoundary({ let visibleParentId: string | null = null let headId: string | null = null - for (const message of messages) { + for (const message of coalesceToolOnlyAssistants(messages, toolMergeCacheRef.current)) { let parentId = visibleParentId if (message.role === 'assistant' && message.branchGroupId) { diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index 06d8e4c3265..d315617d2ac 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -383,3 +383,61 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage { } } as ThreadMessage } + +export type ToolMergeCache = WeakMap< + ChatMessage, + { merged: ChatMessage; parts: ChatMessagePart[]; prev: ChatMessage; prevParts: ChatMessagePart[] } +> + +export function createToolMergeCache(): ToolMergeCache { + return new WeakMap() +} + +// A settled assistant message with only tool calls — no prose, no reasoning. +// The model routinely emits a follow-up batch of calls as its own text-less +// message; on screen it looks like one continuous run, but assistant-ui can't +// group tool calls across a message boundary. +function isToolOnlyAssistant(message: ChatMessage): boolean { + return ( + message.role === 'assistant' && + !message.pending && + !message.error && + !message.hidden && + message.parts.length > 0 && + message.parts.every(part => part.type === 'tool-call') + ) +} + +/** + * Fold each settled tool-only assistant message into the preceding assistant + * message so its calls join that message's tool group (and can collapse into + * the auto-scrolling window). Render-only — never mutates the `$messages` store + * — and settle-only: pending messages are left alone, so a live turn is never + * merged/un-merged mid-stream. `cache` keys merged results by source identity, + * so a stable turn yields stable merged objects (no re-render churn). + */ +export function coalesceToolOnlyAssistants(messages: ChatMessage[], cache: ToolMergeCache): ChatMessage[] { + const out: ChatMessage[] = [] + + for (const message of messages) { + const prev = out.at(-1) + + if (prev && prev.role === 'assistant' && !prev.pending && !prev.hidden && isToolOnlyAssistant(message)) { + const cached = cache.get(message) + + const merged = + cached && cached.prev === prev && cached.prevParts === prev.parts && cached.parts === message.parts + ? cached.merged + : { ...prev, parts: [...prev.parts, ...message.parts] } + + cache.set(message, { merged, parts: message.parts, prev, prevParts: prev.parts }) + out[out.length - 1] = merged + + continue + } + + out.push(message) + } + + return out +}