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.
This commit is contained in:
Danila Poyarkov 2026-10-03 22:04:31 +04:00 committed by GitHub
parent 3f594fdc3a
commit 249f0cca87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1044 additions and 184 deletions

View file

@ -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.

View file

@ -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=="],

View file

@ -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",

View file

@ -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.",

View file

@ -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.",

View file

@ -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.",

View file

@ -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.",

View file

@ -34,6 +34,12 @@
"toolFinished": "完了",
"toolRunning": "実行中…",
"toolError": "エラー",
"toolInput": "入力",
"toolOutput": "出力",
"toolDetails": "ツール呼び出しの詳細",
"toolSteps": "以前のステップ: {count}",
"toolStepsFailed": "失敗: {count}",
"showNodeOnCanvas": "{name} をキャンバスに表示",
"unknownTool": "不明なツール",
"permissionRequestTitle": "権限リクエスト",
"permissionRequest": "{tool}が権限を要求しています。",

View file

@ -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.",

View file

@ -34,6 +34,12 @@
"toolFinished": "Готово",
"toolRunning": "Выполняется…",
"toolError": "Ошибка",
"toolInput": "Вход",
"toolOutput": "Результат",
"toolDetails": "Подробности вызова инструмента",
"toolSteps": "Предыдущие шаги: {count}",
"toolStepsFailed": "с ошибкой: {count}",
"showNodeOnCanvas": "Показать {name} на холсте",
"unknownTool": "Неизвестный инструмент",
"permissionRequestTitle": "Запрос разрешения",
"permissionRequest": "Инструмент «{tool}» запрашивает разрешение.",

View file

@ -34,6 +34,12 @@
"toolFinished": "完成",
"toolRunning": "正在运行…",
"toolError": "错误",
"toolInput": "输入",
"toolOutput": "输出",
"toolDetails": "工具调用详情",
"toolSteps": "之前的步骤:{count}",
"toolStepsFailed": "失败:{count}",
"showNodeOnCanvas": "在画布上显示 {name}",
"unknownTool": "未知工具",
"permissionRequestTitle": "权限请求",
"permissionRequest": "{tool} 正在请求权限。",

View file

@ -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.'),

View file

@ -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<UIDataTypes, UITools>
export type ToolCallPart = Extract<ChatMessagePart, { toolCallId: string }>
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<string, unknown> {
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<string>()
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
}

View file

@ -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<boolean> {
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'

View file

@ -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',

View file

@ -1,8 +1,7 @@
<script setup lang="ts">
import { refAutoReset, useClipboard } from '@vueuse/core'
import { isReasoningUIPart, isTextUIPart, isToolUIPart, getToolName } from 'ai'
import type { UIDataTypes, UIMessage, UIMessagePart, UITools } from 'ai'
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui'
import { isReasoningUIPart, isTextUIPart } from 'ai'
import type { UIMessage } from 'ai'
import { computed } from 'vue'
import { useI18n, vTestId } from '@open-pencil/vue'
@ -11,13 +10,12 @@ import { attachmentsForMessage } from '@/app/ai/attachment/presentation/store'
import type { AttachmentPresentation } from '@/app/ai/attachment/presentation/types'
import { reasoningDisplay } from '@/app/ai/chat/preferences'
import { visibleUserMessageText } from '@/app/ai/chat/presentation'
import { groupMessageParts, type MessagePartGroup } from '@/app/ai/chat/tool-calls/display'
import AttachmentList from '@/components/chat/attachment/AttachmentList.vue'
import ChatMarkdown from '@/components/chat/ChatMarkdown.vue'
import ReasoningBlock from '@/components/chat/ReasoningBlock.vue'
import ToolCallGroup from '@/components/chat/tool/ToolCallGroup.vue'
import IconButton from '@/components/ui/button/IconButton.vue'
import { collapsibleContentMotion } from '@/theme/collapsible/collapsible'
import { classifyToolState } from './tool-state'
const {
message,
@ -50,35 +48,12 @@ async function copyResponse(): Promise<void> {
copied.value = true
}
type ToolPart = Extract<UIMessagePart<UIDataTypes, UITools>, { toolCallId: string }>
// The AI SDK updates parts in place and replaces only the message, so copy each part for
// the cards' computed state to see the new values.
const groups = computed(() => groupMessageParts(message.parts.map((part) => ({ ...part }))))
function toolDisplayName(part: ToolPart): string {
return getToolName(part)
.replace(/^mcp__[^_]+__/, '')
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
}
function hasErrorOutput(part: ToolPart): boolean {
return (
part.state === 'output-available' &&
typeof part.output === 'object' &&
part.output !== null &&
'error' in part.output
)
}
function toolState(part: ToolPart): 'pending' | 'done' | 'error' {
return classifyToolState({
toolName: getToolName(part),
state: part.state,
output: part.output
})
}
function partKey(part: UIMessagePart<UIDataTypes, UITools>, index: number): string {
if ('toolCallId' in part) return part.toolCallId
return `part-${index}`
function groupKey(group: MessagePartGroup): string {
return group.kind === 'tools' ? `tools-${group.parts[0]?.index}` : `part-${group.index}`
}
</script>
@ -92,80 +67,34 @@ function partKey(part: UIMessagePart<UIDataTypes, UITools>, index: number): stri
:class="message.role === 'user' ? 'max-w-[85%]' : ''"
>
<template v-if="message.role === 'assistant'">
<template v-for="(part, i) in message.parts" :key="partKey(part, i)">
<template v-for="group in groups" :key="groupKey(group)">
<ToolCallGroup
v-if="group.kind === 'tools'"
:parts="group.parts.map(({ part }) => part)"
/>
<!-- Reasoning -->
<ReasoningBlock
v-if="isReasoningUIPart(part) && part.text"
:text="part.text"
v-else-if="isReasoningUIPart(group.part) && group.part.text"
:text="group.part.text"
:display="reasoningDisplay"
:streaming="part.state === 'streaming'"
:streaming="group.part.state === 'streaming'"
:thinking-label="ai.thinking"
:reasoning-label="ai.reasoning"
:duration-label="(seconds) => ai.thoughtFor({ seconds })"
/>
<!-- Tool call -->
<div v-if="isToolUIPart(part)" class="rounded-lg border border-border bg-canvas p-2">
<CollapsibleRoot>
<CollapsibleTrigger
class="flex w-full items-center gap-2 rounded px-1 py-0.5 hover:bg-hover"
>
<div
class="flex size-4 items-center justify-center rounded-full"
:class="{
'bg-accent/20 text-accent': toolState(part) === 'pending',
'bg-green-500/20 text-green-400': toolState(part) === 'done',
'bg-red-500/20 text-red-400': toolState(part) === 'error'
}"
>
<icon-lucide-loader-circle
v-if="toolState(part) === 'pending'"
class="size-3 animate-spin motion-reduce:animate-none"
/>
<icon-lucide-check v-else-if="toolState(part) === 'done'" class="size-3" />
<icon-lucide-triangle-alert v-else class="size-3" />
</div>
<span class="text-[11px] text-surface">
{{ toolDisplayName(part) }}
</span>
<span class="text-[10px] text-muted">
{{
toolState(part) === 'pending'
? ai.toolRunning
: toolState(part) === 'done'
? ai.toolFinished
: ai.toolError
}}
</span>
<icon-lucide-chevron-down
v-if="toolState(part) !== 'pending'"
class="ml-auto size-3 text-muted transition-transform [[data-state=open]>&]:rotate-180"
/>
</CollapsibleTrigger>
<CollapsibleContent
v-if="toolState(part) !== 'pending'"
:class="[collapsibleContentMotion, 'text-[10px]']"
>
<pre class="mt-1 overflow-x-auto rounded bg-input p-2 text-muted">{{
part.state === 'output-error' && part.errorText
? part.errorText
: hasErrorOutput(part)
? (part.output as { error: string }).error
: JSON.stringify(part.output, null, 2)
}}</pre>
</CollapsibleContent>
</CollapsibleRoot>
</div>
<!-- Text -->
<div
v-else-if="isTextUIPart(part) && part.text"
v-else-if="isTextUIPart(group.part) && group.part.text"
data-test-id="chat-text-bubble"
class="group/response relative rounded-xl rounded-tl-md bg-hover px-3 py-2 text-xs leading-relaxed text-surface"
>
<ChatMarkdown :content="part.text" :mode="markdownMode" />
<ChatMarkdown :content="group.part.text" :mode="markdownMode" />
<IconButton
v-if="i === firstAssistantTextPartIndex && assistantText && clipboardSupported"
v-if="
group.index === firstAssistantTextPartIndex && assistantText && clipboardSupported
"
:label="copied ? ai.responseCopied : ai.copyResponse"
size="xs"
data-slot="chat-copy-response"

View file

@ -115,7 +115,7 @@ export const Interaction: Story = {
await userEvent.type(title, 'My dashboard')
await userEvent.click(canvas.getByRole('button', { name: 'Delete fixture' }))
await expect(title).toHaveValue('Monthly expense dashboard')
await expect(canvas.getByRole('button', { name: 'Create Frame Done' })).toBeVisible()
await expect(canvas.getByRole('button', { name: /^Done Create Frame\b/ })).toBeVisible()
}
}
export const Empty: Story = { args: { initialChat: 'empty' } }

View file

@ -1,11 +1,12 @@
<script setup lang="ts">
import type { ChatStatus, UIMessage } from 'ai'
import { ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
import { computed, ref } from 'vue'
import { computed, provide, ref } from 'vue'
import { useI18n } from '@open-pencil/vue'
import type { AttachmentPresentation } from '@/app/ai/attachment/presentation/types'
import { CHAT_NODES_LIVE } from '@/components/chat/tool/context'
import AppButton from '@/components/ui/button/AppButton.vue'
import IconButton from '@/components/ui/button/IconButton.vue'
import AppPlaceholder from '@/components/ui/feedback/AppPlaceholder.vue'
@ -16,15 +17,22 @@ import { useScrollFollowing } from './transcript/useScrollFollowing'
const {
messages,
status,
showContinue = false
showContinue = false,
nodesLive = true
} = defineProps<{
messages: UIMessage[]
status: ChatStatus
showContinue?: boolean
presentations?: Record<string, { text?: string; attachments?: AttachmentPresentation[] }>
/** False for a conversation from another document: its layer IDs are not this document's. */
nodesLive?: boolean
}>()
const emit = defineEmits<{ continue: [] }>()
const { ai } = useI18n()
provide(
CHAT_NODES_LIVE,
computed(() => nodesLive)
)
const running = computed(() => status === 'submitted' || status === 'streaming')
const isThinking = computed(() => {
if (!running.value) return false

View file

@ -83,7 +83,7 @@ function updateOpen(value: boolean): void {
>
<CollapsibleTrigger
data-slot="chat-reasoning-trigger"
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] text-muted hover:bg-hover hover:text-surface"
class="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] text-muted hover:bg-hover hover:text-surface"
>
<icon-lucide-brain class="size-3.5 shrink-0 text-accent" aria-hidden="true" />
<span class="flex-1">{{ streaming ? thinkingLabel : finishedLabel }}</span>

View file

@ -0,0 +1,116 @@
<script setup lang="ts">
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui'
import { computed, defineAsyncComponent, ref } from 'vue'
import { useI18n } from '@open-pencil/vue'
import {
displayedToolOutput,
toolDisplayName,
toolErrorText,
toolHasInput,
toolImage,
toolNodeIds,
toolSource,
toolSummary,
type ToolCallPart
} from '@/app/ai/chat/tool-calls/display'
import { toolCallState } from '@/components/chat/tool/state'
import ToolNodeChips from '@/components/chat/tool/ToolNodeChips.vue'
import AppAlert from '@/components/ui/feedback/AppAlert.vue'
import SegmentedControl from '@/components/ui/select/SegmentedControl.vue'
import { chatToolTheme } from '@/theme/chat/tool'
import { collapsibleContentMotion } from '@/theme/collapsible/collapsible'
// CodeMirror loads with the first expanded call, not with the chat panel.
const CodeViewer = defineAsyncComponent(() => import('@/components/code-editor/CodeViewer.vue'))
const { part } = defineProps<{ part: ToolCallPart }>()
const { ai } = useI18n()
const ui = chatToolTheme()
const state = computed(() => toolCallState(part))
const name = computed(() => toolDisplayName(part))
const summary = computed(() => toolSummary(part))
const source = computed(() => toolSource(part))
const error = computed(() => toolErrorText(part))
const image = computed(() => toolImage(part))
const nodeIds = computed(() => (state.value === 'pending' ? [] : toolNodeIds(part)))
const hasInput = computed(() => toolHasInput(part))
const output = computed(() => displayedToolOutput(part))
// Error results such as `diff_apply`'s mismatch report stay inspectable next to the alert.
const hasOutput = computed(() => output.value !== undefined)
// Streaming source is worth watching; other pending calls have nothing to show yet.
const expandable = computed(() => state.value !== 'pending' || source.value !== null)
const view = ref<'input' | 'output'>('output')
const shownView = computed(() => {
if (!hasOutput.value) return 'input'
if (!hasInput.value) return 'output'
return view.value
})
const viewOptions = computed(() => [
{ value: 'input', label: ai.value.toolInput },
{ value: 'output', label: ai.value.toolOutput }
])
const stateLabel = computed(() => {
if (state.value === 'pending') return ai.value.toolRunning
return state.value === 'done' ? ai.value.toolFinished : ai.value.toolError
})
function json(value: unknown): string {
return JSON.stringify(value, null, 2) ?? ''
}
</script>
<template>
<CollapsibleRoot :class="ui.root()" :data-tool-state="state" data-slot="chat-tool-call">
<CollapsibleTrigger :class="ui.trigger()" :disabled="!expandable">
<span :class="ui.status()" :data-state="state" :aria-label="stateLabel" role="img">
<icon-lucide-loader-circle
v-if="state === 'pending'"
:class="[ui.statusIcon(), 'animate-spin motion-reduce:animate-none']"
/>
<icon-lucide-check v-else-if="state === 'done'" :class="ui.statusIcon()" />
<icon-lucide-triangle-alert v-else :class="ui.statusIcon()" />
</span>
<span :class="ui.name()">{{ name }}</span>
<span :class="ui.summary()">{{ summary }}</span>
<icon-lucide-chevron-down v-if="expandable" :class="ui.chevron()" aria-hidden="true" />
</CollapsibleTrigger>
<CollapsibleContent :class="collapsibleContentMotion">
<div :class="ui.body()">
<AppAlert v-if="error" tone="error" :heading="ai.toolError" :description="error" />
<ToolNodeChips :ids="nodeIds" />
<SegmentedControl
v-if="hasInput && hasOutput"
v-model="view"
:options="viewOptions"
:label="ai.toolDetails"
size="sm"
/>
<template v-if="shownView === 'input'">
<CodeViewer
v-if="source"
:code="source.code"
:language="source.language"
:label="ai.toolInput"
/>
<CodeViewer
v-else-if="hasInput"
:code="json(part.input)"
language="json"
:label="ai.toolInput"
/>
</template>
<img v-else-if="image" :src="image" :alt="summary || name" :class="ui.image()" />
<CodeViewer
v-else-if="hasOutput"
:code="json(output)"
language="json"
:label="ai.toolOutput"
/>
</div>
</CollapsibleContent>
</CollapsibleRoot>
</template>

View file

@ -0,0 +1,83 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { ToolCallPart } from '@/app/ai/chat/tool-calls/display'
import ToolCallGroup from './ToolCallGroup.vue'
const PREVIEW_PNG =
'iVBORw0KGgoAAAANSUhEUgAAAGAAAAA4CAYAAAACRf2iAAAAgElEQVR42u3boQ0AIAxE0e5Kwv4VCBRMQCqbkCf+Avf0Ra591FcYAQAAAQAgAAAEAIAAAKgaM/UIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAActIDIAAABACAAAAQgH+7S7Yd8vOtmqgAAAAASUVORK5CYII='
const JSX = `<Frame name="Pricing" w={1200} flex="col" gap={32} p={64} bg="#FFFFFF">
<Text size={40} weight="bold">Simple pricing</Text>
<Frame flex="row" gap={24}>
<Frame name="Starter" w="fill" p={24} rounded={16} bg="#F1F5F9" />
</Frame>
</Frame>`
function part(fields: Record<string, unknown>): ToolCallPart {
return { state: 'input-available', input: {}, ...fields } as ToolCallPart
}
const rendered = part({
type: 'tool-render',
toolCallId: 'render',
state: 'output-available',
input: { jsx: JSX },
output: { id: '12:4', name: 'Pricing', children: 3 }
})
const streaming = part({
type: 'tool-render',
toolCallId: 'render-streaming',
state: 'input-streaming',
input: { jsx: JSX.slice(0, 140) }
})
const exported = part({
type: 'tool-export_image',
toolCallId: 'export',
state: 'output-available',
input: { ids: ['12:4'], maxEdge: 640 },
output: { base64: PREVIEW_PNG, mimeType: 'image/png', width: 96, height: 56 }
})
const failed = part({
type: 'tool-set_fill',
toolCallId: 'failed',
state: 'output-available',
input: { id: '99:1', color: '#3B82F6' },
output: { error: 'Node "99:1" not found' }
})
const described = part({
type: 'tool-describe',
toolCallId: 'describe',
state: 'output-available',
input: { id: '12:4', depth: 2 },
output: { id: '12:4', role: 'section', issues: ['Text overflows its frame'] }
})
interface Args {
parts: ToolCallPart[]
}
const meta = {
title: 'Chat/Tool calls',
component: ToolCallGroup,
args: { parts: [rendered] },
render: (args) => ({
components: { ToolCallGroup },
setup: () => ({ args }),
template: '<div class="w-80"><ToolCallGroup v-bind="args" /></div>'
})
} satisfies Meta<Args>
export default meta
type Story = StoryObj<Omit<typeof meta, 'component'>>
export const Rendered: Story = {}
export const StreamingRender: Story = { args: { parts: [streaming] } }
export const ExportedImage: Story = { args: { parts: [exported] } }
export const Failed: Story = { args: { parts: [failed] } }
export const LongRun: Story = {
args: { parts: [described, rendered, failed, exported, { ...streaming, toolCallId: 'last' }] }
}

View file

@ -0,0 +1,50 @@
<script setup lang="ts">
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui'
import { computed } from 'vue'
import { useI18n } from '@open-pencil/vue'
import type { ToolCallPart } from '@/app/ai/chat/tool-calls/display'
import { toolCallState } from '@/components/chat/tool/state'
import ToolCallCard from '@/components/chat/tool/ToolCallCard.vue'
import { chatToolTheme } from '@/theme/chat/tool'
import { collapsibleContentMotion } from '@/theme/collapsible/collapsible'
/** Runs up to this long stay expanded; longer ones fold all but the latest call. */
const INLINE_CALLS = 3
const { parts } = defineProps<{ parts: ToolCallPart[] }>()
const { ai } = useI18n()
const ui = chatToolTheme()
const earlier = computed(() => parts.slice(0, -1))
const latest = computed(() => parts.at(-1))
const failed = computed(
() => earlier.value.filter((part) => toolCallState(part) === 'error').length
)
const label = computed(() => {
const steps = ai.value.toolSteps({ count: earlier.value.length })
return failed.value ? `${steps} · ${ai.value.toolStepsFailed({ count: failed.value })}` : steps
})
</script>
<template>
<div v-if="parts.length <= INLINE_CALLS" class="space-y-1.5">
<ToolCallCard v-for="part in parts" :key="part.toolCallId" :part="part" />
</div>
<div v-else class="space-y-1.5">
<CollapsibleRoot :class="ui.group()" data-slot="chat-tool-group">
<CollapsibleTrigger :class="ui.groupTrigger()">
<icon-lucide-list-checks :class="ui.groupIcon()" aria-hidden="true" />
<span class="flex-1">{{ label }}</span>
<icon-lucide-chevron-down :class="ui.chevron()" aria-hidden="true" />
</CollapsibleTrigger>
<CollapsibleContent :class="collapsibleContentMotion">
<div :class="ui.groupItems()">
<ToolCallCard v-for="part in earlier" :key="part.toolCallId" :part="part" />
</div>
</CollapsibleContent>
</CollapsibleRoot>
<ToolCallCard v-if="latest" :key="latest.toolCallId" :part="latest" />
</div>
</template>

View file

@ -0,0 +1,28 @@
<script setup lang="ts">
import { useI18n } from '@open-pencil/vue'
import { useToolNodes } from '@/components/chat/tool/useToolNodes'
import { chatToolTheme } from '@/theme/chat/tool'
const { ids } = defineProps<{ ids: string[] }>()
const { ai } = useI18n()
const ui = chatToolTheme()
const { nodes, show } = useToolNodes(() => ids)
</script>
<template>
<div v-if="nodes.length" :class="ui.nodes()" data-slot="chat-tool-nodes">
<button
v-for="node in nodes"
:key="node.id"
type="button"
:class="ui.node()"
:disabled="!node.present"
:aria-label="ai.showNodeOnCanvas({ name: node.label })"
@click="void show(node.id)"
>
<icon-lucide-crosshair :class="ui.nodeIcon()" aria-hidden="true" />
<span :class="ui.nodeLabel()">{{ node.label }}</span>
</button>
</div>
</template>

View file

@ -0,0 +1,7 @@
import type { InjectionKey, Ref } from 'vue'
/**
* Whether node IDs in the transcript refer to the open document. A conversation from another
* document shows its tool calls, but their layers are not this document's.
*/
export const CHAT_NODES_LIVE: InjectionKey<Readonly<Ref<boolean>>> = Symbol('chat-nodes-live')

View file

@ -1,3 +1,7 @@
import { getToolName } from 'ai'
import type { ToolCallPart } from '@/app/ai/chat/tool-calls/display'
export type ToolDisplayState = 'pending' | 'done' | 'error'
export type ToolStateInput = {
@ -33,3 +37,8 @@ export function classifyToolState({ toolName, state, output }: ToolStateInput):
if (state === 'output-available') return 'done'
return 'pending'
}
/** A chat tool call's display state. */
export function toolCallState(part: ToolCallPart): ToolDisplayState {
return classifyToolState({ toolName: getToolName(part), state: part.state, output: part.output })
}

View file

@ -0,0 +1,60 @@
import { computed, inject, ref } from 'vue'
import { getActiveEditorStoreOrNull, useActiveEditorStoreRef } from '@/app/editor/active-store'
import { focusNodesOnTheirPage } from '@/app/editor/selection/focus'
import { notificationMessages } from '@/app/i18n/notifications'
import { toast } from '@/app/shell/ui'
import { CHAT_NODES_LIVE } from '@/components/chat/tool/context'
const MAX_NODES = 8
export interface ToolNode {
id: string
label: string
/** Whether the node is still in the open document, so it can be shown. */
present: boolean
}
/**
* The layers a tool call touched, looked up in the open document, and a way to show one on
* the canvas. A conversation from another document lists its IDs without resolving them.
*/
export function useToolNodes(ids: () => string[]) {
const live = inject(CHAT_NODES_LIVE, ref(true))
const activeStore = useActiveEditorStoreRef()
const nodes = computed<ToolNode[]>(() => {
const store = activeStore.value
// The graph is not reactive; the scene version changes with every edit to it.
void store?.state.sceneVersion
const graph = live.value ? store?.graph : undefined
return ids()
.slice(0, MAX_NODES)
.map((id) => {
const node = graph?.getNode(id)
return {
id,
label: node?.name || id,
present: node !== undefined && node.type !== 'CANVAS'
}
})
})
async function show(id: string): Promise<void> {
const store = getActiveEditorStoreOrNull()
if (!store || !live.value) return
try {
await focusNodesOnTheirPage(store, [id])
} catch (error) {
// A newer page switch supersedes this one.
if (error instanceof Error && error.name === 'AbortError') return
toast.error(
notificationMessages.get().operationFailed({
error: error instanceof Error ? error.message : String(error)
})
)
}
}
return { nodes, show }
}

View file

@ -55,9 +55,9 @@ export function useScrollFollowing(
'click',
(event) => {
if (!(event.target instanceof Element)) return
if (event.target.closest('[data-slot="chat-reasoning-trigger"]')) {
following.value = false
}
// Opening reasoning or a tool call is reading, not new output: following would pin the
// bottom on every frame of the animation and slide the opened content away.
if (event.target.closest('[aria-expanded]')) following.value = false
},
{ capture: true }
)

View file

@ -6,7 +6,7 @@ import { javascript } from '@codemirror/lang-javascript'
import { bracketMatching, foldGutter, foldKeymap, indentOnInput } from '@codemirror/language'
import { lintKeymap } from '@codemirror/lint'
import { searchKeymap } from '@codemirror/search'
import { Compartment, EditorState, Transaction, type Extension } from '@codemirror/state'
import { EditorState, Transaction, type Extension } from '@codemirror/state'
import {
drawSelection,
EditorView,
@ -16,11 +16,11 @@ import {
keymap,
lineNumbers
} from '@codemirror/view'
import { onBeforeUnmount, onMounted, useTemplateRef, watch } from 'vue'
import { useTemplateRef, watch } from 'vue'
import { resolvedAppTheme } from '@/app/shell/theme'
import { designJSXExtensions } from '@/components/code-editor/extensions'
import type { CodeEditorLanguage } from '@/components/code-editor/types'
import { useCodeMirror } from '@/components/code-editor/useCodeMirror'
import { codeEditorTheme } from '@/theme/code/editor'
const {
@ -39,12 +39,6 @@ const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const host = useTemplateRef('host')
const languageCompartment = new Compartment()
const editableCompartment = new Compartment()
const labelCompartment = new Compartment()
const themeCompartment = new Compartment()
let editor: EditorView | undefined
let externalUpdate = false
function languageExtensions(language: CodeEditorLanguage): Extension {
@ -59,52 +53,46 @@ function editableExtensions(readOnly: boolean): Extension {
return [EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly)]
}
onMounted(() => {
const parent = host.value
if (!parent) return
editor = new EditorView({
doc: modelValue,
parent,
extensions: [
lineNumbers(),
highlightActiveLineGutter(),
highlightSpecialChars(),
history(),
foldGutter(),
drawSelection(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
themeCompartment.of(codeEditorTheme(resolvedAppTheme.value === 'dark')),
bracketMatching(),
closeBrackets(),
highlightActiveLine(),
keymap.of([
{ key: 'Ctrl-z', run: undo },
{ key: 'Ctrl-Shift-z', run: redo },
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
...lintKeymap
]),
languageCompartment.of(languageExtensions(language)),
editableCompartment.of(editableExtensions(readOnly)),
labelCompartment.of(EditorView.contentAttributes.of({ 'aria-label': label })),
EditorView.lineWrapping,
EditorView.updateListener.of((update) => {
if (!update.docChanged || externalUpdate) return
emit('update:modelValue', update.state.doc.toString())
})
]
})
const view = useCodeMirror(useTemplateRef('host'), {
doc: () => modelValue,
label: () => label,
theme: codeEditorTheme,
extensions: [
lineNumbers(),
highlightActiveLineGutter(),
highlightSpecialChars(),
history(),
foldGutter(),
drawSelection(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
bracketMatching(),
closeBrackets(),
highlightActiveLine(),
keymap.of([
{ key: 'Ctrl-z', run: undo },
{ key: 'Ctrl-Shift-z', run: redo },
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
...lintKeymap
]),
EditorView.lineWrapping,
EditorView.updateListener.of((update) => {
if (!update.docChanged || externalUpdate) return
emit('update:modelValue', update.state.doc.toString())
})
],
reactive: [() => languageExtensions(language), () => editableExtensions(readOnly)]
})
watch(
() => modelValue,
(value) => {
const editor = view.value
if (!editor || editor.state.doc.toString() === value) return
externalUpdate = true
editor.dispatch({
@ -114,36 +102,12 @@ watch(
externalUpdate = false
}
)
watch(
() => language,
(language) =>
editor?.dispatch({ effects: languageCompartment.reconfigure(languageExtensions(language)) })
)
watch(
() => readOnly,
(readOnly) =>
editor?.dispatch({ effects: editableCompartment.reconfigure(editableExtensions(readOnly)) })
)
watch(
() => label,
(label) =>
editor?.dispatch({
effects: labelCompartment.reconfigure(
EditorView.contentAttributes.of({ 'aria-label': label })
)
})
)
watch(resolvedAppTheme, (theme) =>
editor?.dispatch({ effects: themeCompartment.reconfigure(codeEditorTheme(theme === 'dark')) })
)
onBeforeUnmount(() => editor?.destroy())
</script>
<template>
<div ref="host" data-slot="code-editor" class="min-h-0 flex-1 overflow-hidden text-xs" />
<div
ref="host"
data-slot="code-editor"
class="min-h-0 flex-1 overflow-hidden text-xs [&_.cm-scroller]:scrollbar-thin"
/>
</template>

View file

@ -0,0 +1,63 @@
<script setup lang="ts">
import { javascript } from '@codemirror/lang-javascript'
import { json } from '@codemirror/lang-json'
import { foldGutter } from '@codemirror/language'
import { search, searchKeymap } from '@codemirror/search'
import { EditorState, type Extension } from '@codemirror/state'
import { EditorView, highlightSpecialChars, keymap } from '@codemirror/view'
import { useTemplateRef, watch } from 'vue'
import { useCodeMirror } from '@/components/code-editor/useCodeMirror'
import { codeEditorTheme, codeViewerTheme } from '@/theme/code/editor'
export type CodeViewerLanguage = 'json' | 'design-jsx' | 'javascript'
const { code, language, label } = defineProps<{
code: string
language: CodeViewerLanguage
label: string
}>()
function languageExtension(language: CodeViewerLanguage): Extension {
return language === 'json' ? json() : javascript({ jsx: true, typescript: true })
}
const view = useCodeMirror(useTemplateRef('host'), {
doc: () => code,
label: () => label,
theme: (dark) => [codeEditorTheme(dark), codeViewerTheme],
extensions: [
EditorState.readOnly.of(true),
highlightSpecialChars(),
foldGutter(),
search({ top: true }),
keymap.of(searchKeymap),
EditorView.lineWrapping
],
reactive: [() => languageExtension(language)]
})
// Streamed tool input only grows; append when possible so folds and scroll survive.
watch(
() => code,
(next) => {
const editor = view.value
if (!editor) return
const current = editor.state.doc.toString()
if (current === next) return
editor.dispatch(
next.startsWith(current)
? { changes: { from: current.length, insert: next.slice(current.length) } }
: { changes: { from: 0, to: current.length, insert: next } }
)
}
)
</script>
<template>
<div
ref="host"
data-slot="code-viewer"
class="max-h-64 overflow-hidden rounded border border-border [&_.cm-scroller]:scrollbar-thin"
/>
</template>

View file

@ -0,0 +1,57 @@
import { Compartment, type Extension } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { onBeforeUnmount, onMounted, shallowRef, watch, type ShallowRef } from 'vue'
import { resolvedAppTheme } from '@/app/shell/theme'
export interface CodeMirrorOptions {
/** The document the editor opens with; later changes are the caller's to dispatch. */
doc: () => string
/** The editor's accessible name. */
label: () => string
/** The editor theme for the resolved app theme. */
theme: (dark: boolean) => Extension
/** Extensions fixed for the editor's lifetime. */
extensions: Extension[]
/** Extensions rebuilt in their own compartment whenever the reactive state they read changes. */
reactive?: (() => Extension)[]
}
/**
* A CodeMirror view mounted into `host` for the component's lifetime. The label, the theme,
* and each `reactive` extension reconfigure in place, so the document, folds, and scroll
* survive.
*/
export function useCodeMirror(
host: Readonly<ShallowRef<HTMLElement | null>>,
options: CodeMirrorOptions
): Readonly<ShallowRef<EditorView | undefined>> {
const view = shallowRef<EditorView>()
const configurable = [
() => EditorView.contentAttributes.of({ 'aria-label': options.label() }),
() => options.theme(resolvedAppTheme.value === 'dark'),
...(options.reactive ?? [])
].map((build) => ({ build, compartment: new Compartment() }))
onMounted(() => {
const parent = host.value
if (!parent) return
view.value = new EditorView({
doc: options.doc(),
parent,
extensions: [
...options.extensions,
...configurable.map(({ build, compartment }) => compartment.of(build()))
]
})
})
for (const { build, compartment } of configurable) {
watch(build, (extension) => {
view.value?.dispatch({ effects: compartment.reconfigure(extension) })
})
}
onBeforeUnmount(() => view.value?.destroy())
return view
}

28
src/theme/chat/tool.ts Normal file
View file

@ -0,0 +1,28 @@
import { tv } from 'tailwind-variants'
export const chatToolTheme = tv({
slots: {
root: 'rounded-lg border border-border bg-canvas',
trigger:
'flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left hover:bg-hover disabled:cursor-default disabled:hover:bg-transparent',
status:
'flex size-4 shrink-0 items-center justify-center rounded-full data-[state=done]:bg-green-500/20 data-[state=done]:text-green-400 data-[state=error]:bg-red-500/20 data-[state=error]:text-red-400 data-[state=pending]:bg-accent/20 data-[state=pending]:text-accent',
statusIcon: 'size-3',
name: 'shrink-0 text-[11px] font-medium text-surface',
summary: 'min-w-0 flex-1 truncate font-mono text-[10px] text-muted',
chevron:
'ml-auto size-3 shrink-0 text-muted transition-transform motion-reduce:transition-none [[data-state=open]>&]:rotate-180',
body: 'space-y-2 border-t border-border px-2 py-2 text-[10px]',
nodes: 'flex flex-wrap gap-1',
node: 'inline-flex max-w-40 cursor-pointer items-center gap-1 rounded border border-border bg-input px-1.5 py-0.5 text-[10px] text-surface hover:border-accent hover:text-accent disabled:cursor-default disabled:opacity-50 disabled:hover:border-border disabled:hover:text-surface',
nodeIcon: 'size-3 shrink-0 text-muted',
nodeLabel: 'truncate',
image:
'max-h-56 w-auto max-w-full rounded border border-border bg-[repeating-conic-gradient(var(--color-hover)_0_25%,transparent_0_50%)] bg-size-[12px_12px] object-contain',
group: 'rounded-lg border border-dashed border-border',
groupTrigger:
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[11px] text-muted hover:bg-hover hover:text-surface',
groupIcon: 'size-3.5 shrink-0 text-accent',
groupItems: 'space-y-1.5 px-1.5 pb-1.5'
}
})

View file

@ -29,7 +29,11 @@ export function codeEditorTheme(dark: boolean) {
fontSize: '12px'
},
'&.cm-focused': { outline: 'none' },
'.cm-scroller': { overflow: 'auto', fontFamily: 'var(--font-mono)', lineHeight: '1.65' },
'.cm-scroller': {
overflow: 'auto',
fontFamily: 'var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
lineHeight: '1.65'
},
'.cm-content': { padding: '10px 0', caretColor: 'var(--color-accent)' },
'.cm-line': { padding: '0 8px' },
'.cm-gutters': {
@ -97,3 +101,12 @@ export function codeEditorTheme(dark: boolean) {
)
]
}
/** Compact read-only code in chat tool calls, layered over `codeEditorTheme`. */
export const codeViewerTheme = EditorView.theme({
'&': { maxHeight: '16rem', fontSize: '10.5px', backgroundColor: 'var(--color-input)' },
'.cm-scroller': { lineHeight: '1.55' },
'.cm-content': { padding: '6px 0' },
'.cm-line': { padding: '0 6px' },
'.cm-gutters': { backgroundColor: 'var(--color-input)' }
})

View file

@ -0,0 +1,157 @@
import { describe, expect, test } from 'bun:test'
import { fromUint8Array } from 'js-base64'
import {
displayedToolOutput,
groupMessageParts,
toolErrorText,
toolHasInput,
toolImage,
toolNodeIds,
toolSource,
toolSummary,
type ChatMessagePart,
type ToolCallPart
} from '@/app/ai/chat/tool-calls/display'
function call(part: Partial<ToolCallPart> & { type: `tool-${string}` }): ToolCallPart {
return {
toolCallId: part.type,
state: 'input-available',
input: {},
...part
} as ToolCallPart
}
describe('tool call display', () => {
test('summarizes render calls by their root element and name, even while streaming', () => {
const partial = call({
type: 'tool-render',
state: 'input-streaming',
input: { jsx: '<Frame name="Pricing" w={1200}>\n <Text>Pl' }
})
expect(toolSummary(partial)).toBe('Frame “Pricing”')
expect(toolSource(partial)).toEqual({
code: '<Frame name="Pricing" w={1200}>\n <Text>Pl',
language: 'design-jsx'
})
})
test('names a render call only by its root element, not by a named child', () => {
const unnamedRoot = call({
type: 'tool-render',
input: { jsx: '<Frame w={320}>\n <Text name="Price">$9</Text>\n</Frame>' }
})
expect(toolSummary(unnamedRoot)).toBe('Frame')
})
test('summarizes scripts by their first line and other calls by a naming field', () => {
expect(toolSummary(call({ type: 'tool-eval', input: { code: 'const a = 1\nreturn a' } }))).toBe(
'const a = 1'
)
expect(toolSummary(call({ type: 'tool-find_nodes', input: { query: 'Button' } }))).toBe(
'Button'
)
expect(toolSummary(call({ type: 'tool-batch_update', input: { ids: ['1:2', '1:3'] } }))).toBe(
'2 nodes'
)
})
test('collects targeted and produced node ids, but not deleted ones', () => {
expect(
toolNodeIds(
call({
type: 'tool-render',
state: 'output-available',
input: { replace_id: '1:1' },
output: { id: '2:1', results: [{ id: '2:2' }] }
})
)
).toEqual(['1:1', '2:1', '2:2'])
expect(
toolNodeIds(
call({
type: 'tool-delete_node',
state: 'output-available',
input: {},
output: { deleted: '1:5', id: '1:5' }
})
)
).toEqual([])
})
test('reads errors from failed calls and from error outputs', () => {
expect(toolErrorText(call({ type: 'tool-x', state: 'output-error', errorText: 'Boom' }))).toBe(
'Boom'
)
expect(
toolErrorText(
call({ type: 'tool-x', state: 'output-available', output: { error: 'Not found' } })
)
).toBe('Not found')
})
test('shows exported images inline and elides their base64 from the JSON output', () => {
const base64 = fromUint8Array(new Uint8Array([137, 80, 78, 71]))
const part = call({
type: 'tool-export_image',
state: 'output-available',
output: { base64, mimeType: 'image/png', width: 10 }
})
expect(toolImage(part)).toBe(`data:image/png;base64,${base64}`)
expect(displayedToolOutput(part)).toEqual({
base64: `<${base64.length} base64 characters>`,
mimeType: 'image/png',
width: 10
})
expect(
toolImage(
call({
type: 'tool-export_image',
state: 'output-available',
output: { base64, mimeType: 'image/svg+xml' }
})
)
).toBeNull()
})
test('groups consecutive tool calls between other parts', () => {
const parts: ChatMessagePart[] = [
{ type: 'text', text: 'Plan' },
call({ type: 'tool-get_selection' }),
call({ type: 'tool-render' }),
{ type: 'text', text: 'Done' },
call({ type: 'tool-describe' })
]
expect(
groupMessageParts(parts).map((group) =>
group.kind === 'tools' ? group.parts.map(({ index }) => index) : group.index
)
).toEqual([0, [1, 2], 3, [4]])
})
test('keeps a run of calls together across the unrendered step boundaries between them', () => {
const parts: ChatMessagePart[] = [
call({ type: 'tool-get_selection' }),
{ type: 'step-start' },
call({ type: 'tool-render' }),
{ type: 'step-start' },
{ type: 'text', text: 'Done' }
]
expect(
groupMessageParts(parts).map((group) =>
group.kind === 'tools' ? group.parts.map(({ index }) => index) : group.index
)
).toEqual([[0, 2], 4])
})
})
describe('toolHasInput', () => {
test('counts source or any argument as input', () => {
expect(toolHasInput(call({ type: 'tool-render', input: { jsx: '<Frame />' } }))).toBe(true)
expect(toolHasInput(call({ type: 'tool-get_node', input: { id: '1:2' } }))).toBe(true)
expect(toolHasInput(call({ type: 'tool-get_selection', input: {} }))).toBe(false)
expect(toolHasInput(call({ type: 'tool-get_selection', input: undefined }))).toBe(false)
})
})

View file

@ -29,11 +29,33 @@ test('multipart assistant messages expose one copy action', async ({ configuredC
test('tool calls render their result', async ({ configuredChat: chat }) => {
await chat.submit('Create a frame')
await expect(chat.assistantMessage().getByText('Create Shape')).toBeVisible()
await expect(chat.assistantMessage().getByText('Done')).toBeVisible()
// The status icon's label leads the call's accessible name.
await expect(
chat.assistantMessage().getByRole('button', { name: /^Done Create Shape\b/ })
).toBeVisible()
await expect(chat.assistantMessage().getByText('Created a frame', { exact: false })).toBeVisible()
})
test('opening a tool call at the bottom keeps it in place instead of following', async ({
configuredChat: chat,
page
}) => {
await chat.submit(Array.from({ length: 60 }, (_, i) => `Context line ${i}`).join('\n'))
await chat.submit('Create a frame')
const trigger = chat.assistantMessage().getByRole('button', { name: /^Done Create Shape\b/ })
await expect(trigger).toBeVisible()
const top = async () => (await trigger.boundingBox())?.y ?? 0
const before = await top()
await trigger.click()
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
// Sample through the expansion: following would scroll the header up as the card grows.
for (let frame = 0; frame < 10; frame++) {
expect(Math.abs((await top()) - before)).toBeLessThan(2)
await page.waitForTimeout(30)
}
})
test('authentication errors explain the cause and link to Settings', async ({
configuredChat: chat
}) => {

View file

@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test'
import { classifyToolState, isMCPToolName } from '@/components/chat/tool-state'
import type { ToolCallPart } from '@/app/ai/chat/tool-calls/display'
import { classifyToolState, isMCPToolName, toolCallState } from '@/components/chat/tool/state'
describe('classifyToolState', () => {
test('recognizes MCP tools by their names', () => {
@ -62,3 +63,15 @@ describe('classifyToolState', () => {
).toBe('error')
})
})
describe('toolCallState', () => {
test('classifies a chat part by its tool name, state, and output', () => {
const part = (fields: Partial<ToolCallPart>) =>
({ type: 'tool-diff_apply', toolCallId: 'call', input: {}, ...fields }) as ToolCallPart
expect(toolCallState(part({ state: 'input-streaming' }))).toBe('pending')
expect(toolCallState(part({ state: 'output-available', output: { applied: 1 } }))).toBe('done')
expect(
toolCallState(part({ state: 'output-available', output: { error: 'Patch does not apply' } }))
).toBe('error')
})
})

View file

@ -4,6 +4,7 @@ import {
findNodesByName,
focusNodes,
focusNodesByName,
focusNodesOnTheirPage,
type FocusStore
} from '@/app/editor/selection/focus'
@ -142,3 +143,15 @@ test('ignores ids that are no longer in the document', () => {
expect(focusNodes(store, [stale.id, live.id])).toBe(true)
expect(selected).toEqual([[live.id]])
})
test('focuses nodes on their own page, switching to it first', async () => {
const { graph, pageId, store, selected } = harness()
const other = graph.addPage('Run page')
const frame = graph.createNode('FRAME', other.id, { name: 'Hero' })
const elsewhere = graph.createNode('FRAME', pageId, { name: 'Here' })
expect(await focusNodesOnTheirPage(store, ['missing', frame.id, elsewhere.id])).toBe(true)
expect(store.state.currentPageId).toBe(other.id)
expect(selected).toEqual([[frame.id]])
expect(await focusNodesOnTheirPage(store, ['missing'])).toBe(false)
})