From 249f0cca87f9fab5615c1b19ca7e47280e3f5f57 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 3 Oct 2026 22:04:31 +0400 Subject: [PATCH] feat(ai): render tool calls as summarized, highlighted cards (#811) * feat(ai): render tool calls as summarized, highlighted cards Every tool call showed only a status and its output as a JSON string, so render calls hid their JSX, export_image dumped base64, and long runs filled the transcript with identical rows. A call now shows a one-line summary read from its input and chips that select and zoom to the layers it touched, switching to the run's page when needed. Expanded, it shows the JSX or script it wrote and its JSON input and output in a read-only CodeMirror view, and exported images inline. Render calls can be expanded while their input streams, so the JSX appears alongside the canvas preview. Consecutive calls beyond three fold into one row that keeps the latest call visible. CodeMirror loads with the first expanded call. The code theme gains a monospace fallback because the editor font variable is not always emitted. * refactor(ai): drop the unused tool JSON slot and place the JSX summary comment * fix(ai): keep an opened tool call in place instead of following the output Opening reasoning already stopped the transcript from following new output; tool calls and tool groups did not, so expanding one near the bottom re-pinned the bottom on every animation frame and slid the card away as it opened. Any disclosure in the transcript now stops following. * fix(ai): show a pointer over chat tool calls, tool groups, and reasoning * refactor(app): share CodeMirror setup between the code editor and viewer CodeViewer repeated CodeEditor's view lifecycle: mounting the EditorView, label, theme, and language compartments, the app-theme watcher, and teardown. useCodeMirror owns that once; each component passes its own fixed and reactive extensions. * refactor(ai): move tool node lookup and focusing into useToolNodes ToolNodeChips looked nodes up in the active document and ran the show-on-canvas flow, with its superseded-switch and error handling, inside the component. The composable owns both; the component renders the chips. * refactor(ai): derive tool call state and input once ToolCallCard and ToolCallGroup each rebuilt classifyToolState's input from the part, and the card decided inline whether a call had input to show. toolCallState and toolHasInput own those rules beside the other per-call helpers. * fix(app): use the thin app scrollbar in code editors and viewers CodeMirror scrolls its own .cm-scroller, which fell back to the platform scrollbar, thick and light in the dark chat. The hosts now give it the shared scrollbar-thin utility. --- CHANGELOG.md | 1 + bun.lock | 5 + package.json | 1 + packages/vue/src/i18n/locales/de/ai.json | 6 + packages/vue/src/i18n/locales/es/ai.json | 6 + packages/vue/src/i18n/locales/fr/ai.json | 6 + packages/vue/src/i18n/locales/it/ai.json | 6 + packages/vue/src/i18n/locales/ja/ai.json | 6 + packages/vue/src/i18n/locales/pl/ai.json | 6 + packages/vue/src/i18n/locales/ru/ai.json | 6 + packages/vue/src/i18n/locales/zh-cn/ai.json | 6 + packages/vue/src/i18n/messages/ai.ts | 6 + src/app/ai/chat/tool-calls/display.ts | 148 +++++++++++++++++ src/app/editor/selection/focus.ts | 30 ++++ src/components/ChatPanel.vue | 1 + src/components/chat/ChatMessage.vue | 117 +++---------- src/components/chat/ChatPlayground.stories.ts | 2 +- src/components/chat/ChatTranscript.vue | 12 +- src/components/chat/ReasoningBlock.vue | 2 +- src/components/chat/tool/ToolCallCard.vue | 116 +++++++++++++ .../chat/tool/ToolCallGroup.stories.ts | 83 +++++++++ src/components/chat/tool/ToolCallGroup.vue | 50 ++++++ src/components/chat/tool/ToolNodeChips.vue | 28 ++++ src/components/chat/tool/context.ts | 7 + .../chat/{tool-state.ts => tool/state.ts} | 9 + src/components/chat/tool/useToolNodes.ts | 60 +++++++ .../chat/transcript/useScrollFollowing.ts | 6 +- src/components/code-editor/CodeEditor.vue | 122 +++++--------- src/components/code-editor/CodeViewer.vue | 63 +++++++ src/components/code-editor/useCodeMirror.ts | 57 +++++++ src/theme/chat/tool.ts | 28 ++++ src/theme/code/editor.ts | 15 +- tests/app/ai/chat/tool-calls/display.test.ts | 157 ++++++++++++++++++ tests/e2e/chat/responses.spec.ts | 26 ++- tests/engine/app/chat/tool-state.test.ts | 15 +- .../engine/app/editor/selection/focus.test.ts | 13 ++ 36 files changed, 1044 insertions(+), 184 deletions(-) create mode 100644 src/app/ai/chat/tool-calls/display.ts create mode 100644 src/components/chat/tool/ToolCallCard.vue create mode 100644 src/components/chat/tool/ToolCallGroup.stories.ts create mode 100644 src/components/chat/tool/ToolCallGroup.vue create mode 100644 src/components/chat/tool/ToolNodeChips.vue create mode 100644 src/components/chat/tool/context.ts rename src/components/chat/{tool-state.ts => tool/state.ts} (74%) create mode 100644 src/components/chat/tool/useToolNodes.ts create mode 100644 src/components/code-editor/CodeViewer.vue create mode 100644 src/components/code-editor/useCodeMirror.ts create mode 100644 src/theme/chat/tool.ts create mode 100644 tests/app/ai/chat/tool-calls/display.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 80198db54..fc213e949 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Hide, lock, and constrain layers in design JSX with `visible={false}`, `locked`, and `constraints={{ horizontal, vertical }}`, set italic text with `italic`, and describe strokes fully with `strokes`, `strokeWeights`, `strokeCap`, `strokeJoin`, and the node-level `dashPattern`. JSX export now writes these together with stacked, gradient, and image fills, every effect, absolutely positioned children, size limits, vertical text alignment, masks, and variable bindings, so rendering exported JSX reproduces them and `diff_jsx` reports changes to them. - Jump between pages from the command palette: it lists the pages you visited recently in the tab, **Go to page…** lists every page, and typing a page name finds it. - Choose how much an AI model thinks for each message from the chat composer, from Off to Extra high or the provider default. Anthropic, Google, DeepSeek, OpenAI, OpenRouter, and compatible models apply it, where reasoning effort previously reached only OpenAI and OpenRouter models. A model profile's thinking level, which replaces its free-text reasoning effort, sets the starting choice, and finished reasoning shows how long the model thought. +- Read AI tool calls at a glance: each call shows a one-line summary and buttons that bring the layers it touched into view, even on another page. Expanded, it shows highlighted JSX, scripts, and JSON input and output, and exported images inline. A render call's JSX streams in as the model writes it, and a long run folds its earlier steps into one row. - Preview designs progressively on the canvas as direct AI providers stream JSX, without saving partial designs or adding intermediate undo steps. A preview stays with its page: it hides while you view another page and returns when you come back. - Write design trees as TSX with `@open-pencil/design-jsx` as the JSX import source, and render them with `renderTree`. - Swap the component behind an instance with `instance.swapComponent(component)` in the plugin API, as in Figma. diff --git a/bun.lock b/bun.lock index ecf557302..b637b89e3 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "@codemirror/commands": "^6.11.0", "@codemirror/lang-html": "^6.4.12", "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-json": "^6.0.2", "@codemirror/language": "^6.12.4", "@codemirror/lint": "^6.9.7", "@codemirror/search": "^6.7.1", @@ -896,6 +897,8 @@ "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="], + "@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="], + "@codemirror/language": ["@codemirror/language@6.12.4", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A=="], "@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="], @@ -1318,6 +1321,8 @@ "@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="], + "@lezer/json": ["@lezer/json@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="], + "@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="], "@libp2p/bootstrap": ["@libp2p/bootstrap@11.0.42", "", { "dependencies": { "@libp2p/interface": "^2.10.4", "@libp2p/interface-internal": "^2.3.17", "@libp2p/peer-id": "^5.1.7", "@multiformats/mafmt": "^12.1.6", "@multiformats/multiaddr": "^12.4.4", "main-event": "^1.0.1" } }, "sha512-xe5LMZrXR2cnFcR69ax/cHqNCc8zwSvqDWOf82u/ifAsUae7M0eeoEVi15lfrPewWst4YPkTqYDkZSPWC65I3Q=="], diff --git a/package.json b/package.json index e105d9a2e..07f33dd71 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "@codemirror/commands": "^6.11.0", "@codemirror/lang-html": "^6.4.12", "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-json": "^6.0.2", "@codemirror/language": "^6.12.4", "@codemirror/lint": "^6.9.7", "@codemirror/search": "^6.7.1", diff --git a/packages/vue/src/i18n/locales/de/ai.json b/packages/vue/src/i18n/locales/de/ai.json index ad6d48337..44223fdc6 100644 --- a/packages/vue/src/i18n/locales/de/ai.json +++ b/packages/vue/src/i18n/locales/de/ai.json @@ -34,6 +34,12 @@ "toolFinished": "Fertig", "toolRunning": "Wird ausgeführt…", "toolError": "Fehler", + "toolInput": "Eingabe", + "toolOutput": "Ausgabe", + "toolDetails": "Details des Werkzeugaufrufs", + "toolSteps": "Frühere Schritte: {count}", + "toolStepsFailed": "fehlgeschlagen: {count}", + "showNodeOnCanvas": "{name} auf der Arbeitsfläche zeigen", "unknownTool": "Unbekanntes Werkzeug", "permissionRequestTitle": "Berechtigungsanfrage", "permissionRequest": "{tool} fordert eine Berechtigung an.", diff --git a/packages/vue/src/i18n/locales/es/ai.json b/packages/vue/src/i18n/locales/es/ai.json index e39348fe5..2d184f0b6 100644 --- a/packages/vue/src/i18n/locales/es/ai.json +++ b/packages/vue/src/i18n/locales/es/ai.json @@ -34,6 +34,12 @@ "toolFinished": "Hecho", "toolRunning": "Ejecutando…", "toolError": "Error", + "toolInput": "Entrada", + "toolOutput": "Salida", + "toolDetails": "Detalles de la llamada a la herramienta", + "toolSteps": "Pasos anteriores: {count}", + "toolStepsFailed": "con errores: {count}", + "showNodeOnCanvas": "Mostrar {name} en el lienzo", "unknownTool": "Herramienta desconocida", "permissionRequestTitle": "Solicitud de permiso", "permissionRequest": "{tool} solicita permiso.", diff --git a/packages/vue/src/i18n/locales/fr/ai.json b/packages/vue/src/i18n/locales/fr/ai.json index bd8942c55..c192673d7 100644 --- a/packages/vue/src/i18n/locales/fr/ai.json +++ b/packages/vue/src/i18n/locales/fr/ai.json @@ -34,6 +34,12 @@ "toolFinished": "Terminé", "toolRunning": "Exécution…", "toolError": "Erreur", + "toolInput": "Entrée", + "toolOutput": "Sortie", + "toolDetails": "Détails de l’appel d’outil", + "toolSteps": "Étapes précédentes : {count}", + "toolStepsFailed": "en échec : {count}", + "showNodeOnCanvas": "Afficher {name} sur le canevas", "unknownTool": "Outil inconnu", "permissionRequestTitle": "Demande d’autorisation", "permissionRequest": "{tool} demande une autorisation.", diff --git a/packages/vue/src/i18n/locales/it/ai.json b/packages/vue/src/i18n/locales/it/ai.json index c30a09fb7..2c7bd33f7 100644 --- a/packages/vue/src/i18n/locales/it/ai.json +++ b/packages/vue/src/i18n/locales/it/ai.json @@ -34,6 +34,12 @@ "toolFinished": "Fine", "toolRunning": "Esecuzione…", "toolError": "Errore", + "toolInput": "Dati in ingresso", + "toolOutput": "Risultato", + "toolDetails": "Dettagli della chiamata allo strumento", + "toolSteps": "Passaggi precedenti: {count}", + "toolStepsFailed": "non riusciti: {count}", + "showNodeOnCanvas": "Mostra {name} nella tela", "unknownTool": "Strumento sconosciuto", "permissionRequestTitle": "Richiesta di autorizzazione", "permissionRequest": "{tool} richiede l’autorizzazione.", diff --git a/packages/vue/src/i18n/locales/ja/ai.json b/packages/vue/src/i18n/locales/ja/ai.json index 5d103ff4a..36093ca17 100644 --- a/packages/vue/src/i18n/locales/ja/ai.json +++ b/packages/vue/src/i18n/locales/ja/ai.json @@ -34,6 +34,12 @@ "toolFinished": "完了", "toolRunning": "実行中…", "toolError": "エラー", + "toolInput": "入力", + "toolOutput": "出力", + "toolDetails": "ツール呼び出しの詳細", + "toolSteps": "以前のステップ: {count}", + "toolStepsFailed": "失敗: {count}", + "showNodeOnCanvas": "{name} をキャンバスに表示", "unknownTool": "不明なツール", "permissionRequestTitle": "権限リクエスト", "permissionRequest": "{tool}が権限を要求しています。", diff --git a/packages/vue/src/i18n/locales/pl/ai.json b/packages/vue/src/i18n/locales/pl/ai.json index efd73a76a..92eb60098 100644 --- a/packages/vue/src/i18n/locales/pl/ai.json +++ b/packages/vue/src/i18n/locales/pl/ai.json @@ -34,6 +34,12 @@ "toolFinished": "Gotowe", "toolRunning": "Uruchamianie…", "toolError": "Błąd", + "toolInput": "Wejście", + "toolOutput": "Wyjście", + "toolDetails": "Szczegóły wywołania narzędzia", + "toolSteps": "Wcześniejsze kroki: {count}", + "toolStepsFailed": "nieudane: {count}", + "showNodeOnCanvas": "Pokaż {name} na kanwie", "unknownTool": "Nieznane narzędzie", "permissionRequestTitle": "Prośba o uprawnienie", "permissionRequest": "{tool} prosi o uprawnienie.", diff --git a/packages/vue/src/i18n/locales/ru/ai.json b/packages/vue/src/i18n/locales/ru/ai.json index 1e233b35e..5f62e66ba 100644 --- a/packages/vue/src/i18n/locales/ru/ai.json +++ b/packages/vue/src/i18n/locales/ru/ai.json @@ -34,6 +34,12 @@ "toolFinished": "Готово", "toolRunning": "Выполняется…", "toolError": "Ошибка", + "toolInput": "Вход", + "toolOutput": "Результат", + "toolDetails": "Подробности вызова инструмента", + "toolSteps": "Предыдущие шаги: {count}", + "toolStepsFailed": "с ошибкой: {count}", + "showNodeOnCanvas": "Показать {name} на холсте", "unknownTool": "Неизвестный инструмент", "permissionRequestTitle": "Запрос разрешения", "permissionRequest": "Инструмент «{tool}» запрашивает разрешение.", diff --git a/packages/vue/src/i18n/locales/zh-cn/ai.json b/packages/vue/src/i18n/locales/zh-cn/ai.json index 1bc67eb74..9e82af4e3 100644 --- a/packages/vue/src/i18n/locales/zh-cn/ai.json +++ b/packages/vue/src/i18n/locales/zh-cn/ai.json @@ -34,6 +34,12 @@ "toolFinished": "完成", "toolRunning": "正在运行…", "toolError": "错误", + "toolInput": "输入", + "toolOutput": "输出", + "toolDetails": "工具调用详情", + "toolSteps": "之前的步骤:{count}", + "toolStepsFailed": "失败:{count}", + "showNodeOnCanvas": "在画布上显示 {name}", "unknownTool": "未知工具", "permissionRequestTitle": "权限请求", "permissionRequest": "{tool} 正在请求权限。", diff --git a/packages/vue/src/i18n/messages/ai.ts b/packages/vue/src/i18n/messages/ai.ts index 0bef4ccbf..60e7073a9 100644 --- a/packages/vue/src/i18n/messages/ai.ts +++ b/packages/vue/src/i18n/messages/ai.ts @@ -76,6 +76,12 @@ export const aiMessageDefaults = { toolFinished: 'Done', toolRunning: 'Running…', toolError: 'Error', + toolInput: 'Input', + toolOutput: 'Output', + toolDetails: 'Tool call details', + toolSteps: params('Earlier steps: {count}'), + toolStepsFailed: params('failed: {count}'), + showNodeOnCanvas: params('Show {name} on canvas'), unknownTool: 'Unknown tool', permissionRequestTitle: 'Permission request', permissionRequest: params('{tool} is requesting permission.'), diff --git a/src/app/ai/chat/tool-calls/display.ts b/src/app/ai/chat/tool-calls/display.ts new file mode 100644 index 000000000..fd37ff57f --- /dev/null +++ b/src/app/ai/chat/tool-calls/display.ts @@ -0,0 +1,148 @@ +import { getToolName, isToolUIPart } from 'ai' +import type { UIDataTypes, UIMessagePart, UITools } from 'ai' +import { isValid } from 'js-base64' + +export type ChatMessagePart = UIMessagePart +export type ToolCallPart = Extract + +const MCP_PREFIX = /^mcp__[^_]+__/ +const SUMMARY_LENGTH = 60 +const IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']) + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function truncate(text: string): string { + const line = text.trim().split('\n')[0]?.trim() ?? '' + return line.length > SUMMARY_LENGTH ? `${line.slice(0, SUMMARY_LENGTH - 1)}…` : line +} + +export function toolName(part: ToolCallPart): string { + return getToolName(part).replace(MCP_PREFIX, '') +} + +export function toolDisplayName(part: ToolCallPart): string { + return toolName(part) + .replace(/_/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()) +} + +/** The root element's opening tag, which may still be streaming in. */ +const ROOT_TAG = /<([A-Z][\w.]*)([^>]*)/ + +/** The element and name a JSX source opens with, such as `Frame "Pricing"`. */ +function jsxSummary(jsx: string): string { + const root = ROOT_TAG.exec(jsx) + if (!root) return '' + const [, element, attributes = ''] = root + // Only the root's own attributes: a child's name must not label the root. + const name = /\bname=["'{]+([^"'}]+)/.exec(attributes)?.[1] + return name ? `${element} “${truncate(name)}”` : element +} + +const SUMMARY_FIELDS = ['name', 'query', 'text', 'question', 'path', 'id'] as const + +/** A one-line description of what a call does, read from its (possibly partial) input. */ +export function toolSummary(part: ToolCallPart): string { + const input = part.input + if (!isRecord(input)) return '' + if (typeof input.jsx === 'string') return jsxSummary(input.jsx) + if (typeof input.code === 'string') return truncate(input.code) + if (Array.isArray(input.ids) && input.ids.length > 1) return `${input.ids.length} nodes` + for (const field of SUMMARY_FIELDS) { + const value = input[field] + if (typeof value === 'string' && value.trim()) return truncate(value) + } + return '' +} + +function stringIds(value: unknown): string[] { + if (typeof value === 'string') return [value] + if (!Array.isArray(value)) return [] + return value.flatMap((item) => { + if (typeof item === 'string') return [item] + return isRecord(item) && typeof item.id === 'string' ? [item.id] : [] + }) +} + +/** Nodes a call targeted or produced, for chips that bring them into view. */ +export function toolNodeIds(part: ToolCallPart): string[] { + const ids = new Set() + const { input } = part + if (isRecord(input)) { + for (const key of ['id', 'ids', 'replace_id', 'parent_id', 'parentId']) { + for (const id of stringIds(input[key])) ids.add(id) + } + } + if (part.state === 'output-available' && isRecord(part.output)) { + const { output } = part + if (typeof output.deleted !== 'string') { + for (const key of ['id', 'ids', 'selection', 'results', 'created']) { + for (const id of stringIds(output[key])) ids.add(id) + } + } + } + return [...ids] +} + +export function toolErrorText(part: ToolCallPart): string | null { + if (part.state === 'output-error') return part.errorText + if (part.state !== 'output-available' || !isRecord(part.output)) return null + const { error } = part.output + return typeof error === 'string' ? error : null +} + +/** An image the tool returned for the model, such as `export_image`, as a data URL. */ +export function toolImage(part: ToolCallPart): string | null { + if (part.state !== 'output-available' || !isRecord(part.output)) return null + const { base64, mimeType } = part.output + if (typeof base64 !== 'string' || typeof mimeType !== 'string') return null + if (!IMAGE_MEDIA_TYPES.has(mimeType) || !isValid(base64)) return null + return `data:${mimeType};base64,${base64}` +} + +/** Output with large binary payloads elided, for display as JSON. */ +export function displayedToolOutput(part: ToolCallPart): unknown { + if (part.state !== 'output-available') return undefined + const { output } = part + if (!isRecord(output) || typeof output.base64 !== 'string') return output + return { ...output, base64: `<${output.base64.length} base64 characters>` } +} + +/** The source a call writes, shown highlighted instead of as a JSON string. */ +export function toolSource( + part: ToolCallPart +): { code: string; language: 'design-jsx' | 'javascript' } | null { + const input = part.input + if (!isRecord(input)) return null + if (typeof input.jsx === 'string') return { code: input.jsx, language: 'design-jsx' } + if (typeof input.code === 'string') return { code: input.code, language: 'javascript' } + return null +} + +/** Whether a call has input worth showing: source, or at least one argument. */ +export function toolHasInput(part: ToolCallPart): boolean { + return toolSource(part) !== null || (isRecord(part.input) && Object.keys(part.input).length > 0) +} + +export type MessagePartGroup = + | { kind: 'part'; part: ChatMessagePart; index: number } + | { kind: 'tools'; parts: { part: ToolCallPart; index: number }[] } + +/** Consecutive tool calls form one group, so a long run can collapse into a single row. */ +export function groupMessageParts(parts: readonly ChatMessagePart[]): MessagePartGroup[] { + const groups: MessagePartGroup[] = [] + parts.forEach((part, index) => { + // Step boundaries are not rendered, so they must not split a run of calls. + if (part.type === 'step-start') return + if (!isToolUIPart(part)) { + groups.push({ kind: 'part', part, index }) + return + } + const last = groups.at(-1) + if (last?.kind === 'tools') last.parts.push({ part, index }) + else groups.push({ kind: 'tools', parts: [{ part, index }] }) + }) + return groups +} diff --git a/src/app/editor/selection/focus.ts b/src/app/editor/selection/focus.ts index 8c6bf46e2..bcd90d67e 100644 --- a/src/app/editor/selection/focus.ts +++ b/src/app/editor/selection/focus.ts @@ -46,6 +46,36 @@ export function focusNodes(store: FocusStore, ids: readonly string[]): boolean { return true } +function pageIdOf(graph: FocusStore['graph'], id: string): string | null { + let node = graph.getNode(id) + while (node && node.type !== 'CANVAS') + node = node.parentId ? graph.getNode(node.parentId) : undefined + return node?.id ?? null +} + +/** + * Focuses the nodes that share the first present node's page, switching to that page. + * An AI run can work on a page other than the one on screen. + */ +export async function focusNodesOnTheirPage( + store: FocusStore, + ids: readonly string[] +): Promise { + const pageIds = new Map(ids.map((id) => [id, pageIdOf(store.graph, id)])) + const pageId = [...pageIds.values()].find((id) => id !== null) + if (!pageId) return false + if (pageId !== store.state.currentPageId) { + const switches = store.pageSwitchCount() + await store.switchPage(pageId) + if (store.pageSwitchCount() !== switches + 1 || store.state.currentPageId !== pageId) + return false + } + return focusNodes( + store, + ids.filter((id) => pageIds.get(id) === pageId) + ) +} + /** `superseded` when the user switched pages while other pages were being searched. */ export type FocusByNameResult = 'found' | 'missing' | 'superseded' diff --git a/src/components/ChatPanel.vue b/src/components/ChatPanel.vue index d89bc6279..2d4b50b40 100644 --- a/src/components/ChatPanel.vue +++ b/src/components/ChatPanel.vue @@ -189,6 +189,7 @@ function handleStop() { :messages="messages" :status="status" :show-continue="showContinue" + :nodes-live="!history.readOnly.value" @continue=" submission.submit({ modelText: 'Continue where you left off', diff --git a/src/components/chat/ChatMessage.vue b/src/components/chat/ChatMessage.vue index 18f694793..22d74a791 100644 --- a/src/components/chat/ChatMessage.vue +++ b/src/components/chat/ChatMessage.vue @@ -1,8 +1,7 @@ @@ -92,80 +67,34 @@ function partKey(part: UIMessagePart, index: number): stri :class="message.role === 'user' ? 'max-w-[85%]' : ''" >