* 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.
81 lines
3.3 KiB
TypeScript
81 lines
3.3 KiB
TypeScript
import { expect, test } from '#tests/helpers/chat/fixture'
|
|
|
|
test('assistant responds', async ({ configuredChat: chat }) => {
|
|
await chat.submit('Hello there')
|
|
await expect(chat.assistantMessage()).toContainText('mock response')
|
|
})
|
|
|
|
test('reasoning and response copy actions render', async ({ configuredChat: chat }) => {
|
|
await chat.submit('Show reasoning')
|
|
|
|
// Reasoning that streamed in this session reports how long the model thought.
|
|
const reasoning = chat.assistantMessage().getByRole('button', { name: /^Thought for \d+s$/ })
|
|
await expect(reasoning).toHaveAttribute('data-state', 'closed')
|
|
await reasoning.click()
|
|
await expect(reasoning).toHaveAttribute('data-state', 'open')
|
|
await expect(
|
|
chat.assistantMessage().locator('[data-slot="chat-reasoning-content"]')
|
|
).toBeVisible()
|
|
await expect(chat.assistantMessage().getByRole('button', { name: 'Copy response' })).toBeVisible()
|
|
})
|
|
|
|
test('multipart assistant messages expose one copy action', async ({ configuredChat: chat }) => {
|
|
await chat.submit('Show multiple parts')
|
|
await expect(chat.assistantMessage()).toContainText('Second')
|
|
await expect(chat.assistantMessage().getByRole('button', { name: 'Copy response' })).toHaveCount(
|
|
1
|
|
)
|
|
})
|
|
|
|
test('tool calls render their result', async ({ configuredChat: chat }) => {
|
|
await chat.submit('Create a frame')
|
|
// 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
|
|
}) => {
|
|
await chat.submit('Trigger expired key error')
|
|
|
|
const toast = chat.page.locator('[data-slot="toast"]').filter({
|
|
hasText: 'Your provider API key is invalid or expired. Replace it in Settings.'
|
|
})
|
|
await expect(toast).toBeVisible()
|
|
await expect(chat.page.locator('[data-slot="toast"]')).toHaveCount(1)
|
|
await toast.getByRole('button', { name: 'Open settings' }).click()
|
|
await expect(chat.page.getByTestId('app-settings-dialog')).toBeVisible()
|
|
})
|
|
|
|
test('transport errors show a safe localized toast', async ({ configuredChat: chat }) => {
|
|
await chat.submit('Trigger missing agent error')
|
|
await expect(
|
|
chat.page.locator('[data-slot="toast"]').filter({
|
|
hasText: 'The model request failed. Check the provider settings and try again.'
|
|
})
|
|
).toBeVisible()
|
|
})
|