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:
parent
3f594fdc3a
commit
249f0cca87
|
|
@ -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.
|
||||
|
|
|
|||
5
bun.lock
5
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=="],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,12 @@
|
|||
"toolFinished": "完了",
|
||||
"toolRunning": "実行中…",
|
||||
"toolError": "エラー",
|
||||
"toolInput": "入力",
|
||||
"toolOutput": "出力",
|
||||
"toolDetails": "ツール呼び出しの詳細",
|
||||
"toolSteps": "以前のステップ: {count}",
|
||||
"toolStepsFailed": "失敗: {count}",
|
||||
"showNodeOnCanvas": "{name} をキャンバスに表示",
|
||||
"unknownTool": "不明なツール",
|
||||
"permissionRequestTitle": "権限リクエスト",
|
||||
"permissionRequest": "{tool}が権限を要求しています。",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,12 @@
|
|||
"toolFinished": "Готово",
|
||||
"toolRunning": "Выполняется…",
|
||||
"toolError": "Ошибка",
|
||||
"toolInput": "Вход",
|
||||
"toolOutput": "Результат",
|
||||
"toolDetails": "Подробности вызова инструмента",
|
||||
"toolSteps": "Предыдущие шаги: {count}",
|
||||
"toolStepsFailed": "с ошибкой: {count}",
|
||||
"showNodeOnCanvas": "Показать {name} на холсте",
|
||||
"unknownTool": "Неизвестный инструмент",
|
||||
"permissionRequestTitle": "Запрос разрешения",
|
||||
"permissionRequest": "Инструмент «{tool}» запрашивает разрешение.",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,12 @@
|
|||
"toolFinished": "完成",
|
||||
"toolRunning": "正在运行…",
|
||||
"toolError": "错误",
|
||||
"toolInput": "输入",
|
||||
"toolOutput": "输出",
|
||||
"toolDetails": "工具调用详情",
|
||||
"toolSteps": "之前的步骤:{count}",
|
||||
"toolStepsFailed": "失败:{count}",
|
||||
"showNodeOnCanvas": "在画布上显示 {name}",
|
||||
"unknownTool": "未知工具",
|
||||
"permissionRequestTitle": "权限请求",
|
||||
"permissionRequest": "{tool} 正在请求权限。",
|
||||
|
|
|
|||
|
|
@ -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.'),
|
||||
|
|
|
|||
148
src/app/ai/chat/tool-calls/display.ts
Normal file
148
src/app/ai/chat/tool-calls/display.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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' } }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
116
src/components/chat/tool/ToolCallCard.vue
Normal file
116
src/components/chat/tool/ToolCallCard.vue
Normal 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>
|
||||
83
src/components/chat/tool/ToolCallGroup.stories.ts
Normal file
83
src/components/chat/tool/ToolCallGroup.stories.ts
Normal 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' }] }
|
||||
}
|
||||
50
src/components/chat/tool/ToolCallGroup.vue
Normal file
50
src/components/chat/tool/ToolCallGroup.vue
Normal 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>
|
||||
28
src/components/chat/tool/ToolNodeChips.vue
Normal file
28
src/components/chat/tool/ToolNodeChips.vue
Normal 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>
|
||||
7
src/components/chat/tool/context.ts
Normal file
7
src/components/chat/tool/context.ts
Normal 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')
|
||||
|
|
@ -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 })
|
||||
}
|
||||
60
src/components/chat/tool/useToolNodes.ts
Normal file
60
src/components/chat/tool/useToolNodes.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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 }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
63
src/components/code-editor/CodeViewer.vue
Normal file
63
src/components/code-editor/CodeViewer.vue
Normal 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>
|
||||
57
src/components/code-editor/useCodeMirror.ts
Normal file
57
src/components/code-editor/useCodeMirror.ts
Normal 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
28
src/theme/chat/tool.ts
Normal 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'
|
||||
}
|
||||
})
|
||||
|
|
@ -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)' }
|
||||
})
|
||||
|
|
|
|||
157
tests/app/ai/chat/tool-calls/display.test.ts
Normal file
157
tests/app/ai/chat/tool-calls/display.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue