Compare commits

..

1 Commits

Author SHA1 Message Date
coderabbitai[bot]
d38450d8bd CodeRabbit Generated Unit Tests: Add Generated Unit Tests for PR Changes 2026-07-14 16:56:33 +00:00
101 changed files with 408 additions and 12429 deletions

View File

@@ -41,9 +41,6 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# Enable PostHog debug logging in the browser console.
# VITE_POSTHOG_DEBUG=true
# Send local dev PostHog events to the staging project (cloud mode only).
# VITE_POSTHOG_PROJECT_TOKEN=
# Override staging comfy-api / comfy-platform base URLs.
# VITE_STAGING_API_BASE_URL=https://stagingapi.comfy.org
# VITE_STAGING_PLATFORM_BASE_URL=https://stagingplatform.comfy.org

View File

@@ -1,147 +0,0 @@
import type { WebSocketRoute } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { webSocketFixture } from '@e2e/fixtures/ws'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { AgentWsEvent } from '@/workbench/extensions/agent/schemas/agentApiSchema'
import {
DRAFT_PATCH,
MESSAGE_DELTA_EVENT,
MESSAGE_DONE_EVENT,
THINKING_EVENT,
TOOL_CALL_EVENT,
mockAgentBoot
} from '@e2e/tests/agent/agentPanelMocks'
type AgentFixtures = {
agentFlagEnabled: boolean
postedMessages: string[]
}
const agentCloudFixture = comfyPageFixture.extend<AgentFixtures>({
agentFlagEnabled: [true, { option: true }],
postedMessages: async ({}, use) => {
await use([])
},
page: async ({ page, agentFlagEnabled, postedMessages }, use) => {
await mockAgentBoot(page, { agentFlag: agentFlagEnabled, postedMessages })
await use(page)
}
})
const test = mergeTests(agentCloudFixture, webSocketFixture)
const OPEN_AGENT_LABEL = enMessages.agent.askComfyAgent
function pushEvent(ws: WebSocketRoute, event: AgentWsEvent): void {
ws.send(JSON.stringify(event))
}
test.describe('In-App Agent panel', { tag: '@cloud' }, () => {
test.describe('flag off', () => {
test.use({ agentFlagEnabled: false })
test('does not expose the Ask Comfy Agent button', async ({
comfyPage,
postedMessages
}) => {
expect(postedMessages).toHaveLength(0)
await expect(
comfyPage.page.getByRole('button', { name: OPEN_AGENT_LABEL })
).toHaveCount(0)
})
})
test('shows the greeting, inserts a suggested prompt, and completes a chat turn', async ({
comfyPage,
postedMessages,
getWebSocket
}) => {
test.setTimeout(30_000)
const page = comfyPage.page
const openButton = page.getByRole('button', { name: OPEN_AGENT_LABEL })
await expect(openButton).toBeVisible()
await openButton.click()
const panel = page.locator('#agent-panel-root')
await expect(panel).toBeVisible()
await expect(panel.getByText(/^Hello/)).toBeVisible()
await expect(panel.getByText('What do you want to make?')).toBeVisible()
const firstPrompt = enMessages.agent.suggestedPrompts[0]
const promptChip = panel.getByRole('button', { name: firstPrompt })
await expect(promptChip).toBeVisible()
const composer = panel.getByPlaceholder(enMessages.agent.placeholder)
const sendButton = panel.getByRole('button', { name: 'Send' })
await expect(composer).toHaveValue('')
await promptChip.click()
await expect(composer).toHaveValue(firstPrompt)
expect(
postedMessages,
'inserting a prompt must not POST a message'
).toHaveLength(0)
const ws = await getWebSocket()
await sendButton.click()
await expect.poll(() => postedMessages.length).toBeGreaterThanOrEqual(1)
expect(postedMessages[0]).toContain(firstPrompt)
await expect(composer).toHaveValue('')
pushEvent(ws, THINKING_EVENT)
await expect(panel.getByText('Thinking...')).toBeVisible()
pushEvent(ws, TOOL_CALL_EVENT)
await expect(panel.getByText('Ran 1 tool call')).toBeVisible()
pushEvent(ws, MESSAGE_DELTA_EVENT)
await expect(
panel.locator('strong', { hasText: 'fully ready' })
).toBeVisible()
await expect(panel.getByText('Thinking...')).toBeHidden()
pushEvent(ws, MESSAGE_DONE_EVENT)
await expect(panel.getByRole('button', { name: 'Send' })).toBeVisible()
await expect(panel.getByRole('button', { name: 'Stop' })).toHaveCount(0)
})
test('applies a draft_patch graph to the canvas', async ({
comfyPage,
postedMessages,
getWebSocket
}) => {
test.setTimeout(30_000)
const page = comfyPage.page
const panel = page.locator('#agent-panel-root')
const openButton = page.getByRole('button', { name: OPEN_AGENT_LABEL })
await expect(openButton).toBeVisible()
await openButton.click()
await expect(panel).toBeVisible()
await panel.getByPlaceholder(enMessages.agent.placeholder).fill('Build it')
await panel.getByRole('button', { name: 'Send' }).click()
await expect.poll(() => postedMessages.length).toBeGreaterThanOrEqual(1)
const ws = await getWebSocket()
pushEvent(ws, { type: 'draft_patch', data: DRAFT_PATCH })
await expect
.poll(() => page.evaluate(() => window.app!.graph!.nodes.length))
.toBe(2)
const nodeTypes = await page.evaluate(() =>
window.app!.graph!.nodes.map((n) => n.type)
)
expect(nodeTypes).toEqual(
expect.arrayContaining(['CheckpointLoaderSimple', 'SaveImage'])
)
})
})

View File

@@ -1,229 +0,0 @@
import type { Page, Route } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
AgentDraftSnapshot,
AgentTurnAccepted,
AgentWsEvent,
DraftPatchData
} from '@/workbench/extensions/agent/schemas/agentApiSchema'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { mockBilling } from '@e2e/fixtures/utils/cloudBillingMocks'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
const THREAD_ID = 'd4c016c4-3b8c-44cf-97de-1ae27e43e718'
const TURN_ID = '3818ba00-d772-4a3f-98c1-9312725b577d'
const WORKFLOW_ID = 'a81718a4-02ae-41e6-ae85-c33b7bb880f6'
const TURN_ACCEPTED: AgentTurnAccepted = {
message_id: TURN_ID,
thread_id: THREAD_ID,
workflow_id: WORKFLOW_ID
}
const DRAFT_GRAPH: ComfyWorkflowJSON = {
version: 0.4,
last_node_id: 2,
last_link_id: 0,
nodes: [
{
id: 1,
type: 'CheckpointLoaderSimple',
pos: [100, 300],
size: [210, 100],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [
{ name: 'MODEL', type: 'MODEL', links: [] },
{ name: 'CLIP', type: 'CLIP', links: [] },
{ name: 'VAE', type: 'VAE', links: [] }
],
properties: {},
widgets_values: ['sd_xl_base_1.0.safetensors']
},
{
id: 2,
type: 'SaveImage',
pos: [1400, 300],
size: [210, 100],
flags: {},
order: 1,
mode: 0,
inputs: [{ name: 'images', type: 'IMAGE', link: null }],
outputs: [],
properties: {},
widgets_values: ['ComfyUI']
}
],
links: []
}
const DRAFT_SNAPSHOT: AgentDraftSnapshot = {
content: DRAFT_GRAPH as unknown as Record<string, unknown>,
version: 24
}
export const DRAFT_PATCH: DraftPatchData = {
base_version: 24,
version: 25,
content: DRAFT_GRAPH as unknown as Record<string, unknown>,
workflow_id: WORKFLOW_ID,
message_id: TURN_ID,
thread_id: THREAD_ID
}
export const THINKING_EVENT: AgentWsEvent = {
type: 'agent_thinking',
data: {
delta: "I'll set the positive prompt to your red fox scene.",
message_id: TURN_ID,
thread_id: THREAD_ID
}
}
export const TOOL_CALL_EVENT: AgentWsEvent = {
type: 'agent_tool_call',
data: {
tool_name: 'set_widget',
status: 'ok',
args: ['workflow', 'set-widget', 'workflow.json'],
message_id: TURN_ID,
thread_id: THREAD_ID
}
}
const MESSAGE_DELTA_TEXT =
'The graph is **fully ready** to go — prompt set to the red fox in the snow.'
export const MESSAGE_DELTA_EVENT: AgentWsEvent = {
type: 'agent_message_delta',
data: {
delta: MESSAGE_DELTA_TEXT,
message_id: TURN_ID,
thread_id: THREAD_ID
}
}
export const MESSAGE_DONE_EVENT: AgentWsEvent = {
type: 'agent_message_done',
data: {
message_id: TURN_ID,
thread_id: THREAD_ID,
usage: {
input_tokens: 4493,
output_tokens: 425,
total_tokens: 12393,
cache_read_input_tokens: 35596,
cache_creation_input_tokens: 0
}
}
}
function agentFeatures(agentFlag: boolean): RemoteConfig {
return {
team_workspaces_enabled: true,
posthog_project_token: 'phc_e2e_agent_panel',
posthog_config: {
advanced_disable_flags: true,
bootstrap: {
featureFlags: { 'agent-in-app-experience': agentFlag }
}
}
}
}
export async function mockAgentBoot(
page: Page,
{
agentFlag,
postedMessages
}: { agentFlag: boolean; postedMessages: string[] }
): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('Comfy.AgentPanel.onboarded', 'true')
})
await mockBilling(page)
await page.route('**/api/assets**', (r) =>
r.fulfill(jsonRoute({ assets: [] }))
)
await page.route('**/api/features', (r) =>
r.fulfill(jsonRoute(agentFeatures(agentFlag)))
)
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
await page.route('**/api/settings', (r) =>
r.fulfill(
jsonRoute({
'Comfy.TutorialCompleted': true,
'Comfy.RightSidePanel.ShowErrorsTab': false
})
)
)
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/api/auth/token', (r) =>
r.fulfill(
jsonRoute({
token: 'mock-workspace-token',
expires_at: '2100-01-01T00:00:00.000Z',
workspace: { id: 'ws-personal', name: 'Personal', type: 'personal' },
role: 'owner',
permissions: ['owner:*']
})
)
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/workspaces', (r) =>
r.fulfill(
jsonRoute({
workspaces: [
{
id: 'ws-personal',
name: 'Personal',
type: 'personal',
role: 'owner'
}
]
})
)
)
await page.route('**/api/agent/threads/*/messages', (route: Route) => {
const request = route.request()
if (request.method() === 'POST') {
postedMessages.push(request.postData() ?? '')
return route.fulfill({
status: 202,
contentType: 'application/json',
body: JSON.stringify(TURN_ACCEPTED)
})
}
return route.fulfill(jsonRoute([]))
})
await page.route('**/api/agent/draft**', (r) =>
r.fulfill(jsonRoute(DRAFT_SNAPSHOT))
)
}

View File

@@ -522,5 +522,32 @@ describe('formatUtil', () => {
)
expect(escapeVueI18nMessageSyntax('')).toBe('')
})
it('escapes a mix of different syntax characters in one string', () => {
expect(escapeVueI18nMessageSyntax('@{a|b}%c')).toBe(
"{'@'}{'{'}a{'|'}b{'}'}{'%'}c"
)
})
it('escapes back-to-back occurrences of the same character', () => {
expect(escapeVueI18nMessageSyntax('@@')).toBe("{'@'}{'@'}")
})
it('does not escape characters that are not vue-i18n syntax', () => {
const text = 'price: $10 #tag & "quoted" \'single\''
expect(escapeVueI18nMessageSyntax(text)).toBe(text)
})
it('is not idempotent: re-escaping already-escaped output changes it further', () => {
const once = escapeVueI18nMessageSyntax('@')
const twice = escapeVueI18nMessageSyntax(once)
expect(once).toBe("{'@'}")
expect(twice).not.toBe(once)
})
it('does not leak regex match state across successive calls', () => {
expect(escapeVueI18nMessageSyntax('@first')).toBe("{'@'}first")
expect(escapeVueI18nMessageSyntax('@second')).toBe("{'@'}second")
})
})
})

View File

@@ -178,40 +178,6 @@ export function normalizeI18nKey(key: string) {
return typeof key === 'string' ? key.replace(/\./g, '_') : ''
}
/**
* Characters that vue-i18n's message compiler treats as syntax in message text,
* so plain text has to escape them to render verbatim through `t()`/`st()`:
*
* - `@` starts a linked-message reference (`@:key`); malformed usage throws
* `Invalid linked format`.
* - `{` / `}` delimit interpolation (`{name}`, `{'literal'}`); an unbalanced
* brace throws `Unterminated/Unbalanced closing brace`.
* - `|` separates plural branches, so `a | b` silently renders as one branch.
* - `%` forms modulo interpolation when immediately followed by `{` (`%{name}`);
* it must be escaped too, otherwise escaping a following `{` re-forms `%{`.
*
* The set is a build-inlined `const enum` (`TokenChars`) in
* `@intlify/message-compiler` and is not exported, so it is hardcoded here.
*
* @see https://vue-i18n.intlify.dev/guide/essentials/syntax (Special Characters, Literal interpolation)
* @see https://vue-i18n.intlify.dev/guide/essentials/pluralization
*/
const VUE_I18N_SYNTAX_CHARS = /[@{}|%]/g
/**
* Escapes vue-i18n message-syntax characters as literal interpolations (`{'x'}`)
* so arbitrary text renders verbatim instead of being parsed as syntax. This is
* the only escape vue-i18n supports; see {@link VUE_I18N_SYNTAX_CHARS}.
*
* Only apply to values read through the compiler (`t()`/`st()`). Values read raw
* via `tm()`/`stRaw()` (e.g. node tooltips) must be left untouched, or the
* literal `{'x'}` would surface to users. Apply exactly once to raw text: the
* escape output itself contains `{`/`}`, so it is not idempotent.
*/
export function escapeVueI18nMessageSyntax(text: string): string {
return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
}
/**
* Takes a dynamic prompt in the format {opt1|opt2|{optA|optB}|} and randomly replaces groups. Supports C style comments.
* @param input The dynamic prompt to process

276
pnpm-lock.yaml generated
View File

@@ -167,7 +167,7 @@ catalogs:
version: 2.0.1
'@vitejs/plugin-vue':
specifier: ^6.0.0
version: 6.0.7
version: 6.0.3
'@vitest/coverage-v8':
specifier: ^4.0.16
version: 4.0.16
@@ -380,7 +380,7 @@ catalogs:
version: 3.2.2
vite-plugin-vue-devtools:
specifier: ^8.0.0
version: 8.1.2
version: 8.0.5
vitest:
specifier: ^4.1.0
version: 4.1.8
@@ -389,7 +389,7 @@ catalogs:
version: 3.5.34
vue-component-type-helpers:
specifier: ^3.2.1
version: 3.3.7
version: 3.3.2
vue-eslint-parser:
specifier: ^10.4.0
version: 10.4.0
@@ -717,7 +717,7 @@ importers:
version: 0.184.1
'@vitejs/plugin-vue':
specifier: 'catalog:'
version: 6.0.7(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
version: 6.0.3(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
'@vitest/coverage-v8':
specifier: 'catalog:'
version: 4.0.16(vitest@4.1.8)
@@ -861,13 +861,13 @@ importers:
version: 3.2.2(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-plugin-vue-devtools:
specifier: 'catalog:'
version: 8.1.2(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
version: 8.0.5(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
vitest:
specifier: 'catalog:'
version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/coverage-v8@4.0.16)(@vitest/ui@4.0.16)(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vue-component-type-helpers:
specifier: 'catalog:'
version: 3.3.7
version: 3.3.2
vue-eslint-parser:
specifier: 'catalog:'
version: 10.4.0(eslint@10.4.0(jiti@2.6.1))
@@ -925,7 +925,7 @@ importers:
version: 4.3.0(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
'@vitejs/plugin-vue':
specifier: 'catalog:'
version: 6.0.7(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
version: 6.0.3(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
dotenv:
specifier: 'catalog:'
version: 16.6.1
@@ -943,7 +943,7 @@ importers:
version: 3.2.2(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-plugin-vue-devtools:
specifier: 'catalog:'
version: 8.1.2(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
version: 8.0.5(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
vue-tsc:
specifier: 'catalog:'
version: 3.2.5(typescript@5.9.3)
@@ -1037,7 +1037,7 @@ importers:
version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/coverage-v8@4.0.16(vitest@4.1.8))(@vitest/ui@4.0.16(vitest@4.1.8))(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vue-component-type-helpers:
specifier: 'catalog:'
version: 3.3.7
version: 3.3.2
packages/comfyui-desktop-bridge-types: {}
@@ -3305,6 +3305,9 @@ packages:
cpu: [x64]
os: [win32]
'@rolldown/pluginutils@1.0.0-beta.53':
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
@@ -3464,32 +3467,32 @@ packages:
pinia:
optional: true
'@shikijs/core@4.3.1':
resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==}
'@shikijs/core@4.1.0':
resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==}
engines: {node: '>=20'}
'@shikijs/engine-javascript@4.3.1':
resolution: {integrity: sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==}
'@shikijs/engine-javascript@4.1.0':
resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==}
engines: {node: '>=20'}
'@shikijs/engine-oniguruma@4.3.1':
resolution: {integrity: sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==}
'@shikijs/engine-oniguruma@4.1.0':
resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==}
engines: {node: '>=20'}
'@shikijs/langs@4.3.1':
resolution: {integrity: sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==}
'@shikijs/langs@4.1.0':
resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==}
engines: {node: '>=20'}
'@shikijs/primitive@4.3.1':
resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==}
'@shikijs/primitive@4.1.0':
resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==}
engines: {node: '>=20'}
'@shikijs/themes@4.3.1':
resolution: {integrity: sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==}
'@shikijs/themes@4.1.0':
resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==}
engines: {node: '>=20'}
'@shikijs/types@4.3.1':
resolution: {integrity: sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==}
'@shikijs/types@4.1.0':
resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==}
engines: {node: '>=20'}
'@shikijs/vscode-textmate@10.0.2':
@@ -4203,6 +4206,13 @@ packages:
vite: ^8.0.13
vue: ^3.0.0
'@vitejs/plugin-vue@6.0.3':
resolution: {integrity: sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
vite: ^8.0.13
vue: ^3.2.25
'@vitejs/plugin-vue@6.0.7':
resolution: {integrity: sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -4359,6 +4369,11 @@ packages:
'@vue/devtools-api@7.7.9':
resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==}
'@vue/devtools-core@8.0.5':
resolution: {integrity: sha512-dpCw8nl0GDBuiL9SaY0mtDxoGIEmU38w+TQiYEPOLhW03VDC0lfNMYXS/qhl4I0YlysGp04NLY4UNn6xgD0VIQ==}
peerDependencies:
vue: ^3.0.0
'@vue/devtools-core@8.1.2':
resolution: {integrity: sha512-ZGGyaSBP4/+bN2Nd9ZHNYAVDRIzMw1rv2RyXWtyZlo6mQal+IDmTvKY4V+DjAEBhaXt30mHmsgYp1yXJ/2tIWg==}
peerDependencies:
@@ -4367,12 +4382,18 @@ packages:
'@vue/devtools-kit@7.7.9':
resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==}
'@vue/devtools-kit@8.0.5':
resolution: {integrity: sha512-q2VV6x1U3KJMTQPUlRMyWEKVbcHuxhqJdSr6Jtjz5uAThAIrfJ6WVZdGZm5cuO63ZnSUz0RCsVwiUUb0mDV0Yg==}
'@vue/devtools-kit@8.1.2':
resolution: {integrity: sha512-f75/upc+GCyjXErpgPGz4582ujS0L/adAltGy+tqXMGUJpgAcfGr6CxnnhpZY8BHuMYt6KpbF8uaFrrQG66rGQ==}
'@vue/devtools-shared@7.7.9':
resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==}
'@vue/devtools-shared@8.0.5':
resolution: {integrity: sha512-bRLn6/spxpmgLk+iwOrR29KrYnJjG9DGpHGkDFG82UM21ZpJ39ztUT9OXX3g+usW7/b2z+h46I9ZiYyB07XMXg==}
'@vue/devtools-shared@8.1.2':
resolution: {integrity: sha512-X9RyVFYAdkBe4IUf5v48TxBF/6QPmF8CmWrDAjXzfUHrgQ/HGfTC1A6TqgXqZ03ye66l3AD51BAGD69IvKM9sw==}
@@ -6688,6 +6709,10 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@11.2.6:
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
engines: {node: 20 || >=22}
lru-cache@11.5.1:
resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==}
engines: {node: 20 || >=22}
@@ -6721,6 +6746,9 @@ packages:
resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==}
engines: {node: '>=12'}
magicast@0.5.1:
resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==}
magicast@0.5.3:
resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==}
@@ -7044,6 +7072,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.5:
resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==}
engines: {node: ^18 || >=20}
hasBin: true
napi-postinstall@0.3.3:
resolution: {integrity: sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -7894,8 +7927,8 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shiki@4.3.1:
resolution: {integrity: sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==}
shiki@4.1.0:
resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==}
engines: {node: '>=20'}
side-channel-list@1.0.0:
@@ -8220,6 +8253,10 @@ packages:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'}
tinyrainbow@3.0.3:
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
engines: {node: '>=14.0.0'}
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
@@ -8632,12 +8669,23 @@ packages:
'@nuxt/kit':
optional: true
vite-plugin-vue-devtools@8.0.5:
resolution: {integrity: sha512-p619BlKFOqQXJ6uDWS1vUPQzuJOD6xJTfftj57JXBGoBD/yeQCowR7pnWcr/FEX4/HVkFbreI6w2uuGBmQOh6A==}
engines: {node: '>=v14.21.3'}
peerDependencies:
vite: ^8.0.13
vite-plugin-vue-devtools@8.1.2:
resolution: {integrity: sha512-gt5h1CNryR9Hy0tvhSbqY3j0F7aj0pGxBxWLa1lXSiZVkhdWDf0vbCOZyjh8ivFGE6FDHTGy3zkcZGlMZdVHig==}
engines: {node: '>=v14.21.3'}
peerDependencies:
vite: ^8.0.13
vite-plugin-vue-inspector@5.3.2:
resolution: {integrity: sha512-YvEKooQcSiBTAs0DoYLfefNja9bLgkFM7NI2b07bE2SruuvX0MEa9cMaxjKVMkeCp5Nz9FRIdcN1rOdFVBeL6Q==}
peerDependencies:
vite: ^8.0.13
vite-plugin-vue-inspector@6.0.0:
resolution: {integrity: sha512-OpyITJLgZNibxlrik1EmRtvXHDjLRxNPsWkGFTERZs2LgMEdG4W0WoFt5GIgp3a3jRou+eJR8U1zOBk/XQgEbw==}
peerDependencies:
@@ -8842,8 +8890,11 @@ packages:
vue-component-type-helpers@2.2.12:
resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==}
vue-component-type-helpers@3.3.7:
resolution: {integrity: sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg==}
vue-component-type-helpers@3.3.2:
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
vue-component-type-helpers@3.3.6:
resolution: {integrity: sha512-FkljacAwJ9BUoSUdpFe3VDy0sGigNlTH9+2zcXUWmZOjN8swiCkl3t48wOJun0OsUd2cEIda1l04tsxMiKIIrQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -9289,7 +9340,7 @@ snapshots:
'@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
lru-cache: 11.5.1
lru-cache: 11.2.6
'@asamuzakjp/dom-selector@6.7.6':
dependencies:
@@ -9297,7 +9348,7 @@ snapshots:
bidi-js: 1.0.3
css-tree: 3.1.0
is-potential-custom-element-name: 1.0.1
lru-cache: 11.5.1
lru-cache: 11.2.6
'@asamuzakjp/nwsapi@2.3.9': {}
@@ -9323,7 +9374,7 @@ snapshots:
js-yaml: 4.1.1
picomatch: 4.0.4
retext-smartypants: 6.2.0
shiki: 4.3.1
shiki: 4.1.0
smol-toml: 1.6.1
unified: 11.0.5
@@ -11311,6 +11362,8 @@ snapshots:
'@rolldown/binding-win32-x64-msvc@1.0.1':
optional: true
'@rolldown/pluginutils@1.0.0-beta.53': {}
'@rolldown/pluginutils@1.0.1': {}
'@rollup/pluginutils@4.2.1':
@@ -11484,40 +11537,40 @@ snapshots:
optionalDependencies:
pinia: 3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))
'@shikijs/core@4.3.1':
'@shikijs/core@4.1.0':
dependencies:
'@shikijs/primitive': 4.3.1
'@shikijs/types': 4.3.1
'@shikijs/primitive': 4.1.0
'@shikijs/types': 4.1.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@4.3.1':
'@shikijs/engine-javascript@4.1.0':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/types': 4.1.0
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.6
'@shikijs/engine-oniguruma@4.3.1':
'@shikijs/engine-oniguruma@4.1.0':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/types': 4.1.0
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@4.3.1':
'@shikijs/langs@4.1.0':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/types': 4.1.0
'@shikijs/primitive@4.3.1':
'@shikijs/primitive@4.1.0':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/types': 4.1.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/themes@4.3.1':
'@shikijs/themes@4.1.0':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/types': 4.1.0
'@shikijs/types@4.3.1':
'@shikijs/types@4.1.0':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
@@ -11624,7 +11677,7 @@ snapshots:
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
type-fest: 2.19.0
vue: 3.5.34(typescript@5.9.3)
vue-component-type-helpers: 3.3.7
vue-component-type-helpers: 3.3.6
'@swc/helpers@0.5.21':
dependencies:
@@ -12250,12 +12303,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-vue@6.0.7(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
'@vitejs/plugin-vue@6.0.3(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.1
'@rolldown/pluginutils': 1.0.0-beta.53
vite: 8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vue: 3.5.34(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.3(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.53
vite: 8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vue: 3.5.34(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.7(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.1
@@ -12271,10 +12330,10 @@ snapshots:
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 5.0.6
istanbul-reports: 3.2.0
magicast: 0.5.3
magicast: 0.5.1
obug: 2.1.1
std-env: 3.10.0
tinyrainbow: 3.1.0
tinyrainbow: 3.0.3
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/coverage-v8@4.0.16)(@vitest/ui@4.0.16)(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
transitivePeerDependencies:
- supports-color
@@ -12318,7 +12377,7 @@ snapshots:
'@vitest/pretty-format@4.0.16':
dependencies:
tinyrainbow: 3.1.0
tinyrainbow: 3.0.3
'@vitest/pretty-format@4.1.8':
dependencies:
@@ -12350,7 +12409,7 @@ snapshots:
pathe: 2.0.3
sirv: 3.0.2
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
tinyrainbow: 3.0.3
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/coverage-v8@4.0.16)(@vitest/ui@4.0.16)(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
'@vitest/utils@3.2.4':
@@ -12362,7 +12421,7 @@ snapshots:
'@vitest/utils@4.0.16':
dependencies:
'@vitest/pretty-format': 4.0.16
tinyrainbow: 3.1.0
tinyrainbow: 3.0.3
'@vitest/utils@4.1.8':
dependencies:
@@ -12531,6 +12590,30 @@ snapshots:
dependencies:
'@vue/devtools-kit': 7.7.9
'@vue/devtools-core@8.0.5(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
dependencies:
'@vue/devtools-kit': 8.0.5
'@vue/devtools-shared': 8.0.5
mitt: 3.0.1
nanoid: 5.1.5
pathe: 2.0.3
vite-hot-client: 2.1.0(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vue: 3.5.34(typescript@5.9.3)
transitivePeerDependencies:
- vite
'@vue/devtools-core@8.0.5(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
dependencies:
'@vue/devtools-kit': 8.0.5
'@vue/devtools-shared': 8.0.5
mitt: 3.0.1
nanoid: 5.1.5
pathe: 2.0.3
vite-hot-client: 2.1.0(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vue: 3.5.34(typescript@5.9.3)
transitivePeerDependencies:
- vite
'@vue/devtools-core@8.1.2(vue@3.5.34(typescript@5.9.3))':
dependencies:
'@vue/devtools-kit': 8.1.2
@@ -12547,6 +12630,16 @@ snapshots:
speakingurl: 14.0.1
superjson: 2.2.2
'@vue/devtools-kit@8.0.5':
dependencies:
'@vue/devtools-shared': 8.0.5
birpc: 2.9.0
hookable: 5.5.3
mitt: 3.0.1
perfect-debounce: 2.0.0
speakingurl: 14.0.1
superjson: 2.2.2
'@vue/devtools-kit@8.1.2':
dependencies:
'@vue/devtools-shared': 8.1.2
@@ -12558,6 +12651,10 @@ snapshots:
dependencies:
rfdc: 1.4.1
'@vue/devtools-shared@8.0.5':
dependencies:
rfdc: 1.4.1
'@vue/devtools-shared@8.1.2': {}
'@vue/language-core@2.2.0(typescript@5.9.3)':
@@ -12891,7 +12988,7 @@ snapshots:
picomatch: 4.0.4
rehype: 13.0.2
semver: 7.7.4
shiki: 4.3.1
shiki: 4.1.0
smol-toml: 1.6.1
svgo: 4.0.1
tinyclip: 0.1.13
@@ -15132,6 +15229,8 @@ snapshots:
lru-cache@10.4.3: {}
lru-cache@11.2.6: {}
lru-cache@11.5.1: {}
lru-cache@5.1.1:
@@ -15160,6 +15259,12 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magicast@0.5.1:
dependencies:
'@babel/parser': 7.29.3
'@babel/types': 7.29.0
source-map-js: 1.2.1
magicast@0.5.3:
dependencies:
'@babel/parser': 7.29.3
@@ -15768,6 +15873,8 @@ snapshots:
nanoid@3.3.12: {}
nanoid@5.1.5: {}
napi-postinstall@0.3.3: {}
natural-compare@1.4.0: {}
@@ -16120,7 +16227,7 @@ snapshots:
path-scurry@2.0.2:
dependencies:
lru-cache: 11.5.1
lru-cache: 11.2.6
minipass: 7.1.3
path-type@4.0.0: {}
@@ -16900,14 +17007,14 @@ snapshots:
shebang-regex@3.0.0: {}
shiki@4.3.1:
shiki@4.1.0:
dependencies:
'@shikijs/core': 4.3.1
'@shikijs/engine-javascript': 4.3.1
'@shikijs/engine-oniguruma': 4.3.1
'@shikijs/langs': 4.3.1
'@shikijs/themes': 4.3.1
'@shikijs/types': 4.3.1
'@shikijs/core': 4.1.0
'@shikijs/engine-javascript': 4.1.0
'@shikijs/engine-oniguruma': 4.1.0
'@shikijs/langs': 4.1.0
'@shikijs/themes': 4.1.0
'@shikijs/types': 4.1.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
@@ -17273,6 +17380,8 @@ snapshots:
tinyrainbow@2.0.0: {}
tinyrainbow@3.0.3: {}
tinyrainbow@3.1.0: {}
tinyspy@4.0.4: {}
@@ -17741,15 +17850,29 @@ snapshots:
transitivePeerDependencies:
- supports-color
vite-plugin-vue-devtools@8.1.2(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)):
vite-plugin-vue-devtools@8.0.5(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)):
dependencies:
'@vue/devtools-core': 8.1.2(vue@3.5.34(typescript@5.9.3))
'@vue/devtools-kit': 8.1.2
'@vue/devtools-shared': 8.1.2
'@vue/devtools-core': 8.0.5(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
'@vue/devtools-kit': 8.0.5
'@vue/devtools-shared': 8.0.5
sirv: 3.0.2
vite: 8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-plugin-inspect: 11.3.3(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-plugin-vue-inspector: 6.0.0(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-plugin-vue-inspector: 5.3.2(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
transitivePeerDependencies:
- '@nuxt/kit'
- supports-color
- vue
vite-plugin-vue-devtools@8.0.5(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)):
dependencies:
'@vue/devtools-core': 8.0.5(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
'@vue/devtools-kit': 8.0.5
'@vue/devtools-shared': 8.0.5
sirv: 3.0.2
vite: 8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-plugin-inspect: 11.3.3(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-plugin-vue-inspector: 5.3.2(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
transitivePeerDependencies:
- '@nuxt/kit'
- supports-color
@@ -17769,7 +17892,7 @@ snapshots:
- supports-color
- vue
vite-plugin-vue-inspector@6.0.0(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
vite-plugin-vue-inspector@5.3.2(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
@@ -17784,6 +17907,21 @@ snapshots:
transitivePeerDependencies:
- supports-color
vite-plugin-vue-inspector@5.3.2(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
'@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0)
'@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0)
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0)
'@vue/babel-plugin-jsx': 1.4.0(@babel/core@7.29.0)
'@vue/compiler-dom': 3.5.34
kolorist: 1.8.0
magic-string: 0.30.21
vite: 8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
transitivePeerDependencies:
- supports-color
vite-plugin-vue-inspector@6.0.0(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
dependencies:
'@babel/core': 7.29.0
@@ -18009,7 +18147,9 @@ snapshots:
vue-component-type-helpers@2.2.12: {}
vue-component-type-helpers@3.3.7: {}
vue-component-type-helpers@3.3.2: {}
vue-component-type-helpers@3.3.6: {}
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
dependencies:

View File

@@ -3,10 +3,7 @@ import * as fs from 'fs'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
import {
escapeVueI18nMessageSyntax,
normalizeI18nKey
} from '@/utils/formatUtil'
import { normalizeI18nKey } from '../packages/shared-frontend-utils/src/formatUtil'
import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
const localePath = './src/locales/en/main.json'
@@ -47,6 +44,8 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
}
)
console.log(`Collected ${nodeDefs.length} node definitions`)
const allDataTypesLocale = Object.fromEntries(
nodeDefs
.flatMap((nodeDef) => {
@@ -61,7 +60,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
)
return allDataTypes.map((dataType) => [
normalizeI18nKey(dataType),
escapeVueI18nMessageSyntax(dataType)
dataType
])
})
.sort((a, b) => a[0].localeCompare(b[0]))
@@ -99,10 +98,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
const runtimeWidgets = Object.fromEntries(
Object.entries(widgetsMappings)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([key, value]) => [
normalizeI18nKey(key),
{ name: value ? escapeVueI18nMessageSyntax(value) : value }
])
.map(([key, value]) => [normalizeI18nKey(key), { name: value }])
)
if (Object.keys(runtimeWidgets).length > 0) {
@@ -125,10 +121,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
function extractInputs(nodeDef: ComfyNodeDefImpl) {
const inputs = Object.fromEntries(
Object.values(nodeDef.inputs).flatMap((input) => {
const name =
input.name === undefined
? undefined
: escapeVueI18nMessageSyntax(input.name)
const name = input.name
const tooltip = input.tooltip
if (name === undefined && tooltip === undefined) {
@@ -153,10 +146,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
const outputs = Object.fromEntries(
nodeDef.outputs.flatMap((output, i) => {
// Ignore data types if they are already translated in allDataTypesLocale.
const name =
output.name === undefined || output.name in allDataTypesLocale
? undefined
: escapeVueI18nMessageSyntax(output.name)
const name = output.name in allDataTypesLocale ? undefined : output.name
const tooltip = output.tooltip
if (name === undefined && tooltip === undefined) {
@@ -189,12 +179,8 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
return [
normalizeI18nKey(nodeDef.name),
{
display_name: escapeVueI18nMessageSyntax(
nodeDef.display_name ?? nodeDef.name
),
description: nodeDef.description
? escapeVueI18nMessageSyntax(nodeDef.description)
: undefined,
display_name: nodeDef.display_name ?? nodeDef.name,
description: nodeDef.description || undefined,
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
outputs: extractOutputs(nodeDef)
}
@@ -206,10 +192,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
nodeDefs.flatMap((nodeDef) =>
nodeDef.category
.split('/')
.map((category) => [
normalizeI18nKey(category),
escapeVueI18nMessageSyntax(category)
])
.map((category) => [normalizeI18nKey(category), category])
)
)

View File

@@ -1,5 +1,4 @@
@import '@comfyorg/design-system/css/style.css';
@import '../../workbench/extensions/agent/agentTheme.css';
/* Use 0.001ms instead of 0s so transitionend/animationend events still fire
and JS listeners aren't broken. */

View File

@@ -1,154 +1,133 @@
<template>
<div
class="pointer-events-none absolute top-0 left-0 z-999 flex size-full flex-row"
class="pointer-events-none absolute top-0 left-0 z-999 flex size-full flex-col"
>
<slot name="workflow-tabs" />
<div
class="pointer-events-none flex min-w-0 flex-1 flex-col overflow-hidden"
class="pointer-events-none flex flex-1 overflow-hidden"
:class="{
'flex-row': sidebarLocation === 'left',
'flex-row-reverse': sidebarLocation === 'right'
}"
>
<slot name="workflow-tabs" />
<div
class="pointer-events-none flex flex-1 overflow-hidden"
:class="{
'flex-row': sidebarLocation === 'left',
'flex-row-reverse': sidebarLocation === 'right'
}"
>
<div class="side-toolbar-container">
<slot name="side-toolbar" />
</div>
<Splitter
:key="splitterRefreshKey"
class="pointer-events-none flex-1 overflow-hidden border-none bg-transparent"
:state-key="
isSelectMode
? sidebarLocation === 'left'
? 'builder-splitter'
: 'builder-splitter-right'
: sidebarStateKey
"
state-storage="local"
@resizestart="onResizestart"
@resizeend="normalizeSavedSizes"
>
<!-- First panel: sidebar when left, properties when right -->
<SplitterPanel
v-if="firstPanelVisible"
:class="
sidebarLocation === 'left'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
"
:min-size="
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:style="firstPanelStyle"
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
:aria-label="
sidebarLocation === 'left' ? t('sideToolbar.sidebar') : undefined
"
>
<slot
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
name="side-bar-panel"
/>
<slot
v-else-if="sidebarLocation === 'right'"
name="right-side-panel"
/>
</SplitterPanel>
<!-- Main panel (always present) -->
<SplitterPanel :size="centerPanelDefaultSize" class="flex flex-col">
<slot name="topmenu" :sidebar-panel-visible />
<Splitter
class="splitter-overlay-bottom pointer-events-none mx-1 mb-1 flex-1 border-none bg-transparent"
layout="vertical"
:pt:gutter="
cn(
'rounded-t-lg',
!(bottomPanelVisible && !focusMode) && 'hidden'
)
"
state-key="bottom-panel-splitter"
state-storage="local"
@resizestart="onResizestart"
>
<SplitterPanel
class="graph-canvas-panel relative overflow-visible"
>
<slot name="graph-canvas-panel" />
</SplitterPanel>
<SplitterPanel
v-show="bottomPanelVisible && !focusMode"
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg focus-visible:outline-hidden"
>
<slot name="bottom-panel" />
</SplitterPanel>
</Splitter>
</SplitterPanel>
<!-- Last panel: properties when left, sidebar when right -->
<SplitterPanel
v-if="lastPanelVisible"
:class="
sidebarLocation === 'right'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
"
:min-size="
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:style="lastPanelStyle"
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
:aria-label="
sidebarLocation === 'right' ? t('sideToolbar.sidebar') : undefined
"
>
<slot v-if="sidebarLocation === 'left'" name="right-side-panel" />
<slot
v-else-if="sidebarLocation === 'right' && sidebarPanelVisible"
name="side-bar-panel"
/>
</SplitterPanel>
</Splitter>
<div class="side-toolbar-container">
<slot name="side-toolbar" />
</div>
</div>
<div
v-if="agentPanelDocked"
class="pointer-events-auto relative h-full shrink-0 overflow-hidden"
:style="{ width: `${agentPanelWidth}px` }"
>
<div
class="agent-resize-handle absolute top-0 left-0 z-10 h-full w-[5px] cursor-col-resize"
:data-resizing="isAgentResizing"
@pointerdown="onAgentResizeStart"
@lostpointercapture="isAgentResizing = false"
/>
<slot name="agent-panel" />
<Splitter
:key="splitterRefreshKey"
class="pointer-events-none flex-1 overflow-hidden border-none bg-transparent"
:state-key="
isSelectMode
? sidebarLocation === 'left'
? 'builder-splitter'
: 'builder-splitter-right'
: sidebarStateKey
"
state-storage="local"
@resizestart="onResizestart"
@resizeend="normalizeSavedSizes"
>
<!-- First panel: sidebar when left, properties when right -->
<SplitterPanel
v-if="firstPanelVisible"
:class="
sidebarLocation === 'left'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
"
:min-size="
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:style="firstPanelStyle"
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
:aria-label="
sidebarLocation === 'left' ? t('sideToolbar.sidebar') : undefined
"
>
<slot
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
name="side-bar-panel"
/>
<slot
v-else-if="sidebarLocation === 'right'"
name="right-side-panel"
/>
</SplitterPanel>
<!-- Main panel (always present) -->
<SplitterPanel :size="centerPanelDefaultSize" class="flex flex-col">
<slot name="topmenu" :sidebar-panel-visible />
<Splitter
class="splitter-overlay-bottom pointer-events-none mx-1 mb-1 flex-1 border-none bg-transparent"
layout="vertical"
:pt:gutter="
cn(
'rounded-t-lg',
!(bottomPanelVisible && !focusMode) && 'hidden'
)
"
state-key="bottom-panel-splitter"
state-storage="local"
@resizestart="onResizestart"
>
<SplitterPanel class="graph-canvas-panel relative overflow-visible">
<slot name="graph-canvas-panel" />
</SplitterPanel>
<SplitterPanel
v-show="bottomPanelVisible && !focusMode"
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg focus-visible:outline-hidden"
>
<slot name="bottom-panel" />
</SplitterPanel>
</Splitter>
</SplitterPanel>
<!-- Last panel: properties when left, sidebar when right -->
<SplitterPanel
v-if="lastPanelVisible"
:class="
sidebarLocation === 'right'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
"
:min-size="
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:style="lastPanelStyle"
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
:aria-label="
sidebarLocation === 'right' ? t('sideToolbar.sidebar') : undefined
"
>
<slot v-if="sidebarLocation === 'left'" name="right-side-panel" />
<slot
v-else-if="sidebarLocation === 'right' && sidebarPanelVisible"
name="side-bar-panel"
/>
</SplitterPanel>
</Splitter>
</div>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useEventListener } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import Splitter from 'primevue/splitter'
import type { SplitterResizeStartEvent } from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppMode } from '@/composables/useAppMode'
@@ -163,13 +142,11 @@ import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useAgentPanelStore } from '@/workbench/extensions/agent/stores/agent/agentPanelStore'
const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const sidebarTabStore = useSidebarTabStore()
const agentPanelStore = useAgentPanelStore()
const { t } = useI18n()
const sidebarLocation = computed<'left' | 'right'>(() =>
settingStore.get('Comfy.Sidebar.Location')
@@ -185,33 +162,6 @@ const { isSelectMode, isBuilderMode } = useAppMode()
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
const {
isOpen: agentPanelOpen,
enabled: agentPanelEnabled,
width: agentPanelWidth
} = storeToRefs(agentPanelStore)
const agentPanelDocked = computed(
() => agentPanelEnabled.value && agentPanelOpen.value
)
const isAgentResizing = ref(false)
let agentResizeStartX = 0
let agentResizeStartWidth = 0
function onAgentResizeStart(e: PointerEvent): void {
isAgentResizing.value = true
agentResizeStartX = e.clientX
agentResizeStartWidth = agentPanelStore.width
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
e.preventDefault()
}
useEventListener(document, 'pointermove', (e: PointerEvent) => {
if (!isAgentResizing.value) return
agentPanelStore.setWidth(
agentResizeStartWidth + (agentResizeStartX - e.clientX)
)
})
const showOffsideSplitter = computed(
() => rightSidePanelVisible.value || isSelectMode.value
)
@@ -354,10 +304,4 @@ const lastPanelStyle = computed(() => {
.splitter-overlay-bottom :deep(.p-splitter-gutter) {
transform: translateY(5px);
}
.agent-resize-handle:hover,
.agent-resize-handle[data-resizing='true'] {
transition: background-color 0.2s ease 300ms;
background-color: var(--p-primary-color);
}
</style>

View File

@@ -38,15 +38,6 @@
<AppBuilder v-if="isBuilderMode" />
<NodePropertiesPanel v-else />
</template>
<template #agent-panel>
<div class="size-full p-2">
<div
class="size-full overflow-hidden rounded-lg border border-(--interface-stroke)"
>
<AgentPanelRoot />
</div>
</div>
</template>
<template #graph-canvas-panel>
<GraphCanvasMenu
v-if="canvasMenuEnabled && !isBuilderMode"
@@ -120,7 +111,6 @@
import { until, useEventListener } from '@vueuse/core'
import {
computed,
defineAsyncComponent,
nextTick,
onMounted,
onUnmounted,
@@ -207,10 +197,6 @@ import { forEachNode } from '@/utils/graphTraversalUtil'
import SelectionRectangle from './SelectionRectangle.vue'
import { useUrlActionLoaders } from '@/composables/useUrlActionLoaders'
const AgentPanelRoot = defineAsyncComponent(
() => import('@/workbench/extensions/agent/AgentPanelRoot.vue')
)
const { t } = useI18n()
const emit = defineEmits<{
ready: []

View File

@@ -150,22 +150,6 @@ describe('TabErrors.vue', () => {
expect(screen.queryByText('Error details')).not.toBeInTheDocument()
})
it('passes raw details through to the card for agent prompt errors only', () => {
renderComponent({
executionError: {
lastPromptError: {
type: 'agent_api_failed',
message: 'Comfy Agent hit a server error.',
details: 'HTTP 500 from /api/agent/threads'
}
}
})
expect(
screen.getByText('HTTP 500 from /api/agent/threads')
).toBeInTheDocument()
})
it('renders node validation errors grouped by catalog copy', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {

View File

@@ -65,10 +65,6 @@ vi.mock('@/i18n', () => {
'Prompt has no outputs',
'errorCatalog.promptErrors.prompt_no_outputs.desc':
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.',
'errorCatalog.promptErrors.agent_draft_apply_failed.title':
'An error was found',
'errorCatalog.promptErrors.agent_draft_apply_failed.desc':
"Couldn't apply the agent's draft to the canvas.",
'errorCatalog.runtimeErrors.execution_failed.title': 'Execution failed',
'errorCatalog.runtimeErrors.execution_failed.message':
'Node threw an error during execution.',
@@ -678,25 +674,6 @@ describe('useErrorGroups', () => {
expect(promptGroup).toBeDefined()
})
it('carries prompt error details onto the card item', async () => {
const { store, groups } = createErrorGroups()
store.lastPromptError = {
type: 'agent_draft_apply_failed',
message: "Couldn't apply the agent's draft to the canvas",
details: 'Validation error: Required at "version"'
}
await nextTick()
const promptGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution' && g.displayTitle === 'An error was found'
)
const details =
promptGroup && 'cards' in promptGroup
? promptGroup.cards[0]?.errors[0]?.details
: undefined
expect(details).toBe('Validation error: Required at "version"')
})
it('includes prompt error when a node is selected', async () => {
const { store, groups } = createErrorGroups()
const canvasStore = useCanvasStore()

View File

@@ -46,11 +46,6 @@ import {
const PROMPT_CARD_ID = '__prompt__'
const AGENT_PROMPT_ERROR_TYPES = new Set([
'agent_api_failed',
'agent_draft_apply_failed'
])
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
const RESOLVING = '__RESOLVING__'
@@ -377,9 +372,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
errors: [
{
message: error.message,
...(AGENT_PROMPT_ERROR_TYPES.has(error.type)
? { details: error.details }
: {}),
...resolvedDisplay
}
]

View File

@@ -79,34 +79,6 @@ vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => ({ shiftDown: false })
}))
const agentPanelHolder = vi.hoisted(() => ({
store: null as unknown as {
isOpen: { value: boolean }
enabled: { value: boolean }
toggle: ReturnType<typeof vi.fn>
}
}))
vi.mock(
'@/workbench/extensions/agent/stores/agent/agentPanelStore',
async () => {
const { ref } = await import('vue')
agentPanelHolder.store = {
isOpen: ref(false),
enabled: ref(false),
toggle: vi.fn(() => {
agentPanelHolder.store.isOpen.value =
!agentPanelHolder.store.isOpen.value
})
}
return { useAgentPanelStore: () => agentPanelHolder.store }
}
)
const trackAgentEntryButtonClicked = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackAgentEntryButtonClicked })
}))
vi.mock('@/utils/mouseDownUtil', () => ({
whileMouseDown: vi.fn()
}))
@@ -159,42 +131,6 @@ function renderComponent() {
return { user, ...result }
}
describe('WorkflowTabs agent entry button', () => {
beforeEach(() => {
tabBarLayout.value = 'Integrated'
agentPanelHolder.store.enabled.value = true
agentPanelHolder.store.isOpen.value = false
trackAgentEntryButtonClicked.mockClear()
agentPanelHolder.store.toggle.mockClear()
})
afterEach(() => {
tabBarLayout.value = 'Legacy'
agentPanelHolder.store.enabled.value = false
agentPanelHolder.store.isOpen.value = false
})
it('reports the entry click with the state the click produces', async () => {
const { user } = renderComponent()
await user.click(
screen.getByRole('button', { name: enMessages.agent.askComfyAgent })
)
expect(trackAgentEntryButtonClicked).toHaveBeenCalledWith({
resulting_state: 'opened'
})
expect(agentPanelHolder.store.toggle).toHaveBeenCalledTimes(1)
agentPanelHolder.store.isOpen.value = true
await user.click(
screen.getByRole('button', { name: enMessages.agent.askComfyAgent })
)
expect(trackAgentEntryButtonClicked).toHaveBeenLastCalledWith({
resulting_state: 'closed'
})
})
})
describe('WorkflowTabs feedback button', () => {
let openSpy: ReturnType<typeof vi.spyOn>

View File

@@ -84,24 +84,6 @@
data-testid="integrated-tab-bar-actions"
class="ml-auto flex shrink-0 items-center gap-2 px-2"
>
<Button
v-if="agentPanelEnabled"
variant="link"
size="sm"
:class="
cn(
'no-drag shrink-0 border border-solid text-base-foreground',
isAgentPanelOpen
? 'border-plum-500 bg-plum-600/20'
: 'border-plum-600 bg-ink-700 hover:border-plum-500'
)
"
:aria-label="$t('agent.askComfyAgent')"
@click="onAgentEntryClick"
>
<i class="icon-[comfy--comfy-c] size-3 text-brand-yellow" />
<span>{{ $t('agent.askComfyAgent') }}</span>
</Button>
<Button
v-if="isCloud || isNightly"
v-tooltip="{ value: $t('actionbar.feedbackTooltip'), showDelay: 300 }"
@@ -124,9 +106,7 @@
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useScroll } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ScrollPanel from 'primevue/scrollpanel'
import SelectButton from 'primevue/selectbutton'
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
@@ -146,8 +126,6 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useTelemetry } from '@/platform/telemetry'
import { useAgentPanelStore } from '@/workbench/extensions/agent/stores/agent/agentPanelStore'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import { whileMouseDown } from '@/utils/mouseDownUtil'
@@ -167,16 +145,6 @@ const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const commandStore = useCommandStore()
const agentPanelStore = useAgentPanelStore()
const { isOpen: isAgentPanelOpen, enabled: agentPanelEnabled } =
storeToRefs(agentPanelStore)
function onAgentEntryClick(): void {
useTelemetry()?.trackAgentEntryButtonClicked({
resulting_state: isAgentPanelOpen.value ? 'closed' : 'opened'
})
agentPanelStore.toggle()
}
const { isLoggedIn } = useCurrentUser()
// Dismiss a tab's terminal status badge once it has been viewed

View File

@@ -1,80 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyExtension } from '@/types/comfy'
const mocks = vi.hoisted(() => ({
capturedExtensions: [] as ComfyExtension[],
agentStore: { enabled: false, close: vi.fn() },
flagEnabled: undefined as boolean | undefined,
flagListener: null as (() => void) | null
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({
registerExtension: (ext: ComfyExtension) => {
mocks.capturedExtensions.push(ext)
}
})
}))
vi.mock('@/workbench/extensions/agent/stores/agent/agentPanelStore', () => ({
useAgentPanelStore: () => mocks.agentStore
}))
vi.mock('posthog-js', () => ({
default: {
isFeatureEnabled: () => mocks.flagEnabled,
onFeatureFlags: (listener: () => void) => {
mocks.flagListener = listener
return () => {}
}
}
}))
const flush = (): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, 0))
async function loadEntryAndSetup(): Promise<void> {
await import('./agentPanel')
const ext = mocks.capturedExtensions.find(
(e) => e.name === 'Comfy.AgentPanel'
)
expect(ext).toBeDefined()
ext!.setup!({} as Parameters<NonNullable<ComfyExtension['setup']>>[0])
for (let i = 0; i < 20 && mocks.flagListener === null; i++) await flush()
expect(mocks.flagListener).toBeTypeOf('function')
}
describe('AgentPanel extension flag gate', () => {
beforeEach(() => {
mocks.capturedExtensions.length = 0
mocks.agentStore.close.mockClear()
mocks.agentStore.enabled = false
mocks.flagEnabled = undefined
mocks.flagListener = null
vi.resetModules()
})
it('leaves the panel disabled while the flag is undefined', async () => {
await loadEntryAndSetup()
expect(mocks.agentStore.enabled).toBe(false)
})
it('enables the panel when the flag turns true', async () => {
await loadEntryAndSetup()
mocks.flagEnabled = true
mocks.flagListener!()
expect(mocks.agentStore.enabled).toBe(true)
})
it('disables and closes the panel when the flag flips back to false', async () => {
await loadEntryAndSetup()
mocks.flagEnabled = true
mocks.flagListener!()
mocks.flagEnabled = false
mocks.flagListener!()
expect(mocks.agentStore.enabled).toBe(false)
expect(mocks.agentStore.close).toHaveBeenCalledWith('flag_disabled')
})
})

View File

@@ -1,25 +0,0 @@
import { createPostHogFlagSource } from '@/workbench/extensions/agent/composables/agent/useAgentFeatureGate'
import { useAgentPanelStore } from '@/workbench/extensions/agent/stores/agent/agentPanelStore'
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
name: 'Comfy.AgentPanel',
setup() {
const agentPanelStore = useAgentPanelStore()
async function setupFlagGate(): Promise<void> {
const posthog = (await import('posthog-js')).default
const source = createPostHogFlagSource(posthog)
const sync = (): void => {
const forceInDev = import.meta.env.MODE === 'development'
const enabled = forceInDev || source.isEnabled()
agentPanelStore.enabled = enabled
if (!enabled) agentPanelStore.close('flag_disabled')
}
source.onChange?.(sync)
sync()
}
void setupFlagGate()
}
})

View File

@@ -38,7 +38,6 @@ if (isCloud) {
await import('./cloudRemoteConfig')
await import('./cloudBadges')
await import('./cloudSessionCookie')
await import('./agentPanel')
}
// Feedback button for cloud and nightly builds

View File

@@ -3,42 +3,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { GizmoManager } from './GizmoManager'
const {
mockSetMode,
mockAttach,
mockDetach,
mockGetHelper,
mockDispose,
transformControlsInstances,
omitGetPointer
} = vi.hoisted(() => ({
mockSetMode: vi.fn(),
mockAttach: vi.fn(),
mockDetach: vi.fn(),
mockGetHelper: vi.fn(),
mockDispose: vi.fn(),
transformControlsInstances: [] as unknown[],
omitGetPointer: { value: false }
}))
const { mockSetMode, mockAttach, mockDetach, mockGetHelper, mockDispose } =
vi.hoisted(() => ({
mockSetMode: vi.fn(),
mockAttach: vi.fn(),
mockDetach: vi.fn(),
mockGetHelper: vi.fn(),
mockDispose: vi.fn()
}))
vi.mock('three/examples/jsm/controls/TransformControls', () => {
class TransformControls {
enabled = true
dragging = false
camera: THREE.Camera
_getPointer?: (event: PointerEvent) => {
x: number
y: number
button: number
}
private listeners = new Map<string, ((e: unknown) => void)[]>()
constructor(camera: THREE.Camera) {
this.camera = camera
if (!omitGetPointer.value) {
this._getPointer = (event) => ({ x: 0, y: 0, button: event.button })
}
transformControlsInstances.push(this)
}
addEventListener(event: string, cb: (e: unknown) => void) {
@@ -83,8 +64,6 @@ describe('GizmoManager', () => {
beforeEach(() => {
vi.clearAllMocks()
transformControlsInstances.length = 0
omitGetPointer.value = false
scene = new THREE.Scene()
interactionElement = document.createElement('div')
@@ -110,120 +89,6 @@ describe('GizmoManager', () => {
vi.restoreAllMocks()
})
describe('setPointerNdcSource', () => {
type PointerNdc = { x: number; y: number; button: number }
function lastControls() {
return transformControlsInstances.at(-1) as {
dragging: boolean
_getPointer?: (event: PointerEvent) => PointerNdc
}
}
function getPointerOverride() {
return lastControls()._getPointer
}
it('routes TransformControls pointer NDC through the injected source', () => {
manager.init()
manager.setPointerNdcSource((clientX, clientY) => ({
x: clientX / 100,
y: clientY / 100,
inside: true
}))
const pointer = getPointerOverride()!({
clientX: 50,
clientY: -25,
button: 2
} as PointerEvent)
expect(pointer).toEqual({ x: 0.5, y: -0.25, button: 2 })
})
it('maps unmappable points to an off-screen pointer', () => {
manager.init()
manager.setPointerNdcSource(() => null)
const pointer = getPointerOverride()!({
clientX: 0,
clientY: 0,
button: 0
} as PointerEvent)
expect(pointer).toEqual({ x: 10, y: 10, button: 0 })
})
it('maps points outside the viewport to an off-screen pointer while not dragging', () => {
manager.init()
manager.setPointerNdcSource(() => ({ x: -1.2, y: 0.4, inside: false }))
const pointer = getPointerOverride()!({
clientX: 0,
clientY: 0,
button: 0
} as PointerEvent)
expect(pointer).toEqual({ x: 10, y: 10, button: 0 })
})
it('keeps the unclamped NDC for points outside the viewport mid-drag', () => {
manager.init()
manager.setPointerNdcSource(() => ({ x: -1.2, y: 0.4, inside: false }))
lastControls().dragging = true
const pointer = getPointerOverride()!({
clientX: 0,
clientY: 0,
button: -1
} as PointerEvent)
expect(pointer).toEqual({ x: -1.2, y: 0.4, button: -1 })
})
it('applies a source registered before init once init runs', () => {
manager.setPointerNdcSource(() => ({ x: 0.5, y: 0.5, inside: true }))
manager.init()
const pointer = getPointerOverride()!({
clientX: 0,
clientY: 0,
button: 1
} as PointerEvent)
expect(pointer).toEqual({ x: 0.5, y: 0.5, button: 1 })
})
it('delegates to the stock mapping until a source is registered', () => {
manager.init()
const stock = getPointerOverride()!({
clientX: 40,
clientY: 60,
button: 2
} as PointerEvent)
expect(stock).toEqual({ x: 0, y: 0, button: 2 })
manager.setPointerNdcSource(() => ({ x: 0.5, y: -0.25, inside: true }))
const mapped = getPointerOverride()!({
clientX: 40,
clientY: 60,
button: 2
} as PointerEvent)
expect(mapped).toEqual({ x: 0.5, y: -0.25, button: 2 })
})
it('warns and skips the override when _getPointer is missing at init', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
omitGetPointer.value = true
manager.setPointerNdcSource(() => ({ x: 0.5, y: 0.5, inside: true }))
manager.init()
expect(warn).toHaveBeenCalledWith(expect.stringContaining('_getPointer'))
expect(lastControls()._getPointer).toBeUndefined()
})
})
describe('init', () => {
it('adds helper to scene with correct name and render order', () => {
manager.init()

View File

@@ -4,9 +4,6 @@ import { TransformControls } from 'three/examples/jsm/controls/TransformControls
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import type { GizmoMode, Model3DTransform } from './interfaces'
import type { PointerNdcSource } from './load3dViewport'
const OFF_SCREEN_POINTER_NDC = { x: 10, y: 10 }
export class GizmoManager {
private transformControls: TransformControls | null = null
@@ -21,7 +18,6 @@ export class GizmoManager {
private interactionElement: HTMLElement
private orbitControls: OrbitControls
private onTransformChange?: () => void
private getPointerNdc?: PointerNdcSource
constructor(
scene: THREE.Scene,
@@ -50,45 +46,12 @@ export class GizmoManager {
}
})
this.installPointerNdcOverride()
const helper = this.transformControls.getHelper()
helper.name = 'GizmoTransformControls'
helper.renderOrder = 999
this.scene.add(helper)
}
setPointerNdcSource(getPointerNdc: PointerNdcSource): void {
this.getPointerNdc = getPointerNdc
}
private installPointerNdcOverride(): void {
if (!this.transformControls) return
const transformControls = this.transformControls
const controls = transformControls as unknown as {
_getPointer?: (event: PointerEvent) => {
x: number
y: number
button: number
}
}
const original = controls._getPointer
if (typeof original !== 'function') {
console.warn(
'TransformControls no longer exposes _getPointer; letterbox-aware gizmo pointer mapping is disabled.'
)
return
}
controls._getPointer = (event: PointerEvent) => {
if (!this.getPointerNdc) return original.call(transformControls, event)
const ndc = this.getPointerNdc(event.clientX, event.clientY)
if (!ndc || (!ndc.inside && !transformControls.dragging)) {
return { ...OFF_SCREEN_POINTER_NDC, button: event.button }
}
return { x: ndc.x, y: ndc.y, button: event.button }
}
}
setupForModel(model: THREE.Object3D): void {
if (!this.transformControls) return

View File

@@ -1,13 +1,11 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Load3dDeps } from '@/extensions/core/load3d/Load3d'
import Load3d from '@/extensions/core/load3d/Load3d'
import type {
CameraState,
GizmoMode
} from '@/extensions/core/load3d/interfaces'
import type { PointerNdcSource } from '@/extensions/core/load3d/load3dViewport'
const {
cloneSkinnedMock,
@@ -1262,102 +1260,4 @@ describe('Load3d', () => {
)
})
})
describe('constructor wiring', () => {
function makeConstructorDeps() {
const container = document.createElement('div')
const canvas = document.createElement('canvas')
container.appendChild(canvas)
const view = {
canvas,
renderer: {
setViewport: vi.fn(),
setScissor: vi.fn(),
setScissorTest: vi.fn(),
setClearColor: vi.fn(),
clear: vi.fn(),
render: vi.fn()
},
width: 800,
height: 600,
state: { clearColor: new THREE.Color(0x000000), clearAlpha: 0 },
observeResize: vi.fn(),
beginRender: vi.fn(),
blit: vi.fn(),
setSize: vi.fn(),
dispose: vi.fn()
}
const gizmoManager = {
setPointerNdcSource: vi.fn(),
init: vi.fn(),
dispose: vi.fn()
}
const deps = {
view,
eventManager: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
emitEvent: vi.fn()
},
sceneManager: {
init: vi.fn(),
scene: new THREE.Scene(),
renderBackground: vi.fn(),
handleResize: vi.fn(),
dispose: vi.fn()
},
cameraManager: {
init: vi.fn(),
activeCamera: new THREE.PerspectiveCamera(),
handleResize: vi.fn(),
dispose: vi.fn()
},
controlsManager: { init: vi.fn(), update: vi.fn(), dispose: vi.fn() },
lightingManager: { init: vi.fn(), dispose: vi.fn() },
viewHelperManager: {
createViewHelper: vi.fn(),
init: vi.fn(),
update: vi.fn(),
render: vi.fn(),
dispose: vi.fn()
},
hdriManager: { dispose: vi.fn() },
loaderManager: { init: vi.fn(), dispose: vi.fn() },
modelManager: { dispose: vi.fn() },
recordingManager: {
getIsRecording: vi.fn(() => false),
dispose: vi.fn()
},
animationManager: {
init: vi.fn(),
update: vi.fn(),
isAnimationPlaying: false,
dispose: vi.fn()
},
gizmoManager,
adapterRef: { current: null, capabilities: null }
}
return { container, deps: deps as unknown as Load3dDeps, gizmoManager }
}
it('wires the gizmo pointer NDC source to clientPointToNdc on every construction path', () => {
const { container, deps, gizmoManager } = makeConstructorDeps()
const load3d = new Load3d(container, deps)
expect(gizmoManager.setPointerNdcSource).toHaveBeenCalledOnce()
const ndc = { x: 0.25, y: -0.5, inside: true }
const clientPointToNdc = vi
.spyOn(load3d, 'clientPointToNdc')
.mockReturnValue(ndc)
const source = gizmoManager.setPointerNdcSource.mock
.calls[0][0] as PointerNdcSource
expect(source(12, 34)).toBe(ndc)
expect(clientPointToNdc).toHaveBeenCalledWith(12, 34)
load3d.remove()
})
})
})

View File

@@ -83,9 +83,6 @@ class Load3d extends Viewport3d {
this.loaderManager.init()
this.animationManager.init()
this.gizmoManager.setPointerNdcSource((clientX, clientY) =>
this.clientPointToNdc(clientX, clientY)
)
this.gizmoManager.init()
this.eventManager.addEventListener('modelReady', () => {

View File

@@ -386,67 +386,6 @@ describe('Viewport3d', () => {
})
})
describe('clientPointToNdc', () => {
function installCanvas(rect: {
left: number
top: number
width: number
height: number
}) {
const canvas = document.createElement('canvas')
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
...rect,
right: rect.left + rect.width,
bottom: rect.top + rect.height,
x: rect.left,
y: rect.top,
toJSON: () => ({})
} as DOMRect)
Object.assign(ctx.viewport, { view: { canvas } })
}
beforeEach(() => {
Object.assign(ctx.viewport, {
targetWidth: 100,
targetHeight: 100,
targetAspectRatio: 1,
isViewerMode: false
})
})
it('normalizes client coordinates against the canvas rect before letterbox mapping', () => {
installCanvas({ left: 100, top: 50, width: 400, height: 200 })
expect(ctx.viewport.clientPointToNdc(300, 150)).toEqual({
x: expect.closeTo(0),
y: expect.closeTo(0),
inside: true
})
expect(ctx.viewport.clientPointToNdc(150, 150)).toEqual({
x: expect.closeTo(-1.5),
y: expect.closeTo(0),
inside: false
})
})
it('returns null when the canvas has no layout size', () => {
installCanvas({ left: 0, top: 0, width: 0, height: 0 })
expect(ctx.viewport.clientPointToNdc(10, 10)).toBeNull()
})
it('maps the full canvas when no aspect ratio is maintained', () => {
installCanvas({ left: 100, top: 50, width: 400, height: 200 })
Object.assign(ctx.viewport, { targetWidth: 0, targetHeight: 0 })
expect(ctx.viewport.clientPointToNdc(100, 50)).toEqual({
x: expect.closeTo(-1),
y: expect.closeTo(1),
inside: true
})
})
})
describe('start / remove lifecycle', () => {
beforeEach(() => {
vi.useFakeTimers()

View File

@@ -1,7 +1,6 @@
import * as THREE from 'three'
import type { RendererView } from '@/renderer/three/RendererView'
import { normalize } from '@/utils/mathUtil'
import type { CameraManager } from './CameraManager'
import type { ControlsManager } from './ControlsManager'
@@ -18,12 +17,7 @@ import type {
import { attachContextMenuGuard } from './load3dContextMenuGuard'
import type { RenderLoopHandle } from './load3dRenderLoop'
import { startRenderLoop } from './load3dRenderLoop'
import type { LetterboxNdc } from './load3dViewport'
import {
clientPointToLetterboxNdc,
computeLetterboxedViewport,
isLoad3dActive
} from './load3dViewport'
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
const VIEW_HELPER_SIZE = 128
@@ -282,17 +276,6 @@ export class Viewport3d {
this.renderer.render(this.sceneManager.scene, this.getRenderCamera())
}
clientPointToNdc(clientX: number, clientY: number): LetterboxNdc | null {
const rect = this.domElement.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return null
return clientPointToLetterboxNdc(
normalize(clientX, rect.left, rect.right),
normalize(clientY, rect.top, rect.bottom),
{ width: rect.width, height: rect.height },
this.shouldMaintainAspectRatio() ? this.targetAspectRatio : null
)
}
protected startAnimation(): void {
this.renderLoop = startRenderLoop({
tick: () => {

View File

@@ -1,10 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
clientPointToLetterboxNdc,
computeLetterboxedViewport,
isLoad3dActive
} from './load3dViewport'
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
import type { Load3dActivityFlags } from './load3dViewport'
describe('computeLetterboxedViewport', () => {
@@ -110,59 +106,3 @@ describe('isLoad3dActive', () => {
expect(isLoad3dActive({ ...idle, [flag]: true })).toBe(true)
})
})
describe('clientPointToLetterboxNdc', () => {
function ndc(x: number, y: number, inside = true) {
return { x: expect.closeTo(x), y: expect.closeTo(y), inside }
}
it('maps the full canvas when no target aspect is set', () => {
expect(
clientPointToLetterboxNdc(0.5, 0.5, { width: 400, height: 300 }, null)
).toEqual(ndc(0, 0))
expect(
clientPointToLetterboxNdc(0, 1, { width: 400, height: 300 }, null)
).toEqual(ndc(-1, -1))
})
it('maps pillarboxed content edges to -1/1', () => {
const container = { width: 400, height: 200 }
expect(clientPointToLetterboxNdc(0.25, 0.5, container, 1)).toEqual(
ndc(-1, 0)
)
expect(clientPointToLetterboxNdc(0.75, 0, container, 1)).toEqual(ndc(1, 1))
expect(clientPointToLetterboxNdc(0.5, 0.5, container, 1)).toEqual(ndc(0, 0))
})
it('extrapolates unclamped NDC marked outside on the letterbox bars', () => {
const container = { width: 400, height: 200 }
expect(clientPointToLetterboxNdc(0.1, 0.5, container, 1)).toEqual(
ndc(-1.6, 0, false)
)
expect(clientPointToLetterboxNdc(0.9, 0.5, container, 1)).toEqual(
ndc(1.6, 0, false)
)
})
it('handles letterbox bars above/below wide content', () => {
const container = { width: 200, height: 400 }
expect(clientPointToLetterboxNdc(0.5, 0.375, container, 2)).toEqual(
ndc(0, 1)
)
expect(clientPointToLetterboxNdc(0.5, 0.1, container, 2)).toEqual(
ndc(0, 3.2, false)
)
})
it('returns null instead of NaN for zero-size containers', () => {
expect(
clientPointToLetterboxNdc(0.5, 0.5, { width: 0, height: 0 }, 1)
).toBeNull()
expect(
clientPointToLetterboxNdc(0.5, 0.5, { width: 0, height: 200 }, 1)
).toBeNull()
expect(
clientPointToLetterboxNdc(0.5, 0.5, { width: 400, height: 0 }, 1)
).toBeNull()
})
})

View File

@@ -1,5 +1,3 @@
import { denormalize, normalize } from '@/utils/mathUtil'
type Size = { width: number; height: number }
type LetterboxedViewport = {
@@ -36,39 +34,6 @@ export function computeLetterboxedViewport(
}
}
export type LetterboxNdc = { x: number; y: number; inside: boolean }
export type PointerNdcSource = (
clientX: number,
clientY: number
) => LetterboxNdc | null
export function clientPointToLetterboxNdc(
normalizedX: number,
normalizedY: number,
container: Size,
targetAspectRatio: number | null
): LetterboxNdc | null {
const toNdc = (localX: number, localY: number): LetterboxNdc => ({
x: denormalize(localX, -1, 1),
y: -denormalize(localY, -1, 1),
inside: localX >= 0 && localX <= 1 && localY >= 0 && localY <= 1
})
if (targetAspectRatio === null) {
return toNdc(normalizedX, normalizedY)
}
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
container,
targetAspectRatio
)
if (width <= 0 || height <= 0) return null
return toNdc(
normalize(normalizedX * container.width, offsetX, offsetX + width),
normalize(normalizedY * container.height, offsetY, offsetY + height)
)
}
export type Load3dActivityFlags = {
mouseOnNode: boolean
mouseOnScene: boolean

View File

@@ -77,4 +77,18 @@ describe('stRaw', () => {
'Fallback value'
)
})
it('returns the fallback when the resolved locale message is not a string', () => {
i18n.global.mergeLocaleMessage('en', {
safeTranslationTest: {
nested: { child: 'value' }
}
})
// The key exists (te() is true), but tm() resolves to an object rather
// than a string, so rawTranslationOrFallback must fall back.
expect(stRaw('safeTranslationTest.nested', 'Fallback value')).toBe(
'Fallback value'
)
})
})

View File

@@ -159,28 +159,15 @@ export const te: (typeof i18n.global)['te'] = i18n.global.te
export const d: (typeof i18n.global)['d'] = i18n.global.d
const tm = i18n.global.tm
function rawTranslationOrFallback(key: string, fallbackMessage: string) {
const message = tm(key)
return typeof message === 'string' ? message : fallbackMessage
}
/**
* Safe translation function that returns the fallback message if the key is not found.
* Invalid message syntax falls back to the raw locale message instead of crashing.
*
* @param key - The key to translate.
* @param fallbackMessage - The fallback message to use if the key is not found.
*/
export function st(key: string, fallbackMessage: string) {
if (!te(key)) return fallbackMessage
try {
// The normal defaultMsg overload fails in some cases for custom nodes
return t(key)
} catch (error) {
if (!(error instanceof SyntaxError)) throw error
return rawTranslationOrFallback(key, fallbackMessage)
}
// The normal defaultMsg overload fails in some cases for custom nodes
return te(key) ? t(key) : fallbackMessage
}
/**
@@ -193,5 +180,6 @@ export function st(key: string, fallbackMessage: string) {
export function stRaw(key: string, fallbackMessage: string) {
if (!te(key)) return fallbackMessage
return rawTranslationOrFallback(key, fallbackMessage)
const message = tm(key)
return typeof message === 'string' ? message : fallbackMessage
}

View File

@@ -4356,14 +4356,6 @@
"prompt_outputs_failed_validation": {
"title": "Prompt validation failed",
"desc": "The workflow has invalid node inputs. Fix the highlighted nodes before running it again."
},
"agent_draft_apply_failed": {
"title": "An error was found",
"desc": "Couldn't apply the agent's draft to the canvas."
},
"agent_api_failed": {
"title": "An error was found",
"desc": "Comfy Agent hit a server error."
}
}
},
@@ -4555,74 +4547,5 @@
"training": "Training…",
"processingVideo": "Processing video…",
"running": "Running…"
},
"agent": {
"title": "Comfy Agent",
"askComfyAgent": "Ask Comfy Agent",
"caption": "The agent edits your workflow as a draft. Only you can run it.",
"latest": "Latest",
"copyUnavailable": "Only the current conversation can be copied in this version",
"alpha": "ALPHA",
"newChat": "New chat",
"newChatTitle": "Untitled",
"maximize": "Maximize panel",
"minimize": "Minimize panel",
"close": "Close",
"history": "Chat history",
"showChatHistory": "Show chat history",
"backToChat": "Back to chat",
"historyCurrent": "Current",
"historyToday": "Today",
"historyYesterday": "Yesterday",
"historyEarlier": "Earlier",
"historyEmpty": "No conversations yet",
"copyMarkdown": "Copy as markdown",
"delete": "Delete",
"untitledChat": "Untitled",
"skip": "Skip",
"gotIt": "Got it",
"greeting": "Hello {name},",
"greetingQuestion": "What do you want to make?",
"placeholder": "Ask Comfy Agent…",
"suggestedPrompts": [
"Generate a yellow duck with a hockey mask",
"List my saved workflows",
"Find the best workflow for skin upscaling",
"Explain the selected node",
"Build a workflow for image to video with 3 models"
],
"attach": "Attach a file",
"attachmentTooLarge": "{name} is larger than {limit}",
"attachmentUploadFailed": "{name} could not be uploaded",
"mention": "Mention a node",
"modelAuto": "Auto",
"send": "Send",
"stop": "Stop",
"thinking": "Thinking...",
"helpful": "Helpful",
"notHelpful": "Not helpful",
"toggleRaw": "Toggle raw markdown",
"ranToolCalls": "Ran {count} tool call | Ran {count} tool calls",
"copy": "Copy",
"copied": "Copied!",
"remove": "Remove",
"uploading": "Uploading",
"noNodesToMention": "No nodes in this workflow",
"friend": "there",
"conflictTitle": "Agent changes are ready",
"conflictBody": "You edited this workflow while Comfy Agent changed the same graph. Choose what should happen in this tab.",
"keepMine": "Keep my changes",
"acceptAgent": "Accept agent changes",
"moreApplyOptions": "More apply options",
"openNewTab": "Open in new tab",
"sendFailed": "Message failed to send",
"sendBusy": "A message is already being sent",
"malformedEvent": "The agent sent an event this panel could not read",
"coachTitle": "Meet the agent",
"coachBody": "Ask it to build or edit graphs.",
"toolOpenedNewTab": "Opened a new tab",
"toolSwitchedTabs": "Switched tabs",
"toolSavedPreference": "Saved a preference",
"toolForgotPreference": "Forgot a preference"
}
}

View File

@@ -28,7 +28,14 @@ describe('escapeVueI18nMessageSyntax output is compiled safely by vue-i18n', ()
'foreground | background',
'50%{done}',
'all of @ { } | % together',
'no special chars here'
'no special chars here',
// Regression fixture for the actual 1.47.7 crash: a node description
// referencing a model repo alongside a JSON example.
'Provided by @acme/model with JSON such as {"mode":"fast"}',
// Node category paths (e.g. `nodeDef.category.split('/')`) are escaped
// per-segment; a segment itself may still contain syntax characters.
'image/@custom-pack',
''
])('renders %s as the original literal text', (raw) => {
expect(compile(escapeVueI18nMessageSyntax(raw))).toBe(raw)
})

View File

@@ -555,23 +555,6 @@ describe('errorMessageResolver', () => {
})
})
it('resolves the agent draft-apply failure to overlay copy', () => {
expect(
resolveRunErrorMessage({
kind: 'prompt',
isCloud: true,
error: {
type: 'agent_draft_apply_failed',
message: "Couldn't apply the agent's draft to the canvas",
details: 'Validation error: Required at "version"'
}
})
).toEqual({
displayTitle: 'An error was found',
displayMessage: "Couldn't apply the agent's draft to the canvas."
})
})
it('resolves server_error prompt copy by environment', () => {
const error = {
type: 'server_error',

View File

@@ -13,8 +13,7 @@ const KNOWN_PROMPT_ERROR_TYPES = new Set([
'no_prompt',
'server_error',
'missing_node_type',
'prompt_outputs_failed_validation',
'agent_draft_apply_failed'
'prompt_outputs_failed_validation'
])
function getPromptExceptionMessage(

View File

@@ -2,13 +2,6 @@ import type { AuditLog } from '@/services/customerEventsService'
import type {
AddCreditsClickMetadata,
AgentEntryButtonClickedMetadata,
AgentMessageFeedbackMetadata,
AgentMessageSentMetadata,
AgentNodeTaggedMetadata,
AgentPanelClosedMetadata,
AgentPanelOpenedMetadata,
AgentWorkflowAppliedMetadata,
AuthErrorMetadata,
AuthMetadata,
BeginCheckoutMetadata,
@@ -295,46 +288,6 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackUiButtonClicked?.(metadata))
}
trackAgentMessageFeedback(metadata: AgentMessageFeedbackMetadata): void {
this.dispatch((provider) => provider.trackAgentMessageFeedback?.(metadata))
}
trackAgentPanelOpened(metadata: AgentPanelOpenedMetadata): void {
this.dispatch((provider) => provider.trackAgentPanelOpened?.(metadata))
}
trackAgentPanelClosed(metadata: AgentPanelClosedMetadata): void {
this.dispatch((provider) => provider.trackAgentPanelClosed?.(metadata))
}
trackAgentEntryButtonClicked(
metadata: AgentEntryButtonClickedMetadata
): void {
this.dispatch((provider) =>
provider.trackAgentEntryButtonClicked?.(metadata)
)
}
trackAgentCloseButtonClicked(): void {
this.dispatch((provider) => provider.trackAgentCloseButtonClicked?.())
}
trackAgentMessageSent(metadata: AgentMessageSentMetadata): void {
this.dispatch((provider) => provider.trackAgentMessageSent?.(metadata))
}
trackAgentNodeTagged(metadata: AgentNodeTaggedMetadata): void {
this.dispatch((provider) => provider.trackAgentNodeTagged?.(metadata))
}
trackAgentAttachButtonClicked(): void {
this.dispatch((provider) => provider.trackAgentAttachButtonClicked?.())
}
trackAgentWorkflowApplied(metadata: AgentWorkflowAppliedMetadata): void {
this.dispatch((provider) => provider.trackAgentWorkflowApplied?.(metadata))
}
trackPageView(pageName: string, properties?: PageViewMetadata): void {
this.dispatch((provider) => provider.trackPageView?.(pageName, properties))
}

View File

@@ -135,29 +135,6 @@ describe('PostHogTelemetryProvider', () => {
)
})
it("lets the server's person_profiles win over the client default", async () => {
hoisted.refs.remoteConfig.value = {
posthog_config: { person_profiles: 'always' }
}
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockInit).toHaveBeenCalledWith(
'phc_test_token',
expect.objectContaining({ person_profiles: 'always' })
)
})
it('defaults person_profiles to identified_only when the server omits it', async () => {
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockInit).toHaveBeenCalledWith(
'phc_test_token',
expect.objectContaining({ person_profiles: 'identified_only' })
)
})
it('registers onUserResolved callback after init', async () => {
createProvider()
await vi.dynamicImportSettled()
@@ -893,11 +870,12 @@ describe('PostHogTelemetryProvider', () => {
expect(result.$set_once).toHaveProperty('plan', 'free')
})
it('remoteConfig.posthog_config cannot override before_send (PII stripping)', async () => {
it('remoteConfig.posthog_config cannot override before_send or person_profiles', async () => {
const remoteBefore_send = vi.fn()
hoisted.refs.remoteConfig.value = {
posthog_config: {
before_send: remoteBefore_send
before_send: remoteBefore_send,
person_profiles: 'always'
}
}
@@ -907,6 +885,7 @@ describe('PostHogTelemetryProvider', () => {
const initConfig = hoisted.mockInit.mock.calls[0][1]
expect(initConfig.before_send).not.toBe(remoteBefore_send)
expect(initConfig.person_profiles).toBe('identified_only')
})
})
})

View File

@@ -11,13 +11,6 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
AddCreditsClickMetadata,
AgentEntryButtonClickedMetadata,
AgentMessageFeedbackMetadata,
AgentMessageSentMetadata,
AgentNodeTaggedMetadata,
AgentPanelClosedMetadata,
AgentPanelOpenedMetadata,
AgentWorkflowAppliedMetadata,
AuthErrorMetadata,
AuthMetadata,
BeginCheckoutMetadata,
@@ -126,9 +119,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
{ immediate: true }
)
const apiKey =
window.__CONFIG__?.posthog_project_token ??
import.meta.env.VITE_POSTHOG_PROJECT_TOKEN
const apiKey = window.__CONFIG__?.posthog_project_token
if (apiKey) {
try {
void import('posthog-js')
@@ -144,8 +135,8 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
capture_pageleave: false,
persistence: 'localStorage+cookie',
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',
person_profiles: 'identified_only',
...serverConfig,
person_profiles: 'identified_only',
// cookie_domain omitted: posthog-js sets a first-party cross-subdomain cookie
// automatically when persistence includes 'cookie' (the default).
// Explicit override interacts badly with posthog-js#3578 where reset() fails
@@ -578,44 +569,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.UI_BUTTON_CLICKED, metadata)
}
trackAgentMessageFeedback(metadata: AgentMessageFeedbackMetadata): void {
this.trackEvent(TelemetryEvents.AGENT_MESSAGE_FEEDBACK, metadata)
}
trackAgentPanelOpened(metadata: AgentPanelOpenedMetadata): void {
this.trackEvent(TelemetryEvents.AGENT_PANEL_OPENED, metadata)
}
trackAgentPanelClosed(metadata: AgentPanelClosedMetadata): void {
this.trackEvent(TelemetryEvents.AGENT_PANEL_CLOSED, metadata)
}
trackAgentEntryButtonClicked(
metadata: AgentEntryButtonClickedMetadata
): void {
this.trackEvent(TelemetryEvents.AGENT_ENTRY_BUTTON_CLICKED, metadata)
}
trackAgentCloseButtonClicked(): void {
this.trackEvent(TelemetryEvents.AGENT_CLOSE_BUTTON_CLICKED)
}
trackAgentMessageSent(metadata: AgentMessageSentMetadata): void {
this.trackEvent(TelemetryEvents.AGENT_MESSAGE_SENT, metadata)
}
trackAgentNodeTagged(metadata: AgentNodeTaggedMetadata): void {
this.trackEvent(TelemetryEvents.AGENT_NODE_TAGGED, metadata)
}
trackAgentAttachButtonClicked(): void {
this.trackEvent(TelemetryEvents.AGENT_ATTACH_BUTTON_CLICKED)
}
trackAgentWorkflowApplied(metadata: AgentWorkflowAppliedMetadata): void {
this.trackEvent(TelemetryEvents.AGENT_WORKFLOW_APPLIED, metadata)
}
trackPageView(pageName: string, properties?: PageViewMetadata): void {
this.captureRaw(TelemetryEvents.PAGE_VIEW, {
page_name: pageName,

View File

@@ -401,51 +401,6 @@ export interface UiButtonClickMetadata {
element_group: string
}
/**
* In-App Agent message rating metadata (PM-98). `vote` is null when the user retracts a
* prior thumb, which the eval pipeline records as a retraction rather than dropping.
*/
export interface AgentMessageFeedbackMetadata extends Record<string, unknown> {
message_id: string
vote: 'up' | 'down' | null
workflow_id: string | null
}
export type AgentPanelCloseSource =
| 'topbar_button'
| 'close_button'
| 'flag_disabled'
export interface AgentPanelOpenedMetadata extends Record<string, unknown> {
source: 'topbar_button'
}
export interface AgentPanelClosedMetadata extends Record<string, unknown> {
source: AgentPanelCloseSource
open_duration_ms: number | null
}
export interface AgentEntryButtonClickedMetadata extends Record<
string,
unknown
> {
resulting_state: 'opened' | 'closed'
}
export interface AgentMessageSentMetadata extends Record<string, unknown> {
attachment_count: number
node_tag_count: number
}
export interface AgentNodeTaggedMetadata extends Record<string, unknown> {
source: 'mention_picker'
}
export interface AgentWorkflowAppliedMetadata extends Record<string, unknown> {
workflow_id: string
target: 'new_tab' | 'existing_tab' | 'active_tab_open' | 'active_tab_switch'
}
/**
* Help center opened metadata
*/
@@ -687,19 +642,6 @@ export interface TelemetryProvider {
// Generic UI button click events
trackUiButtonClicked?(metadata: UiButtonClickMetadata): void
// In-App Agent message rating (PM-98)
trackAgentMessageFeedback?(metadata: AgentMessageFeedbackMetadata): void
// In-App Agent panel engagement (FE-1187)
trackAgentPanelOpened?(metadata: AgentPanelOpenedMetadata): void
trackAgentPanelClosed?(metadata: AgentPanelClosedMetadata): void
trackAgentEntryButtonClicked?(metadata: AgentEntryButtonClickedMetadata): void
trackAgentCloseButtonClicked?(): void
trackAgentMessageSent?(metadata: AgentMessageSentMetadata): void
trackAgentNodeTagged?(metadata: AgentNodeTaggedMetadata): void
trackAgentAttachButtonClicked?(): void
trackAgentWorkflowApplied?(metadata: AgentWorkflowAppliedMetadata): void
// Page view tracking
trackPageView?(pageName: string, properties?: PageViewMetadata): void
}
@@ -803,17 +745,6 @@ export const TelemetryEvents = {
// Generic UI Button Click
UI_BUTTON_CLICKED: 'app:ui_button_clicked',
// In-App Agent
AGENT_MESSAGE_FEEDBACK: 'app:agent_message_feedback',
AGENT_PANEL_OPENED: 'app:agent_panel_opened',
AGENT_PANEL_CLOSED: 'app:agent_panel_closed',
AGENT_ENTRY_BUTTON_CLICKED: 'app:agent_entry_button_clicked',
AGENT_CLOSE_BUTTON_CLICKED: 'app:agent_close_button_clicked',
AGENT_MESSAGE_SENT: 'app:agent_message_sent',
AGENT_NODE_TAGGED: 'app:agent_node_tagged',
AGENT_ATTACH_BUTTON_CLICKED: 'app:agent_attach_button_clicked',
AGENT_WORKFLOW_APPLIED: 'app:agent_workflow_applied',
// Page View
PAGE_VIEW: 'app:page_view'
} as const
@@ -864,13 +795,6 @@ export type TelemetryEventProperties =
| HelpCenterOpenedMetadata
| HelpResourceClickedMetadata
| HelpCenterClosedMetadata
| AgentMessageFeedbackMetadata
| AgentPanelOpenedMetadata
| AgentPanelClosedMetadata
| AgentEntryButtonClickedMetadata
| AgentMessageSentMetadata
| AgentNodeTaggedMetadata
| AgentWorkflowAppliedMetadata
| WorkflowCreatedMetadata
| EnterLinearMetadata
| ShareFlowMetadata

View File

@@ -210,15 +210,10 @@ describe('formatShortMonthDay', () => {
})
describe('formatClockTime', () => {
it('uses app locale with explicit 12-hour preference', () => {
it('formats time with hours, minutes, and seconds', () => {
const ts = new Date(2024, 5, 15, 14, 5, 6).getTime()
expect(formatClockTime(ts, 'en-US', 'en-u-hc-h12')).toBe('2:05:06 PM')
})
it('uses app locale with explicit 24-hour preference', () => {
const ts = new Date(2024, 5, 15, 14, 5, 6).getTime()
expect(formatClockTime(ts, 'en-US', 'en-u-hc-h23')).toBe('14:05:06')
const result = formatClockTime(ts, 'en-GB')
// en-GB uses 24-hour format
expect(result).toBe('14:05:06')
})
})

View File

@@ -84,27 +84,17 @@ export const formatShortMonthDay = (ts: number, locale: string): string => {
}
/**
* Localized clock time, e.g. "10:05:06" with the app locale for language and
* the browser/system locale preference for 12/24-hour formatting.
* Localized clock time, e.g. "10:05:06" with locale defaults for 12/24 hour.
*
* @param ts Unix timestamp in milliseconds
* @param locale BCP-47 locale string
* @param clockPreferenceLocale Optional locale source for hour-cycle preference
* @returns Localized time string
*/
export const formatClockTime = (
ts: number,
locale: string,
clockPreferenceLocale?: string
): string => {
export const formatClockTime = (ts: number, locale: string): string => {
const d = new Date(ts)
const { hourCycle } = new Intl.DateTimeFormat(clockPreferenceLocale, {
hour: 'numeric'
}).resolvedOptions()
return new Intl.DateTimeFormat(locale, {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hourCycle
second: '2-digit'
}).format(d)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,752 +0,0 @@
<script setup lang="ts">
import './agentPanel.css'
import { useClipboard } from '@vueuse/core'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { appendWorkflowJsonExt } from '@/utils/formatUtil'
// eslint-disable-next-line import-x/no-restricted-paths
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { useToastStore } from '@/platform/updates/common/toastStore'
import AgentPanel from './components/agent/AgentPanel.vue'
import OnboardingCoach from './components/agent/OnboardingCoach.vue'
import type { ConflictChoice } from './components/agent/safety/ConflictDialog.vue'
import { useAttachment } from './composables/agent/useAttachment'
import type { ActiveTab } from './components/agent/ActiveTabStrip.vue'
import type { SelectedNode } from './composables/agent/useCanvasSelection'
import { useCanvasSelection } from './composables/agent/useCanvasSelection'
import type { CoachStep } from './composables/agent/useOnboarding'
import type { ComposerAttachment } from './composables/agent/useComposer'
import type {
AgentActiveTabData,
AgentDraftSnapshot,
AgentThreadSummary
} from './schemas/agentApiSchema'
import type { ChatSession } from './stores/agent/agentChatHistoryStore'
import type { ConversationEntry } from './stores/agent/agentConversationStore'
import type { WorkflowTurnContext } from './composables/agent/useAgentSession'
import { useAgentSession } from './composables/agent/useAgentSession'
import { useAgentDraftStore } from './stores/agent/agentDraftStore'
import { useAgentWorkflowTabBindingStore } from './stores/agent/agentWorkflowTabBindingStore'
import {
AgentApiError,
createAgentRestClient
} from './services/agent/agentRestClient'
import type {
DraftUpload,
OpenTabsSnapshot
} from './services/agent/agentRestClient'
import { createAgentEventSource } from './services/agent/agentEventSource'
import { useAgentChatHistoryStore } from './stores/agent/agentChatHistoryStore'
import { useAgentPanelStore } from './stores/agent/agentPanelStore'
const { t } = useI18n()
const toast = useToastStore()
const { userDisplayName } = useCurrentUser()
const userName = computed(
() => userDisplayName.value?.trim().split(/\s+/)[0] || undefined
)
const rest = createAgentRestClient()
const events = createAgentEventSource(api)
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const bindingStore = useAgentWorkflowTabBindingStore()
const draftStore = useAgentDraftStore()
const agentPanelStore = useAgentPanelStore()
const canvasStore = useCanvasStore()
const selectedNodes = computed<SelectedNode[]>(() =>
canvasStore.selectedItems.filter(isLGraphNode).map((node) => ({
id: String(node.id),
title: node.title || node.type
}))
)
const {
staged: selectionTags,
consume: consumeSelection,
remove: removeSelectionTag,
add: addSelectionTag
} = useCanvasSelection({
selection: selectedNodes,
isLive: () => agentPanelStore.isOpen
})
function viewedGraphNodes() {
return app.canvas?.graph?.nodes ?? app.graph?.nodes ?? []
}
function mentionableNodes(): SelectedNode[] {
return viewedGraphNodes().map((node) => ({
id: String(node.id),
title: node.title || node.type
}))
}
let cloudIdsByName = new Map<string, string>()
async function refreshCloudWorkflowIds(): Promise<void> {
try {
const workflows = await rest.listCloudWorkflows()
const nameCounts = new Map<string, number>()
for (const { name } of workflows) {
if (name !== undefined)
nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1)
}
cloudIdsByName = new Map(
workflows.flatMap(({ id, name }) =>
name !== undefined && nameCounts.get(name) === 1
? [[name, id] as const]
: []
)
)
} catch (error) {
console.warn('[agent] could not refresh cloud workflow ids', error)
}
}
function openSavedTabsNamed(filename: string): ComfyWorkflow[] {
return workflowStore.openWorkflows.filter(
(tab) => !tab.isTemporary && tab.filename === filename
)
}
function cloudIdFor(tab: ComfyWorkflow): string | undefined {
const saved =
!tab.isTemporary && openSavedTabsNamed(tab.filename).length === 1
? cloudIdsByName.get(tab.filename)
: undefined
return saved ?? bindingStore.workflowIdFor(tab.path)
}
let lastKnownGraph: { serialized: string; workflowId: string } | null = null
function reclaimMovedBinding(activePath: string): string | undefined {
if (lastKnownGraph === null) return undefined
const graph = app.graph?.serialize()
if (!graph || JSON.stringify(graph) !== lastKnownGraph.serialized)
return undefined
const { workflowId } = lastKnownGraph
bindingStore.bind(workflowId, activePath)
lastKnownGraph = null
return workflowId
}
function activeWorkflowTurnContext(): WorkflowTurnContext | undefined {
const active = workflowStore.activeWorkflow
if (!active) return undefined
const bound = cloudIdFor(active) ?? reclaimMovedBinding(active.path)
return bound === undefined ? undefined : { id: bound, tabPath: active.path }
}
const activeTab = computed<ActiveTab | null>(() => {
const active = workflowStore.activeWorkflow
return active ? { name: active.filename } : null
})
let lastSentGraph: string | null = null
let snapshotTabPath: string | null = null
function takeWorkflowSnapshot(): DraftUpload | undefined {
const graph = app.graph?.serialize()
if (!graph?.nodes?.length) return undefined
const serialized = JSON.stringify(graph)
const activePath = workflowStore.activeWorkflow?.path ?? null
if (serialized === lastSentGraph && activePath === snapshotTabPath)
return undefined
lastSentGraph = serialized
snapshotTabPath = activePath
return { content: graph, version: draftStore.version }
}
function resetSnapshotGuard(): void {
lastSentGraph = null
snapshotTabPath = null
lastKnownGraph = null
}
function openTabsSnapshot(): OpenTabsSnapshot | undefined {
const openTabs = workflowStore.openWorkflows.flatMap((tab) => {
const workflowId = cloudIdFor(tab)
return workflowId === undefined
? []
: [{ workflow_id: workflowId, name: tab.filename }]
})
if (openTabs.length === 0) return undefined
const active = workflowStore.activeWorkflow
return {
open_tabs: openTabs,
current_tab: active ? cloudIdFor(active) : undefined
}
}
function onWorkflowAdopted(
workflowId: string,
sent: WorkflowTurnContext | undefined,
uploaded: boolean
): void {
if (uploaded && lastSentGraph !== null)
lastKnownGraph = { serialized: lastSentGraph, workflowId }
if (sent !== undefined && sent.id === workflowId) {
bindingStore.bind(workflowId, sent.tabPath)
return
}
if (uploaded && snapshotTabPath !== null)
bindingStore.bind(workflowId, snapshotTabPath)
}
const {
sendMessage,
stopTurn,
newChat,
start,
stop,
entries,
isStreaming,
status,
notices,
threadId,
listThreads,
loadThread
} = useAgentSession({
rest,
events,
workflow: {
current: activeWorkflowTurnContext,
adopted: onWorkflowAdopted,
prepare: refreshCloudWorkflowIds,
snapshot: takeWorkflowSnapshot,
uploadSkipped: resetSnapshotGuard,
tabs: openTabsSnapshot,
activeTab: enqueueActiveTab
}
})
const executionErrorStore = useExecutionErrorStore()
function surfaceAgentError(
type: 'agent_api_failed' | 'agent_draft_apply_failed',
details: string
): void {
executionErrorStore.lastPromptError = {
type,
message: t(`errorCatalog.promptErrors.${type}.desc`),
details
}
executionErrorStore.showErrorOverlay()
}
let noticesSeen = 0
watch(
() => notices.value.length,
(length) => {
for (const notice of notices.value.slice(noticesSeen))
surfaceAgentError('agent_api_failed', notice.text)
noticesSeen = length
}
)
let draftRejectionNotified = false
function surfaceDraftApplyFailure(details: string): void {
console.warn(details)
if (draftRejectionNotified) return
draftRejectionNotified = true
surfaceAgentError('agent_draft_apply_failed', details)
}
const conflictOpen = ref(false)
let applySuppressed = false
let lastApplied: { workflowId: string; version: number } | null = null
let applying = false
let reapplyQueued = false
function boundTabFor(workflowId: string): ComfyWorkflow | null {
const path = bindingStore.tabPathFor(workflowId)
const bound =
path === undefined ? null : workflowStore.getWorkflowByPath(path)
if (bound) return bound
for (const [name, id] of cloudIdsByName) {
if (id !== workflowId) continue
const matches = openSavedTabsNamed(name)
return matches.length === 1 ? matches[0] : null
}
return null
}
function unusedFilenameFor(tab: ComfyWorkflow): string {
const takenByOther = (filename: string) => {
const path =
tab.directory +
'/' +
appendWorkflowJsonExt(filename, tab.initialMode === 'app')
return path !== tab.path && workflowStore.getWorkflowByPath(path) !== null
}
if (!takenByOther(tab.filename)) return tab.filename
let counter = 2
while (takenByOther(`${tab.filename} (${counter})`)) counter++
return `${tab.filename} (${counter})`
}
async function autosaveAppliedDraft(
workflowId: string,
tab: ComfyWorkflow
): Promise<void> {
try {
const saved = tab.isTemporary
? await workflowService.saveWorkflowAs(tab, {
filename: unusedFilenameFor(tab)
})
: await workflowService.saveWorkflow(tab)
if (!saved) console.error(`Agent draft autosave failed for ${tab.path}`)
} catch (error) {
console.error(`Agent draft autosave failed for ${tab.path}:`, error)
} finally {
bindingStore.bind(workflowId, tab.path)
}
}
let activeTabGeneration = 0
let activeTabChain: Promise<void> = Promise.resolve()
const lastRenderedVersions = new Map<string, number>()
function enqueueActiveTab(data: AgentActiveTabData): void {
const generation = ++activeTabGeneration
activeTabChain = activeTabChain.then(() => onAgentActiveTab(data, generation))
}
function agentTabFilename(name: string | undefined): string | undefined {
const cleaned = [
...(name ?? '')
.replace(/[/\\\p{Cc}]/gu, '-')
.replace(/\.json$/i, '')
.trim()
.replace(/^\.+/, '')
]
.slice(0, 80)
.join('')
.replace(/^[\s.]+/u, '')
.trim()
return cleaned.length === 0 ? undefined : `${cleaned}.json`
}
async function fetchDraftSnapshot(
workflowId: string
): Promise<AgentDraftSnapshot | null> {
try {
return await rest.getDraft(workflowId)
} catch (error) {
if (error instanceof AgentApiError && error.status === 404) return null
throw error
}
}
function recordRenderedVersion(nextWorkflowId: string): void {
const leaving = draftStore.workflowId
if (leaving === null || leaving === nextWorkflowId) return
if (lastApplied?.workflowId === leaving)
lastRenderedVersions.set(leaving, lastApplied.version)
else lastRenderedVersions.delete(leaving)
}
async function adoptDraftBase(
workflowId: string,
snapshot: AgentDraftSnapshot,
armVersion: number = snapshot.version
): Promise<void> {
draftStore.bind(workflowId)
await nextTick()
if (
!(
lastApplied?.workflowId === workflowId && lastApplied.version > armVersion
)
)
lastApplied = { workflowId, version: armVersion }
draftStore.adoptSnapshot(snapshot)
}
async function onAgentActiveTab(
data: AgentActiveTabData,
generation: number
): Promise<void> {
const stale = () => generation !== activeTabGeneration
if (stale()) return
try {
recordRenderedVersion(data.workflow_id)
const bound = boundTabFor(data.workflow_id)
if (bound) {
const alreadyCurrent =
draftStore.workflowId === data.workflow_id &&
draftStore.version !== null
await workflowService.openWorkflow(bound)
if (stale()) return
draftStore.bind(data.workflow_id)
const snapshot = await fetchDraftSnapshot(data.workflow_id)
if (stale()) return
if (
snapshot !== null &&
!(alreadyCurrent && (draftStore.version ?? -1) >= snapshot.version)
)
await adoptDraftBase(
data.workflow_id,
snapshot,
lastRenderedVersions.get(data.workflow_id) ?? -1
)
useTelemetry()?.trackAgentWorkflowApplied({
workflow_id: data.workflow_id,
target: 'active_tab_switch'
})
return
}
const snapshot = await fetchDraftSnapshot(data.workflow_id)
if (stale()) return
let validationError = ''
const workflow =
snapshot === null
? null
: await validateComfyWorkflow(snapshot.content, (error) => {
validationError = error
})
if (stale()) return
if (snapshot !== null && !workflow) {
surfaceDraftApplyFailure(validationError)
draftStore.bind(data.workflow_id)
return
}
const tab = workflowStore.createTemporary(
agentTabFilename(data.name),
workflow ?? undefined
)
await workflowService.openWorkflow(tab)
if (stale()) return
await autosaveAppliedDraft(data.workflow_id, tab)
if (stale()) return
if (snapshot === null) draftStore.bind(data.workflow_id)
else await adoptDraftBase(data.workflow_id, snapshot)
useTelemetry()?.trackAgentWorkflowApplied({
workflow_id: data.workflow_id,
target: 'active_tab_open'
})
} catch (error) {
if (stale()) return
draftStore.bind(data.workflow_id)
surfaceAgentError(
'agent_api_failed',
error instanceof Error ? error.message : String(error)
)
}
}
async function loadDraft(
workflowId: string,
version: number,
content: Record<string, unknown>,
tab: ComfyWorkflow | null
): Promise<void> {
const workflow = await validateComfyWorkflow(content, (error) => {
surfaceDraftApplyFailure(error)
})
if (!workflow) return
const openBefore = new Set(workflowStore.openWorkflows.map((w) => w.path))
try {
await app.loadGraphData(workflow, true, true, tab)
draftRejectionNotified = false
lastApplied = { workflowId, version }
const rendered = app.graph?.serialize()
if (rendered)
lastKnownGraph = { serialized: JSON.stringify(rendered), workflowId }
useTelemetry()?.trackAgentWorkflowApplied({
workflow_id: workflowId,
target: tab === null ? 'new_tab' : 'existing_tab'
})
if (tab === null) {
const opened = workflowStore.openWorkflows.find(
(w) => !openBefore.has(w.path)
)
if (opened) {
bindingStore.bind(workflowId, opened.path)
await autosaveAppliedDraft(workflowId, opened)
}
return
}
await autosaveAppliedDraft(workflowId, tab)
} catch (error) {
surfaceDraftApplyFailure(
error instanceof Error ? error.message : String(error)
)
}
}
async function applyDraft(): Promise<void> {
if (applying) {
reapplyQueued = true
return
}
applying = true
try {
const workflowId = draftStore.workflowId
const version = draftStore.version
const content = draftStore.content
if (workflowId === null || version === null || content === null) return
if (applySuppressed) return
if (
lastApplied !== null &&
lastApplied.workflowId === workflowId &&
lastApplied.version >= version
)
return
const nodes = (content as { nodes?: unknown }).nodes
if (!Array.isArray(nodes) || nodes.length === 0) return
const boundTab = boundTabFor(workflowId)
if (boundTab) {
if (workflowStore.activeWorkflow?.path !== boundTab.path) return
if (boundTab.isModified) {
conflictOpen.value = true
return
}
await loadDraft(workflowId, version, content, boundTab)
return
}
await loadDraft(workflowId, version, content, null)
} finally {
applying = false
if (reapplyQueued) {
reapplyQueued = false
void applyDraft()
}
}
}
watch(
() => draftStore.version,
(version) => {
if (version === null || draftStore.content === null) return
void applyDraft()
}
)
watch(
() => workflowStore.activeWorkflow?.path,
() => void applyDraft()
)
watch(
() => draftStore.workflowId,
() => {
lastApplied = null
applySuppressed = false
conflictOpen.value = false
}
)
function onResolveConflict(choice: ConflictChoice): void {
conflictOpen.value = false
const workflowId = draftStore.workflowId
const version = draftStore.version
const content = draftStore.content
if (workflowId === null || version === null || content === null) return
if (choice === 'cancel') {
applySuppressed = true
return
}
if (choice === 'mine') {
lastApplied = { workflowId, version }
return
}
void loadDraft(
workflowId,
version,
content,
choice === 'agent' ? boundTabFor(workflowId) : null
)
}
start()
void refreshCloudWorkflowIds()
onBeforeUnmount(stop)
const history = useAgentChatHistoryStore()
const { copy } = useClipboard({ legacy: true })
function onFeedback(turnId: string, vote: 'up' | 'down' | null): void {
useTelemetry()?.trackAgentMessageFeedback({
message_id: turnId,
vote,
workflow_id: draftStore.workflowId
})
}
function toChatSession(thread: AgentThreadSummary): ChatSession {
const stamp = thread.last_message_at ?? thread.updated_at ?? thread.created_at
const updatedAt = stamp ? Date.parse(stamp) : Date.now()
return {
id: thread.id,
title: thread.title || thread.preview || t('agent.untitledChat'),
updatedAt: Number.isNaN(updatedAt) ? Date.now() : updatedAt
}
}
async function refreshHistory(): Promise<void> {
try {
history.replaceAll((await listThreads()).map(toChatSession))
} catch (error) {
surfaceAgentError(
'agent_api_failed',
error instanceof Error ? error.message : String(error)
)
}
}
watch(threadId, (id) => history.setActive(id), { immediate: true })
void refreshHistory()
async function onSelectHistory(id: string): Promise<void> {
resetSnapshotGuard()
await loadThread(id)
void refreshHistory()
}
function buildTranscriptMarkdown(entries: ConversationEntry[]): string {
return entries
.map((entry) => {
if (entry.role === 'user') return `**You:** ${entry.text}`
const text = entry.parts
.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('')
return `**Agent:** ${text}`
})
.join('\n\n')
}
function onCopyMarkdown(id: string): void {
if (id === history.activeId) void copy(buildTranscriptMarkdown(entries.value))
else toast.add({ severity: 'info', summary: t('agent.copyUnavailable') })
}
const coachStep: CoachStep = {
target: '#agent-panel-root',
title: t('agent.coachTitle'),
body: t('agent.coachBody')
}
function onSend(text: string, attachments: ComposerAttachment[]): void {
applySuppressed = false
void applyDraft()
const nodeTags = consumeSelection()
useTelemetry()?.trackAgentMessageSent({
attachment_count: attachments.length,
node_tag_count: nodeTags.length
})
void sendMessage(text, attachments, nodeTags).then((ok) => {
if (!ok) resetSnapshotGuard()
})
}
function onStop(): void {
void stopTurn()
}
function onNewChat(): void {
resetSnapshotGuard()
newChat()
}
const panelRef = ref<InstanceType<typeof AgentPanel>>()
const fileInput = ref<HTMLInputElement>()
const attachment = useAttachment({
upload: async (file) => ({
ref: (await rest.uploadImage(file, file.name)).name
}),
onError: (message) => surfaceAgentError('agent_api_failed', message),
stage: (staged) => panelRef.value?.addAttachment(staged),
update: (id, patch) => panelRef.value?.updateAttachment(id, patch),
remove: (id) => panelRef.value?.removeAttachment(id)
})
function onAttach(): void {
useTelemetry()?.trackAgentAttachButtonClicked()
fileInput.value?.click()
}
function onMentionPick(node: SelectedNode): void {
const stagedBefore = selectionTags.value.length
addSelectionTag(node)
if (selectionTags.value.length > stagedBefore)
useTelemetry()?.trackAgentNodeTagged({ source: 'mention_picker' })
}
function onClosePanel(): void {
useTelemetry()?.trackAgentCloseButtonClicked()
agentPanelStore.close('close_button')
}
async function onFilesPicked(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const files = input.files
if (files && files.length > 0) await attachment.addFiles(Array.from(files))
input.value = ''
}
</script>
<template>
<div id="agent-panel-root" class="size-full">
<input
ref="fileInput"
type="file"
accept="image/*,video/*"
multiple
class="hidden"
data-testid="agent-file-input"
@change="onFilesPicked"
/>
<AgentPanel
ref="panelRef"
:entries
:user-name="userName"
:streaming="isStreaming"
:submitting="status === 'thinking'"
:can-attach="true"
:is-maximized="agentPanelStore.isMaximized"
:history-groups="history.grouped"
:selection-tags="selectionTags"
:active-tab="activeTab"
:conflict-open="conflictOpen"
:get-mention-nodes="mentionableNodes"
@send="onSend"
@stop="onStop"
@attach="onAttach"
@remove-tag="removeSelectionTag"
@mention-pick="onMentionPick"
@resolve-conflict="onResolveConflict"
@feedback="onFeedback"
@new-chat="onNewChat"
@toggle-size="agentPanelStore.toggleMaximize()"
@close="onClosePanel"
@open-history="refreshHistory()"
@select-history="onSelectHistory"
@delete-history="history.remove($event)"
@copy-history="onCopyMarkdown"
/>
<OnboardingCoach
:step="coachStep"
storage-key="Comfy.AgentPanel.onboarded"
/>
</div>
</template>

View File

@@ -1,7 +0,0 @@
# In-App Agent panel (FE-1187)
The In-App Agent panel is a manager-pattern workbench extension. The panel lives
entirely in this subtree and renders in a flag-gated right dock registered by
`src/extensions/core/agentPanel.ts`, so it shares the host pinia and vue-i18n
instances and wires every host dependency itself (REST client, `/ws` event source,
draft-to-canvas seam).

View File

@@ -1,76 +0,0 @@
@keyframes agent-collapsible-down {
from {
height: 0;
}
to {
height: var(--reka-collapsible-content-height);
}
}
@keyframes agent-collapsible-up {
from {
height: var(--reka-collapsible-content-height);
}
to {
height: 0;
}
}
@keyframes agent-shimmer {
from {
background-position: 150% center;
}
to {
background-position: -50% center;
}
}
.agent-shimmer-text {
background: linear-gradient(
90deg,
var(--color-muted-foreground) 35%,
var(--color-base-foreground) 50%,
var(--color-muted-foreground) 65%
);
background-size: 200% auto;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: agent-shimmer 1.8s linear infinite;
}
.disable-animations .agent-shimmer-text {
animation: none;
color: var(--color-muted-foreground);
-webkit-text-fill-color: currentcolor;
}
@media (prefers-reduced-motion: reduce) {
.agent-shimmer-text {
animation: none;
color: var(--color-muted-foreground);
-webkit-text-fill-color: currentcolor;
}
}
/* Tailwind Preflight is disabled host-wide (PrimeVue + litegraph coexistence), so raw form
elements in the panel inherit UA chrome: a <button> gets the buttonface fill + 2px outset
border, a <textarea>/<input> gets its own border, font and padding. Re-apply Preflight's
normalization, scoped to the panel root and its teleported reka surfaces (dialogs,
dropdowns, drawer carry .agent-scope). :where() holds the selectors at zero specificity so
each element's own bg-* / border utilities still win. */
@layer base {
:where(#agent-panel-root, .agent-scope)
:where(button, [type='button'], [type='reset'], [type='submit']) {
appearance: none;
background-color: transparent;
border: 0 solid;
}
:where(#agent-panel-root, .agent-scope) :where(textarea, input, select) {
appearance: none;
border: 0 solid;
background-color: transparent;
font: inherit;
color: inherit;
}
}

View File

@@ -1,24 +0,0 @@
@theme inline {
--color-agent-surface: var(--color-base-background);
--color-agent-surface-raised: var(--color-secondary-background);
--color-agent-surface-hover: var(--color-secondary-background-hover);
--color-agent-fg: var(--color-base-foreground);
--color-agent-fg-muted: var(--color-muted-foreground);
--color-agent-fg-subtle: var(--color-muted-foreground);
--color-agent-border: var(--color-component-node-border);
--color-agent-border-strong: var(--color-border-default);
--color-agent-accent: var(--color-primary-background);
/* accent-fg: text on the always-blue accent stays white in both themes; no host
on-primary token flips correctly here. Design-review item for FE-1187. */
--color-agent-accent-fg: #ffffff;
--color-agent-danger: var(--color-destructive-background);
--color-agent-success: var(--color-success-background);
--color-agent-pill: #303036;
--radius-agent: 0.75rem;
}
@theme {
--animate-agent-collapsible-down: agent-collapsible-down 200ms ease-out;
--animate-agent-collapsible-up: agent-collapsible-up 200ms ease-out;
}

View File

@@ -1,16 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import ActiveTabStrip from './ActiveTabStrip.vue'
describe('ActiveTabStrip', () => {
it('shows the tab name, and nothing when there is no tab', async () => {
const { rerender } = render(ActiveTabStrip, {
props: { tab: { name: 'portrait.json' } }
})
expect(screen.getByText('portrait.json')).not.toBeNull()
await rerender({ tab: null })
expect(screen.queryByText('portrait.json')).toBeNull()
})
})

View File

@@ -1,17 +0,0 @@
<script setup lang="ts">
export interface ActiveTab {
name: string
}
const { tab } = defineProps<{ tab: ActiveTab | null }>()
</script>
<template>
<span
v-if="tab"
class="text-agent-fg-muted flex min-w-0 items-center gap-1 text-xs"
>
<span class="icon-[lucide--panels-top-left] size-3.5 shrink-0" />
<span class="truncate">{{ tab.name }}</span>
</span>
</template>

View File

@@ -1,189 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ActiveTab } from './ActiveTabStrip.vue'
import type { ComposerAttachment } from '../../composables/agent/useComposer'
import type { SelectedNode } from '../../composables/agent/useCanvasSelection'
import type { ConflictChoice } from './safety/ConflictDialog.vue'
import type { ConversationEntry } from '../../stores/agent/agentConversationStore'
import type { HistoryGroups } from '../../stores/agent/agentChatHistoryStore'
import ChatHistoryScreen from './ChatHistoryScreen.vue'
import Composer from './Composer.vue'
import ConversationView from './ConversationView.vue'
import EmptyState from './EmptyState.vue'
import PanelHeader from './PanelHeader.vue'
import ConflictDialog from './safety/ConflictDialog.vue'
const {
entries,
userName,
streaming = false,
submitting = false,
conflictOpen = false,
canAttach = false,
isMaximized = false,
selectionTags = [],
activeTab = null,
getMentionNodes = () => [],
historyGroups
} = defineProps<{
entries: ConversationEntry[]
userName?: string
streaming?: boolean
submitting?: boolean
conflictOpen?: boolean
canAttach?: boolean
isMaximized?: boolean
selectionTags?: SelectedNode[]
activeTab?: ActiveTab | null
getMentionNodes?: () => SelectedNode[]
historyGroups: HistoryGroups
}>()
const emit = defineEmits<{
send: [text: string, attachments: ComposerAttachment[]]
stop: []
attach: []
removeTag: [id: string]
mentionPick: [node: SelectedNode]
feedback: [turnId: string, vote: 'up' | 'down' | null]
resolveConflict: [choice: ConflictChoice]
newChat: []
toggleSize: []
close: []
openHistory: []
selectHistory: [id: string]
deleteHistory: [id: string]
copyHistory: [id: string]
}>()
const showHistory = ref(false)
function onNewChat(): void {
showHistory.value = false
emit('newChat')
}
function onOpenHistory(): void {
showHistory.value = true
emit('openHistory')
}
function onSelectHistory(id: string): void {
showHistory.value = false
emit('selectHistory', id)
}
const composerRef = ref<InstanceType<typeof Composer>>()
const { t } = useI18n()
const sessionTitle = computed(() => {
const firstUser = entries.find(
(entry): entry is Extract<ConversationEntry, { role: 'user' }> =>
entry.role === 'user'
)
return firstUser?.text.trim().slice(0, 60) || undefined
})
function addAttachment(attachment: ComposerAttachment): void {
composerRef.value?.addAttachment(attachment)
}
function updateAttachment(
id: string,
patch: Partial<ComposerAttachment>
): void {
composerRef.value?.updateAttachment(id, patch)
}
function removeAttachment(id: string): void {
composerRef.value?.removeAttachment(id)
}
defineExpose({ addAttachment, updateAttachment, removeAttachment })
</script>
<template>
<section
class="bg-agent-surface text-agent-fg @container flex h-full flex-col overflow-hidden"
>
<PanelHeader
:is-maximized="isMaximized"
:active-tab="activeTab"
@new-chat="onNewChat"
@toggle-size="emit('toggleSize')"
@close="emit('close')"
/>
<template v-if="showHistory">
<ChatHistoryScreen
:groups="historyGroups"
class="min-h-0 flex-1"
@back="showHistory = false"
@select="onSelectHistory"
@delete="emit('deleteHistory', $event)"
@copy-markdown="emit('copyHistory', $event)"
/>
</template>
<template v-else>
<div class="flex shrink-0 items-center px-2 py-1.5">
<button
v-tooltip.bottom="{
value: t('agent.showChatHistory'),
showDelay: 500
}"
type="button"
class="text-agent-fg-muted hover:bg-agent-surface-hover flex h-6 cursor-pointer items-center gap-1 rounded-sm px-2 text-xs transition-colors"
@click="onOpenHistory"
>
<span class="icon-[lucide--align-justify] size-3.5 shrink-0" />
<span class="max-w-56 truncate">{{
sessionTitle || t('agent.newChatTitle')
}}</span>
</button>
</div>
<div class="min-h-0 flex-1">
<EmptyState
v-if="!entries.length"
:user-name="userName"
@insert="composerRef?.insert($event)"
/>
<ConversationView
v-else
:entries="entries"
@feedback="(id, vote) => emit('feedback', id, vote)"
/>
</div>
</template>
<template v-if="!showHistory">
<footer class="shrink-0 p-4">
<div class="mx-auto flex w-full max-w-[640px] flex-col gap-2.5">
<Composer
ref="composerRef"
:streaming="streaming"
:submitting="submitting"
:can-attach="canAttach"
:selection-tags="selectionTags"
:get-mention-nodes="getMentionNodes"
@send="(text, attachments) => emit('send', text, attachments)"
@stop="emit('stop')"
@attach="emit('attach')"
@remove-tag="emit('removeTag', $event)"
@mention-pick="emit('mentionPick', $event)"
/>
<p class="text-agent-fg-muted my-0 text-center text-xs">
{{ t('agent.caption') }}
</p>
</div>
</footer>
</template>
<ConflictDialog
:open="conflictOpen"
@resolve="emit('resolveConflict', $event)"
/>
</section>
</template>

View File

@@ -1,103 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
ChatSession,
HistoryGroups
} from '../../stores/agent/agentChatHistoryStore'
const { groups } = defineProps<{ groups: HistoryGroups }>()
const emit = defineEmits<{
back: []
select: [id: string]
delete: [id: string]
copyMarkdown: [id: string]
}>()
const { t } = useI18n()
const sections = computed(() =>
(
[
['current', t('agent.historyCurrent'), groups.current],
['today', t('agent.historyToday'), groups.today],
['yesterday', t('agent.historyYesterday'), groups.yesterday],
['earlier', t('agent.historyEarlier'), groups.earlier]
] as const
).filter(([, , items]) => items.length > 0)
)
const isEmpty = computed(() => sections.value.length === 0)
function pick(session: ChatSession): void {
emit('select', session.id)
}
</script>
<template>
<div class="flex h-full flex-col overflow-hidden">
<div class="flex shrink-0 items-center px-2 py-1.5">
<button
v-tooltip.bottom="{ value: t('agent.backToChat'), showDelay: 500 }"
type="button"
class="text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg flex h-6 cursor-pointer items-center gap-1 rounded-sm px-2 text-xs transition-colors"
@click="emit('back')"
>
<span class="icon-[lucide--arrow-left] size-3.5 shrink-0" />
<span>{{ t('agent.history') }}</span>
</button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-2">
<p
v-if="isEmpty"
class="text-agent-fg-muted px-2 py-8 text-center text-sm"
>
{{ t('agent.historyEmpty') }}
</p>
<section v-for="[key, label, items] in sections" :key="key" class="mb-3">
<p class="text-agent-fg-muted my-0 px-2 py-1 text-xs font-medium">
{{ label }}
</p>
<div
v-for="session in items"
:key="session.id"
class="group hover:bg-agent-surface-hover flex items-center gap-2 rounded-md px-2 py-1.5"
>
<button
type="button"
class="text-agent-fg flex min-w-0 flex-1 cursor-pointer items-center gap-2 text-left text-sm"
@click="pick(session)"
>
<span
class="text-agent-fg-muted icon-[lucide--circle-check] size-3.5 shrink-0"
/>
<span class="truncate">{{
session.title || t('agent.untitledChat')
}}</span>
</button>
<div class="hidden shrink-0 items-center gap-0.5 group-hover:flex">
<button
type="button"
class="text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg flex cursor-pointer items-center justify-center rounded-sm p-0.5 transition-colors"
:aria-label="t('agent.copyMarkdown')"
@click="emit('copyMarkdown', session.id)"
>
<span class="icon-[lucide--copy] size-3.5" />
</button>
<button
type="button"
class="text-agent-fg-muted hover:text-agent-danger hover:bg-agent-surface-hover flex cursor-pointer items-center justify-center rounded-sm p-0.5 transition-colors"
:aria-label="t('agent.delete')"
@click="emit('delete', session.id)"
>
<span class="icon-[lucide--trash-2] size-3.5" />
</button>
</div>
</div>
</section>
</div>
</div>
</template>

View File

@@ -1,59 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import type { ComponentProps } from 'vue-component-type-helpers'
import { i18n } from '@/i18n'
import Composer from './Composer.vue'
function mount(props: ComponentProps<typeof Composer> = {}) {
return render(Composer, { props, global: { plugins: [i18n] } })
}
describe('Composer', () => {
it('disables send when empty and enables once text is typed', async () => {
mount()
const send = screen.getByRole('button', { name: 'Send' })
expect(send).toBeDisabled()
await userEvent.type(screen.getByRole('textbox'), 'hello')
expect(send).toBeEnabled()
})
it('emits send with the trimmed text and clears the draft', async () => {
const { emitted } = mount()
const box = screen.getByRole('textbox')
await userEvent.type(box, ' make art ')
await userEvent.click(screen.getByRole('button', { name: 'Send' }))
expect(emitted().send[0]).toEqual(['make art', []])
expect((box as HTMLTextAreaElement).value).toBe('')
})
it('sends on Enter but not on Shift+Enter', async () => {
const { emitted } = mount()
const box = screen.getByRole('textbox')
await userEvent.type(box, 'one{Shift>}{Enter}{/Shift}two')
expect(emitted().send).toBeUndefined()
await userEvent.type(box, '{Enter}')
expect(emitted().send).toHaveLength(1)
})
it('shows Stop while streaming and emits stop instead of send', async () => {
const { emitted } = mount({ streaming: true })
const stop = screen.getByRole('button', { name: 'Stop' })
await userEvent.click(stop)
expect(emitted().stop).toHaveLength(1)
expect(emitted().send).toBeUndefined()
})
it('hides the paperclip by default', () => {
mount()
expect(screen.queryByRole('button', { name: 'Attach a file' })).toBeNull()
})
it('emits attach when the paperclip is clicked and canAttach is set', async () => {
const { emitted } = mount({ canAttach: true })
await userEvent.click(screen.getByRole('button', { name: 'Attach a file' }))
expect(emitted().attach).toHaveLength(1)
})
})

View File

@@ -1,200 +0,0 @@
<script setup lang="ts">
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Textarea from '@/components/ui/textarea/Textarea.vue'
import type { ComposerAttachment } from '../../composables/agent/useComposer'
import { useComposer } from '../../composables/agent/useComposer'
import type { SelectedNode } from '../../composables/agent/useCanvasSelection'
import { cn } from '@comfyorg/tailwind-utils'
import AttachmentChip from './composer/AttachmentChip.vue'
const {
streaming = false,
submitting = false,
canAttach = false,
selectionTags = [],
getMentionNodes = () => []
} = defineProps<{
streaming?: boolean
submitting?: boolean
canAttach?: boolean
selectionTags?: SelectedNode[]
getMentionNodes?: () => SelectedNode[]
}>()
const emit = defineEmits<{
send: [text: string, attachments: ComposerAttachment[]]
stop: []
attach: []
removeTag: [id: string]
mentionPick: [node: SelectedNode]
}>()
const mentionNodes = ref<SelectedNode[]>([])
function onMentionOpen(open: boolean): void {
if (open) mentionNodes.value = getMentionNodes()
}
const { t } = useI18n()
const composer = useComposer({
onSend: (text, attachments) => emit('send', text, attachments),
isStreaming: () => streaming,
onStop: () => emit('stop')
})
function onEnter(event: KeyboardEvent): void {
if (event.isComposing || event.shiftKey) return
event.preventDefault()
composer.submit()
}
defineExpose({
insert: composer.insert,
addAttachment: composer.addAttachment,
updateAttachment: composer.updateAttachment,
removeAttachment: composer.removeAttachment
})
</script>
<template>
<div
class="border-agent-border-strong bg-agent-surface-raised focus-within:border-agent-fg-muted flex flex-col rounded-2xl border transition-colors"
>
<div v-if="selectionTags.length" class="flex flex-wrap gap-1.5 px-4 pt-3">
<span
v-for="tag in selectionTags"
:key="tag.id"
class="rounded-agent bg-agent-pill text-agent-fg inline-flex items-center gap-1.5 py-1 pr-2 pl-1.5 text-xs"
>
<span class="text-agent-fg-subtle icon-[lucide--at-sign] size-3.5" />
<span class="max-w-40 truncate">{{ tag.title }}</span>
<button
type="button"
:aria-label="t('agent.remove')"
class="text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg -my-1 -mr-1 flex size-5 cursor-pointer items-center justify-center rounded-full transition-colors"
@click="emit('removeTag', tag.id)"
>
<span class="icon-[lucide--x] size-3.5" />
</button>
</span>
</div>
<div
v-if="composer.attachments.value.length"
class="flex flex-wrap gap-1.5 px-4 pt-3"
>
<AttachmentChip
v-for="item in composer.attachments.value"
:key="item.id"
:name="item.name"
:preview-url="item.previewUrl"
:uploading="item.uploading"
@remove="composer.removeAttachment(item.id)"
/>
</div>
<Textarea
v-model="composer.draft.value"
:placeholder="t('agent.placeholder')"
rows="1"
class="max-h-48 min-h-20 resize-none rounded-xl bg-transparent px-4 py-3 focus-visible:ring-0"
@keydown.enter="onEnter"
/>
<div class="flex items-center justify-between px-3 py-2">
<div class="flex items-center gap-1">
<button
v-if="canAttach"
type="button"
:aria-label="t('agent.attach')"
class="rounded-agent text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg flex size-8 cursor-pointer items-center justify-center transition-colors"
@click="emit('attach')"
>
<span class="icon-[lucide--paperclip] size-4" />
</button>
<DropdownMenuRoot @update:open="onMentionOpen">
<DropdownMenuTrigger
:aria-label="t('agent.mention')"
class="rounded-agent text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg flex size-8 cursor-pointer items-center justify-center transition-colors"
>
<span class="icon-[lucide--at-sign] size-4" />
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
side="top"
align="start"
:side-offset="4"
class="rounded-agent border-agent-border bg-agent-surface-raised z-1100 max-h-64 w-64 overflow-y-auto border p-1 shadow-lg"
>
<DropdownMenuItem
v-for="node in mentionNodes"
:key="node.id"
class="text-agent-fg data-highlighted:bg-agent-surface-hover rounded-agent flex cursor-pointer items-center gap-1.5 px-2 py-1.5 text-xs outline-none"
@select="emit('mentionPick', node)"
>
<span class="truncate">{{ node.title }}</span>
<span class="text-agent-fg-subtle ml-auto shrink-0">
#{{ node.id }}
</span>
</DropdownMenuItem>
<DropdownMenuItem
v-if="!mentionNodes.length"
disabled
class="text-agent-fg-subtle px-2 py-1.5 text-xs"
>
{{ t('agent.noNodesToMention') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
<div class="flex items-center gap-1">
<button
type="button"
class="text-agent-fg-muted hover:bg-agent-surface-hover flex h-8 cursor-pointer items-center gap-1 rounded-sm px-2 text-xs transition-colors"
:aria-label="t('agent.modelAuto')"
>
<span>{{ t('agent.modelAuto') }}</span>
<span class="icon-[lucide--chevron-down] size-3" />
</button>
<button
type="button"
:aria-label="streaming ? t('agent.stop') : t('agent.send')"
:disabled="!streaming && !composer.canSend.value"
:class="
cn(
'flex size-8 items-center justify-center rounded-xl transition-colors',
streaming
? 'bg-agent-surface-hover text-agent-fg hover:bg-agent-border cursor-pointer'
: 'bg-agent-fg text-agent-surface hover:bg-agent-fg/90 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50'
)
"
@click="composer.submit"
>
<span
:class="
cn(
'size-4',
submitting
? 'icon-[lucide--loader-circle] animate-spin'
: streaming
? 'icon-[lucide--square]'
: 'icon-[lucide--arrow-up]'
)
"
/>
</button>
</div>
</div>
</div>
</template>

View File

@@ -1,123 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as VueUse from '@vueuse/core'
const intersectionCallbacks = vi.hoisted(
() => [] as ((entries: { isIntersecting: boolean }[]) => void)[]
)
vi.mock('@vueuse/core', async (importOriginal) => ({
...(await importOriginal<typeof VueUse>()),
useIntersectionObserver: (
_target: unknown,
callback: (entries: { isIntersecting: boolean }[]) => void
) => {
intersectionCallbacks.push(callback)
return { stop: () => {} }
}
}))
import { i18n } from '@/i18n'
import type { TurnId } from '../../schemas/agentApiSchema'
import { zAgentWsEvent } from '../../schemas/agentApiSchema'
import type { AgentChatEvent } from '../../services/agent/agentEventTransport'
import type { AssistantMessage } from '../../services/agent/agentMessageParts'
import { useAgentConversationStore } from '../../stores/agent/agentConversationStore'
import ConversationView from './ConversationView.vue'
const T = 'msg-1' as TurnId
const chat = (raw: unknown): AgentChatEvent =>
zAgentWsEvent.parse(raw) as AgentChatEvent
const thinking = (id: string, delta: string) =>
chat({
type: 'agent_thinking',
data: { delta, message_id: id, thread_id: 'th' }
})
const delta = (id: string, text: string) =>
chat({
type: 'agent_message_delta',
data: { delta: text, message_id: id, thread_id: 'th' }
})
const toolCall = (id: string, name: string, status: string) =>
chat({
type: 'agent_tool_call',
data: { tool_name: name, status, args: [], message_id: id, thread_id: 'th' }
})
const done = (id: string) =>
chat({
type: 'agent_message_done',
data: { message_id: id, thread_id: 'th', usage: null }
})
const Harness = defineComponent({
components: { ConversationView },
setup() {
const store = useAgentConversationStore()
return { store }
},
template: `<ConversationView :entries="store.entries" user-name="Ada" />`
})
function mountHarness() {
const pinia = createPinia()
setActivePinia(pinia)
const utils = render(Harness, { global: { plugins: [pinia, i18n] } })
return { store: useAgentConversationStore(), ...utils }
}
describe('ConversationView', () => {
beforeEach(() => {
setActivePinia(createPinia())
intersectionCallbacks.length = 0
})
it('wire-driven v1 turn renders user pill, spinner, reasoning-free text, tool group', async () => {
const { store } = mountHarness()
store.recordUser(T, 'make a cat')
store.startTurn(T)
store.ingest(thinking('msg-1', 'pondering'))
expect(await screen.findByText('Thinking...')).toBeInTheDocument()
store.ingest(delta('msg-1', 'Here is a **cat**'))
store.ingest(toolCall('msg-1', 'add_node', 'ok'))
store.ingest(done('msg-1'))
expect(await screen.findByText('make a cat')).toBeInTheDocument()
expect(screen.getByText('Ran 1 tool call')).toBeInTheDocument()
expect(screen.getByText('cat', { selector: 'strong' })).toBeInTheDocument()
expect(store.entries.at(-1)).toMatchObject({
role: 'assistant',
streaming: false
})
})
it('shows a scroll-to-latest pill when scrolled up and returns to bottom on click', async () => {
const assistant: AssistantMessage = {
id: 'msg-1' as TurnId,
role: 'assistant',
parts: [{ type: 'text', text: 'hello', state: 'done' }],
streaming: false,
thinking: false
}
const scrollIntoView = vi.fn()
Element.prototype.scrollIntoView = scrollIntoView
render(ConversationView, {
props: { entries: [assistant] },
global: { plugins: [i18n] }
})
expect(screen.queryByText('Latest')).not.toBeInTheDocument()
for (const cb of intersectionCallbacks) cb([{ isIntersecting: false }])
const pill = await screen.findByRole('button', { name: 'Latest' })
await userEvent.click(pill)
expect(scrollIntoView).toHaveBeenCalled()
})
})

View File

@@ -1,82 +0,0 @@
<script setup lang="ts">
import { useIntersectionObserver } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ConversationEntry } from '../../stores/agent/agentConversationStore'
import AgentMessage from './message/AgentMessage.vue'
import UserMessage from './message/UserMessage.vue'
const { entries } = defineProps<{
entries: ConversationEntry[]
}>()
const emit = defineEmits<{
feedback: [turnId: string, vote: 'up' | 'down' | null]
}>()
const { t } = useI18n()
const bottom = ref<HTMLElement>()
const atBottom = ref(true)
useIntersectionObserver(bottom, ([entry]) => {
atBottom.value = entry?.isIntersecting ?? true
})
function scrollToLatest(): void {
bottom.value?.scrollIntoView({ block: 'end' })
}
const latestContentSignal = computed(() => {
const last = entries.at(-1)
if (!last) return '0'
const size = 'parts' in last ? JSON.stringify(last.parts).length : 0
return `${entries.length}:${size}`
})
watch(
latestContentSignal,
async () => {
if (!atBottom.value) return
await nextTick()
scrollToLatest()
},
{ flush: 'post' }
)
</script>
<template>
<div class="relative h-full">
<div class="h-full overflow-y-auto">
<div class="mx-auto max-w-[640px] p-4">
<div class="flex flex-col gap-4">
<template v-for="entry in entries" :key="`${entry.role}-${entry.id}`">
<UserMessage
v-if="entry.role === 'user'"
:text="entry.text"
:attachments="entry.attachments"
:tags="entry.tags"
/>
<AgentMessage
v-else
:message="entry"
@feedback="emit('feedback', entry.id, $event)"
/>
</template>
<div ref="bottom" />
</div>
</div>
</div>
<button
v-if="!atBottom"
type="button"
class="rounded-agent border-agent-border bg-agent-surface-raised text-agent-fg-muted hover:text-agent-fg absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1 border px-3 py-1.5 text-xs shadow-md transition-colors"
@click="scrollToLatest"
>
<span class="icon-[lucide--arrow-down] size-3.5" />
{{ t('agent.latest') }}
</button>
</div>
</template>

View File

@@ -1,68 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
const { userName } = defineProps<{ userName?: string }>()
const emit = defineEmits<{ insert: [text: string] }>()
const { t, tm } = useI18n()
const prompts = computed(() => tm('agent.suggestedPrompts') as string[])
const promptIcons = [
'icon-[lucide--lightbulb]',
'icon-[lucide--list]',
'icon-[lucide--search]',
'icon-[lucide--message-circle-warning]',
'icon-[lucide--workflow]'
]
</script>
<template>
<div class="flex h-full flex-col p-4">
<div
class="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 p-6 text-center"
>
<div
class="mb-2 flex size-12 items-center justify-center rounded-xl border border-plum-600 bg-ink-700"
>
<span
class="icon-[comfy--comfy-c] size-6 text-brand-yellow drop-shadow-[0_0_12px_currentColor]"
aria-hidden="true"
/>
</div>
<div
class="text-agent-fg flex max-w-sm flex-col items-center gap-2 text-base/snug font-semibold tracking-tight @min-[570px]:text-2xl/snug"
>
<p class="my-0">
{{ t('agent.greeting', { name: userName ?? t('agent.friend') }) }}
</p>
<p class="my-0">
{{ t('agent.greetingQuestion') }}
</p>
</div>
</div>
<div class="flex shrink-0 flex-wrap gap-2 @min-[460px]:justify-center">
<button
v-for="(prompt, index) in prompts"
:key="index"
type="button"
class="bg-agent-surface-raised text-agent-fg hover:bg-agent-surface-hover focus-visible:ring-agent-accent flex h-8 w-full cursor-pointer items-center justify-start gap-2 rounded-full px-3 text-sm whitespace-nowrap transition-colors focus-visible:ring-2 focus-visible:outline-none @min-[460px]:w-auto"
@click="emit('insert', prompt)"
>
<span
:class="
cn(
'text-agent-fg-muted size-3 shrink-0',
promptIcons[index] ?? 'icon-[lucide--sparkles]'
)
"
aria-hidden="true"
/>
<span class="truncate">{{ prompt }}</span>
</button>
</div>
</div>
</template>

View File

@@ -1,84 +0,0 @@
<script setup lang="ts">
import { onKeyStroke, useWindowSize } from '@vueuse/core'
import { ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { CoachStep } from '../../composables/agent/useOnboarding'
import { useOnboarding } from '../../composables/agent/useOnboarding'
const { step, storageKey } = defineProps<{
step: CoachStep
storageKey?: string
}>()
const { t } = useI18n()
const { active, finish } = useOnboarding(storageKey)
onKeyStroke('Escape', () => {
if (active.value) finish()
})
const { width, height } = useWindowSize()
const CARD_W = 256
const CARD_H = 160
const MARGIN = 8
const cardStyle = ref<Record<string, string> | null>(null)
watchEffect(
() => {
cardStyle.value = null
if (!active.value) return
const target = document.querySelector(step.target)
if (!target) return
const rect = target.getBoundingClientRect()
const left = Math.min(
Math.max(MARGIN, rect.left),
Math.max(MARGIN, width.value - CARD_W - MARGIN)
)
const top = Math.min(
Math.max(MARGIN, rect.bottom + MARGIN),
Math.max(MARGIN, height.value - CARD_H - MARGIN)
)
cardStyle.value = { top: `${top}px`, left: `${left}px` }
},
{ flush: 'post' }
)
</script>
<template>
<div
v-if="active && cardStyle"
role="dialog"
aria-modal="true"
:aria-label="step.title"
class="fixed inset-0 z-50"
>
<div class="absolute inset-0 bg-black/40" />
<div
:style="cardStyle"
class="rounded-agent border-agent-border bg-agent-surface-raised text-agent-fg absolute w-64 border p-3 shadow-xl"
>
<p class="text-sm font-semibold">{{ step.title }}</p>
<p class="text-agent-fg-muted mt-1 text-xs">{{ step.body }}</p>
<div class="mt-3 flex justify-end gap-2">
<Button
variant="muted-textonly"
size="md"
class="hover:text-agent-fg focus-visible:ring-agent-accent rounded-xl px-3 text-sm focus-visible:ring-2"
@click="finish"
>{{ t('agent.skip') }}</Button
>
<Button
variant="primary"
size="md"
class="text-agent-accent-fg hover:bg-agent-accent/90 focus-visible:ring-agent-accent rounded-xl px-3 text-sm focus-visible:ring-2"
@click="finish"
>
{{ t('agent.gotIt') }}
</Button>
</div>
</div>
</div>
</template>

View File

@@ -1,80 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import type { ActiveTab } from './ActiveTabStrip.vue'
import ActiveTabStrip from './ActiveTabStrip.vue'
const { isMaximized = false, activeTab = null } = defineProps<{
isMaximized?: boolean
activeTab?: ActiveTab | null
}>()
const emit = defineEmits<{
newChat: []
toggleSize: []
close: []
}>()
const { t } = useI18n()
const sizeToggleIcon = computed(() =>
isMaximized ? 'icon-[lucide--minimize-2]' : 'icon-[lucide--maximize-2]'
)
const sizeToggleLabel = computed(() =>
isMaximized ? t('agent.minimize') : t('agent.maximize')
)
</script>
<template>
<header
class="border-agent-border flex h-12 shrink-0 items-center gap-2 border-b px-4"
>
<h1 class="text-agent-fg my-0 text-sm font-normal whitespace-nowrap">
{{ t('agent.title') }}
</h1>
<span
class="border-agent-border-strong text-agent-fg-muted shrink-0 rounded-full border px-2 py-0.5 text-xs"
>
{{ t('agent.alpha') }}
</span>
<ActiveTabStrip :tab="activeTab" />
<div class="ml-auto flex items-center gap-1">
<Button
v-tooltip.bottom="{ value: t('agent.newChat'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
class="hover:text-agent-fg focus-visible:ring-agent-accent rounded-xl focus-visible:ring-2"
:aria-label="t('agent.newChat')"
@click="emit('newChat')"
>
<span class="icon-[lucide--message-circle-plus] size-4" />
</Button>
<Button
v-tooltip.bottom="{ value: sizeToggleLabel, showDelay: 300 }"
variant="muted-textonly"
size="icon"
class="hover:text-agent-fg focus-visible:ring-agent-accent rounded-xl focus-visible:ring-2"
:aria-label="sizeToggleLabel"
@click="emit('toggleSize')"
>
<span :class="cn(sizeToggleIcon, 'size-4')" />
</Button>
<Button
v-tooltip.bottom="{ value: t('agent.close'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
class="hover:text-agent-fg focus-visible:ring-agent-accent rounded-xl focus-visible:ring-2"
:aria-label="t('agent.close')"
@click="emit('close')"
>
<span class="icon-[lucide--x] size-4" />
</Button>
</div>
</header>
</template>

View File

@@ -1,44 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const {
name,
previewUrl,
uploading = false
} = defineProps<{
name: string
previewUrl?: string
uploading?: boolean
}>()
const emit = defineEmits<{ remove: [] }>()
const { t } = useI18n()
</script>
<template>
<span
class="rounded-agent bg-agent-pill text-agent-fg inline-flex items-center gap-1.5 py-1 pr-2 pl-1 text-xs"
>
<span
v-if="uploading"
:aria-label="t('agent.uploading')"
class="text-agent-fg-subtle icon-[lucide--loader-circle] size-4 animate-spin"
/>
<img
v-else-if="previewUrl"
:src="previewUrl"
:alt="name"
class="size-5 rounded-sm object-cover"
/>
<span v-else class="text-agent-fg-subtle icon-[lucide--paperclip] size-4" />
<span class="max-w-32 truncate">{{ name }}</span>
<button
type="button"
:aria-label="t('agent.remove')"
class="text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg -my-1 -mr-1 flex size-5 cursor-pointer items-center justify-center rounded-full transition-colors"
@click="emit('remove')"
>
<span class="icon-[lucide--x] size-3.5" />
</button>
</span>
</template>

View File

@@ -1,108 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
AssistantMessage,
NoticePart,
TextPart,
ToolPart
} from '../../../services/agent/agentMessageParts'
import { cn } from '@comfyorg/tailwind-utils'
import MarkdownStream from './MarkdownStream.vue'
import MessageFeedback from './MessageFeedback.vue'
import ToolCallGroup from './ToolCallGroup.vue'
const { message } = defineProps<{ message: AssistantMessage }>()
const emit = defineEmits<{ feedback: [vote: 'up' | 'down' | null] }>()
const { t } = useI18n()
type Group =
| { kind: 'text'; part: TextPart }
| { kind: 'notice'; part: NoticePart }
| { kind: 'tools'; parts: ToolPart[] }
const groups = computed<Group[]>(() => {
const out: Group[] = []
for (const part of message.parts) {
const prev = out.at(-1)
if (part.type === 'tool') {
if (prev?.kind === 'tools') prev.parts.push(part)
else out.push({ kind: 'tools', parts: [part] })
} else if (part.type === 'text') {
out.push({ kind: 'text', part })
} else {
out.push({ kind: 'notice', part })
}
}
return out
})
const plainText = computed(() =>
message.parts
.filter((part): part is TextPart => part.type === 'text')
.map((part) => part.text)
.join('\n\n')
)
const showActions = computed(
() => !message.streaming && plainText.value.length > 0
)
const raw = ref(false)
</script>
<template>
<div class="space-y-1.5">
<div
v-if="message.thinking || (message.streaming && !message.parts.length)"
class="text-agent-fg-muted flex items-center gap-1.5 py-1 text-sm"
>
<span class="icon-[lucide--brain] size-3.5 shrink-0" />
<span class="agent-shimmer-text">{{ t('agent.thinking') }}</span>
</div>
<template v-for="(group, index) in groups" :key="index">
<MarkdownStream
v-if="group.kind === 'text'"
:text="group.part.text"
:raw="raw"
/>
<ToolCallGroup v-else-if="group.kind === 'tools'" :tools="group.parts" />
<div
v-else
:class="
cn(
'rounded-agent flex items-start gap-2 border px-3 py-2 text-sm',
group.part.level === 'error'
? 'border-agent-danger/40 text-agent-danger'
: 'border-agent-border text-agent-fg-muted'
)
"
>
<span class="mt-0.5 icon-[lucide--triangle-alert] size-4 shrink-0" />
<span>{{ group.part.text }}</span>
</div>
</template>
<div v-if="showActions" class="flex items-center gap-1">
<MessageFeedback :text="plainText" @feedback="emit('feedback', $event)" />
<button
type="button"
:aria-label="t('agent.toggleRaw')"
:aria-pressed="raw"
:class="
cn(
'rounded-agent text-agent-fg-subtle hover:bg-agent-surface-hover hover:text-agent-fg flex size-7 items-center justify-center transition-colors',
raw && 'text-agent-fg'
)
"
@click="raw = !raw"
>
<span class="icon-[lucide--code] size-4" />
</button>
</div>
</div>
</template>

View File

@@ -1,49 +0,0 @@
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
const { code, lang = 'text' } = defineProps<{
code: string
lang?: string
}>()
const { t } = useI18n()
const { copy, copied } = useClipboard({ copiedDuring: 2000, legacy: true })
</script>
<template>
<div
class="group border-agent-border-strong relative my-2 overflow-hidden rounded-md border"
>
<div
class="border-agent-border-strong bg-agent-surface-hover flex items-center justify-between border-b px-3 py-1.5"
>
<span
class="text-agent-fg-subtle flex items-center gap-1.5 font-mono text-xs"
>
<span class="icon-[lucide--file-code] size-3.5" />
<span class="text-agent-fg font-medium">{{ lang }}</span>
</span>
<button
type="button"
class="text-agent-fg-subtle hover:bg-agent-surface hover:text-agent-fg border-agent-border-strong flex items-center gap-1 rounded-sm border px-2 py-0.5 font-mono text-xs transition-colors"
@click="copy(code)"
>
<span
:class="
cn(
'size-3.5',
copied ? 'icon-[lucide--check]' : 'icon-[lucide--copy]'
)
"
/>
{{ copied ? t('agent.copied') : t('agent.copy') }}
</button>
</div>
<pre
class="text-agent-fg overflow-x-auto p-4 font-mono text-sm"
><code>{{ code }}</code></pre>
</div>
</template>

View File

@@ -1,99 +0,0 @@
// @vitest-environment jsdom
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { i18n } from '@/i18n'
import MarkdownStream from './MarkdownStream.vue'
describe('MarkdownStream', () => {
it('renders markdown prose', () => {
render(MarkdownStream, { props: { text: '**bold**' } })
expect(screen.getByText('bold', { selector: 'strong' })).toBeInTheDocument()
})
it('strips a script tag (XSS guard)', () => {
const { html } = render(MarkdownStream, {
props: { text: 'hi <script>alert(1)</script> there' }
})
expect(html()).not.toContain('<script')
expect(screen.getByText(/hi/)).toBeInTheDocument()
})
it('opens links in a new tab with rel=noopener', () => {
render(MarkdownStream, { props: { text: '[docs](https://example.com)' } })
const link = screen.getByRole('link', { name: 'docs' })
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
it('drops a javascript: url', () => {
const { html } = render(MarkdownStream, {
props: { text: '[x](javascript:alert(1))' }
})
expect(html()).not.toContain('javascript:')
})
it('renders a fenced block as a framed code block with its language and a copy button', () => {
render(MarkdownStream, {
props: { text: 'before\n```python\nprint("hi")\n```\nafter' },
global: { plugins: [i18n] }
})
expect(screen.getByText('python')).toBeInTheDocument()
expect(
screen.getByText('print("hi")', { selector: 'code' })
).toBeInTheDocument()
expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument()
expect(screen.getByText('before')).toBeInTheDocument()
expect(screen.getByText('after')).toBeInTheDocument()
})
it('handles a 4-backtick fence containing a 3-backtick fence', () => {
render(MarkdownStream, {
props: { text: '````md\n```js\ncode\n```\n````' },
global: { plugins: [i18n] }
})
expect(screen.getByText(/```js/, { selector: 'code' })).toBeInTheDocument()
expect(screen.getByText('md')).toBeInTheDocument()
})
it('leaves an inline triple-backtick span mid-sentence as prose', () => {
render(MarkdownStream, {
props: { text: 'use ```npm i``` to install' }
})
expect(screen.getByText('npm i', { selector: 'code' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /copy/i })).toBeNull()
})
it('keeps a 4-space-indented block as prose-rendered code, not a framed block', () => {
render(MarkdownStream, {
props: { text: 'steps:\n\n npm install\n\ndone' },
global: { plugins: [i18n] }
})
expect(
screen.getByText('npm install', { selector: 'code' })
).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /copy/i })).toBeNull()
})
it('labels a fence by the first word of its info string', () => {
render(MarkdownStream, {
props: { text: '```python title=x\nprint("hi")\n```' },
global: { plugins: [i18n] }
})
expect(screen.getByText('python')).toBeInTheDocument()
expect(screen.queryByText(/title=x/)).not.toBeInTheDocument()
})
it('labels a bare fence with no language as text', () => {
render(MarkdownStream, {
props: { text: '```\nplain body\n```' },
global: { plugins: [i18n] }
})
expect(screen.getByText('text')).toBeInTheDocument()
expect(
screen.getByText('plain body', { selector: 'code' })
).toBeInTheDocument()
expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument()
})
})

View File

@@ -1,82 +0,0 @@
<script setup lang="ts">
import { marked } from 'marked'
import { computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import CodeBlock from './CodeBlock.vue'
const { text, raw = false } = defineProps<{
text: string
raw?: boolean
}>()
interface ProseSegment {
type: 'prose'
html: string
}
interface CodeSegment {
type: 'code'
code: string
lang: string
}
type Segment = ProseSegment | CodeSegment
const segments = computed<Segment[]>(() => {
const out: Segment[] = []
let prose = ''
const flushProse = () => {
if (!prose) return
out.push({ type: 'prose', html: renderMarkdownToHtml(prose) })
prose = ''
}
for (const token of marked.lexer(text)) {
if (token.type === 'code' && token.codeBlockStyle !== 'indented') {
flushProse()
out.push({
type: 'code',
code: token.text,
lang: token.lang?.split(/\s+/)[0] || 'text'
})
} else {
prose += token.raw
}
}
flushProse()
return out
})
const proseClass = cn(
'text-agent-fg text-sm/relaxed',
'[&_a]:text-agent-accent [&_a]:cursor-pointer [&_a]:underline',
'[&_p]:my-0 [&_p]:pt-1 [&_strong]:font-semibold',
'[&_h1]:mt-0 [&_h1]:pt-4 [&_h1]:pb-2 [&_h1]:text-2xl [&_h1]:font-semibold',
'[&_h2]:pt-3.5 [&_h2]:pb-1.5 [&_h2]:text-base [&_h2]:font-semibold [&_h3]:pt-2 [&_h3]:font-semibold',
'[&_ol]:my-0 [&_ol]:list-decimal [&_ol]:pt-1 [&_ol]:pb-2 [&_ol]:pl-5',
'[&_ul]:my-0 [&_ul]:list-disc [&_ul]:pt-1 [&_ul]:pb-2 [&_ul]:pl-5',
'[&_:not(pre)>code]:bg-agent-surface-hover [&_:not(pre)>code]:border-agent-border-strong [&_:not(pre)>code]:rounded-sm [&_:not(pre)>code]:border [&_:not(pre)>code]:px-1.5 [&_:not(pre)>code]:py-0.5 [&_:not(pre)>code]:text-[0.875em]',
'[&_blockquote]:border-agent-border-strong [&_blockquote]:text-agent-fg-muted [&_blockquote]:my-2 [&_blockquote]:border-l-[3px] [&_blockquote]:py-1.5 [&_blockquote]:pl-3.5',
'[&_table]:bg-agent-surface-raised [&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_table]:overflow-hidden [&_table]:rounded-lg',
'[&_th]:border-agent-border-strong [&_th]:bg-agent-surface-hover [&_th]:border-b [&_th]:px-4 [&_th]:py-2.5 [&_th]:text-left [&_th]:font-semibold',
'[&_td]:border-agent-border-strong [&_td]:border-b [&_td]:px-4 [&_td]:py-2.5'
)
</script>
<template>
<pre
v-if="raw"
class="rounded-agent bg-agent-surface text-agent-fg overflow-x-auto p-3 text-xs whitespace-pre-wrap"
>{{ text }}</pre
>
<div v-else>
<template v-for="(segment, index) in segments" :key="index">
<CodeBlock
v-if="segment.type === 'code'"
:code="segment.code"
:lang="segment.lang"
/>
<div v-else :class="proseClass" v-html="segment.html" />
</template>
</div>
</template>

View File

@@ -1,68 +0,0 @@
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
const { text } = defineProps<{ text: string }>()
const emit = defineEmits<{ feedback: [vote: 'up' | 'down' | null] }>()
const { t } = useI18n()
const { copy, copied } = useClipboard({ copiedDuring: 2000, legacy: true })
const vote = ref<'up' | 'down' | null>(null)
function setVote(next: 'up' | 'down'): void {
vote.value = vote.value === next ? null : next
emit('feedback', vote.value)
}
</script>
<template>
<div class="text-agent-fg-subtle flex items-center gap-0.5">
<button
type="button"
:aria-label="t('agent.helpful')"
:aria-pressed="vote === 'up'"
:class="
cn(
'rounded-agent hover:bg-agent-surface-hover hover:text-agent-fg flex size-6 items-center justify-center transition-colors',
vote === 'up' && 'text-agent-accent'
)
"
@click="setVote('up')"
>
<span class="icon-[lucide--thumbs-up] size-3.5" />
</button>
<button
type="button"
:aria-label="t('agent.notHelpful')"
:aria-pressed="vote === 'down'"
:class="
cn(
'rounded-agent hover:bg-agent-surface-hover hover:text-agent-fg flex size-6 items-center justify-center transition-colors',
vote === 'down' && 'text-agent-danger'
)
"
@click="setVote('down')"
>
<span class="icon-[lucide--thumbs-down] size-3.5" />
</button>
<button
type="button"
:aria-label="copied ? t('agent.copied') : t('agent.copy')"
class="rounded-agent hover:bg-agent-surface-hover hover:text-agent-fg flex size-6 items-center justify-center transition-colors"
@click="copy(text)"
>
<span
:class="
cn(
'size-3.5',
copied ? 'icon-[lucide--check]' : 'icon-[lucide--copy]'
)
"
/>
</button>
</div>
</template>

View File

@@ -1,61 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { PartState } from '../../../services/agent/agentMessageParts'
import { cn } from '@comfyorg/tailwind-utils'
const {
name,
state,
ok,
count = 1
} = defineProps<{
name: string
state: PartState
ok?: boolean
count?: number
}>()
const { t } = useI18n()
const FRIENDLY_TOOL_KEYS: Record<string, string> = {
new_tab: 'agent.toolOpenedNewTab',
switch_tab: 'agent.toolSwitchedTabs',
remember: 'agent.toolSavedPreference',
forget: 'agent.toolForgotPreference'
}
const friendlyKey = computed(() =>
Object.hasOwn(FRIENDLY_TOOL_KEYS, name) ? FRIENDLY_TOOL_KEYS[name] : undefined
)
const label = computed(() =>
friendlyKey.value === undefined ? name : t(friendlyKey.value)
)
const glyph = computed(() => {
if (state === 'streaming') return 'animate-spin icon-[lucide--loader-circle]'
return ok === false
? 'icon-[lucide--circle-x]'
: 'icon-[lucide--circle-check]'
})
const glyphColor = computed(() => {
if (state === 'streaming') return 'text-agent-fg-subtle'
return ok === false ? 'text-agent-danger' : 'text-agent-success'
})
</script>
<template>
<div class="text-agent-fg flex items-center gap-2 px-3 py-1.5 text-sm">
<span :class="cn('size-4 shrink-0', glyph, glyphColor)" />
<span
:class="cn('truncate text-xs', friendlyKey === undefined && 'font-mono')"
>{{ label }}</span
>
<span v-if="count > 1" class="text-agent-fg-subtle text-xs"
>×{{ count }}</span
>
</div>
</template>

View File

@@ -1,118 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { i18n } from '@/i18n'
import type { TurnId } from '../../../schemas/agentApiSchema'
import type {
AssistantMessage,
ToolPart
} from '../../../services/agent/agentMessageParts'
import AgentMessage from './AgentMessage.vue'
import ToolCallGroup from './ToolCallGroup.vue'
function tool(
callId: string,
name: string,
state: ToolPart['state'],
ok?: boolean
): ToolPart {
return { type: 'tool', callId, name, state, ok }
}
describe('ToolCallGroup', () => {
it('maps tab tools to friendly labels and renders unknown tools by raw name', () => {
render(ToolCallGroup, {
props: {
tools: [
tool('c1', 'new_tab', 'done', true),
tool('c2', 'switch_tab', 'done', true),
tool('c3', 'add_node', 'streaming'),
tool('c4', 'constructor', 'done', true),
tool('c5', 'remember', 'done', true),
tool('c6', 'forget', 'done', true)
]
},
global: { plugins: [i18n] }
})
expect(screen.getByText('Opened a new tab')).toBeInTheDocument()
expect(screen.getByText('Switched tabs')).toBeInTheDocument()
expect(screen.getByText('add_node')).toBeInTheDocument()
expect(screen.getByText('constructor')).toBeInTheDocument()
expect(screen.getByText('Saved a preference')).toBeInTheDocument()
expect(screen.getByText('Forgot a preference')).toBeInTheDocument()
})
it('renders open with the row visible while a call streams', () => {
render(ToolCallGroup, {
props: { tools: [tool('c1', 'add_node', 'streaming')] },
global: { plugins: [i18n] }
})
expect(screen.getByText('Ran 1 tool call')).toBeInTheDocument()
expect(screen.getByText('add_node')).toBeInTheDocument()
})
it('stays open and folds a same-name re-run into the counted row', () => {
render(ToolCallGroup, {
props: {
tools: [
tool('c1', 'add_node', 'done', true),
tool('c2', 'add_node', 'streaming')
]
},
global: { plugins: [i18n] }
})
expect(screen.getByText('Ran 2 tool calls')).toBeInTheDocument()
expect(screen.getAllByText('add_node')).toHaveLength(1)
expect(screen.getByText('×2')).toBeInTheDocument()
})
it('reopens on a failure and folds it into the counted row', async () => {
const { rerender } = render(ToolCallGroup, {
props: { tools: [tool('c1', 'add_node', 'done', true)] },
global: { plugins: [i18n] }
})
expect(screen.queryByText('add_node')).not.toBeInTheDocument()
await rerender({
tools: [
tool('c1', 'add_node', 'done', true),
tool('c2', 'add_node', 'done', false)
]
})
expect(await screen.findByText('add_node')).toBeInTheDocument()
expect(screen.getByText('×2')).toBeInTheDocument()
})
})
describe('AgentMessage tool grouping', () => {
it('groups adjacent tool parts into one pluralized card that opens on click', async () => {
const message: AssistantMessage = {
id: 'msg-1' as TurnId,
role: 'assistant',
parts: [
tool('c1', 'add_node', 'done', true),
tool('c2', 'add_node', 'done', true)
],
streaming: false,
thinking: false
}
render(AgentMessage, { props: { message }, global: { plugins: [i18n] } })
expect(screen.getByText('Ran 2 tool calls')).toBeInTheDocument()
expect(screen.queryByText('add_node')).not.toBeInTheDocument()
await userEvent.click(
screen.getByRole('button', { name: /ran 2 tool calls/i })
)
expect(await screen.findAllByText('add_node')).toHaveLength(1)
expect(screen.getByText('×2')).toBeInTheDocument()
})
})

View File

@@ -1,96 +0,0 @@
<script setup lang="ts">
import {
CollapsibleContent,
CollapsibleRoot,
CollapsibleTrigger
} from 'reka-ui'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ToolPart } from '../../../services/agent/agentMessageParts'
import { cn } from '@comfyorg/tailwind-utils'
import ToolCallCard from './ToolCallCard.vue'
const { tools } = defineProps<{ tools: ToolPart[] }>()
const { t } = useI18n()
interface Row {
name: string
state: ToolPart['state']
ok?: boolean
count: number
}
const rows = computed<Row[]>(() => {
const out: Row[] = []
for (const tool of tools) {
const prev = out.at(-1)
if (prev && prev.name === tool.name) {
prev.count += 1
if (tool.state === 'streaming') prev.state = 'streaming'
if (tool.ok === false) prev.ok = false
} else {
out.push({
name: tool.name,
state: tool.state,
ok: tool.ok,
count: 1
})
}
}
return out
})
const running = computed(() => tools.some((tool) => tool.state === 'streaming'))
const failed = computed(() =>
tools.some((tool) => tool.state === 'done' && tool.ok === false)
)
const open = ref(true)
watch(
() => running.value || failed.value,
(stayOpen) => {
open.value = stayOpen
},
{ immediate: true }
)
</script>
<template>
<CollapsibleRoot v-model:open="open">
<CollapsibleTrigger
class="group text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg flex h-8 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-sm transition-colors"
>
<span
v-if="running"
class="text-agent-fg-subtle icon-[lucide--loader-circle] size-4 shrink-0 animate-spin"
/>
<span
v-else-if="failed"
class="text-agent-danger icon-[lucide--circle-x] size-4 shrink-0"
/>
<span v-else class="icon-[lucide--wrench] size-4 shrink-0" />
<span class="flex-1 text-left">{{
t('agent.ranToolCalls', tools.length)
}}</span>
<span
class="icon-[lucide--chevron-down] size-4 shrink-0 transition-transform group-data-[state=open]:rotate-180"
/>
</CollapsibleTrigger>
<CollapsibleContent
class="data-[state=closed]:animate-agent-collapsible-up data-[state=open]:animate-agent-collapsible-down overflow-hidden"
>
<div :class="cn('flex flex-col gap-0.5 pt-1 pl-2')">
<ToolCallCard
v-for="(row, index) in rows"
:key="index"
:name="row.name"
:state="row.state"
:ok="row.ok"
:count="row.count"
/>
</div>
</CollapsibleContent>
</CollapsibleRoot>
</template>

View File

@@ -1,31 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import UserMessage from './UserMessage.vue'
describe('UserMessage', () => {
it('renders a caption-only placeholder tile for a preview-less attachment', () => {
render(UserMessage, {
props: { text: '', attachments: [{ name: 'clip.bin' }] }
})
expect(screen.getByText('clip.bin')).toBeInTheDocument()
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
it('renders thumbnails for previewable attachments above the text pill', () => {
render(UserMessage, {
props: {
text: 'use these',
attachments: [
{ name: 'a.png', previewUrl: 'blob:a' },
{ name: 'b.png', previewUrl: 'blob:b' }
]
}
})
expect(screen.getByAltText('a.png')).toBeInTheDocument()
expect(screen.getByAltText('b.png')).toBeInTheDocument()
expect(screen.getByText('use these')).toBeInTheDocument()
})
})

View File

@@ -1,60 +0,0 @@
<script setup lang="ts">
import type { UserAttachment } from '../../../stores/agent/agentConversationStore'
const {
text,
attachments = [],
tags = []
} = defineProps<{
text: string
attachments?: UserAttachment[]
tags?: string[]
}>()
</script>
<template>
<div class="flex flex-col items-end gap-1.5">
<div v-if="tags.length" class="flex flex-wrap justify-end gap-1">
<span
v-for="(tag, index) in tags"
:key="`${tag}:${index}`"
class="rounded-agent bg-agent-pill text-agent-fg-muted inline-flex items-center gap-1 px-1.5 py-0.5 text-xs"
>
<span class="icon-[lucide--at-sign] size-3" />
{{ tag }}
</span>
</div>
<div
v-if="attachments.length"
class="grid w-56 max-w-full grid-cols-2 gap-1.5"
>
<figure
v-for="(item, index) in attachments"
:key="`${item.name}:${index}`"
class="m-0"
>
<img
v-if="item.previewUrl"
:src="item.previewUrl"
:alt="item.name"
class="aspect-square w-full rounded-lg object-cover"
/>
<div
v-else
class="bg-agent-surface-raised flex aspect-square w-full items-center justify-center rounded-lg"
>
<span class="text-agent-fg-subtle icon-[lucide--image] size-6" />
</div>
<figcaption class="text-agent-fg-muted mt-0.5 truncate text-xs">
{{ item.name }}
</figcaption>
</figure>
</div>
<div
v-if="text"
class="bg-agent-surface-raised text-agent-fg w-fit max-w-full rounded-lg px-4 py-3 text-xs whitespace-pre-wrap"
>
{{ text }}
</div>
</div>
</template>

View File

@@ -1,112 +0,0 @@
<script setup lang="ts">
import {
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
export type ConflictChoice = 'agent' | 'mine' | 'newtab' | 'cancel'
const { open } = defineProps<{ open: boolean }>()
const emit = defineEmits<{ resolve: [choice: ConflictChoice] }>()
const { t } = useI18n()
function choose(choice: ConflictChoice): void {
emit('resolve', choice)
}
</script>
<template>
<DialogRoot :open="open">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-50 bg-black/60" />
<DialogContent
class="agent-scope rounded-agent border-agent-border bg-agent-surface-raised text-agent-fg fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-1/2 space-y-3 border p-5 shadow-xl focus:outline-none"
>
<div class="flex items-start justify-between gap-2">
<DialogTitle class="text-agent-fg my-0 text-sm font-semibold">
{{ t('agent.conflictTitle') }}
</DialogTitle>
<button
type="button"
:aria-label="t('g.close')"
class="text-agent-fg-subtle hover:text-agent-fg flex size-5 cursor-pointer items-center justify-center"
@click="choose('cancel')"
>
<span class="icon-[lucide--x] size-3.5" />
</button>
</div>
<DialogDescription class="text-agent-fg-muted my-0 text-xs">
{{ t('agent.conflictBody') }}
</DialogDescription>
<div class="flex items-center justify-end gap-2">
<Button
variant="muted-textonly"
size="md"
class="hover:text-agent-fg focus-visible:ring-agent-accent rounded-xl px-3 text-sm focus-visible:ring-2"
@click="choose('cancel')"
>
{{ t('g.cancel') }}
</Button>
<Button
variant="textonly"
size="md"
class="border-agent-border focus-visible:ring-agent-accent rounded-xl border border-solid px-3 text-sm focus-visible:ring-2"
@click="choose('mine')"
>
{{ t('agent.keepMine') }}
</Button>
<div class="flex">
<Button
variant="primary"
size="md"
class="text-agent-accent-fg hover:bg-agent-accent/90 focus-visible:ring-agent-accent rounded-l-xl rounded-r-none px-3 text-sm focus-visible:ring-2"
@click="choose('agent')"
>
{{ t('agent.acceptAgent') }}
</Button>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<Button
variant="primary"
size="md"
class="border-agent-surface/30 text-agent-accent-fg hover:bg-agent-accent/90 focus-visible:ring-agent-accent w-6 rounded-l-none rounded-r-xl border-l border-solid px-0 text-sm focus-visible:ring-2"
:aria-label="t('agent.moreApplyOptions')"
>
<span class="icon-[lucide--chevron-down] size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
side="bottom"
align="end"
:side-offset="4"
class="rounded-agent border-agent-border bg-agent-surface-raised z-1100 border p-1 shadow-lg"
>
<DropdownMenuItem
class="text-agent-fg data-highlighted:bg-agent-surface-hover rounded-agent cursor-pointer px-2 py-1.5 text-xs outline-none"
@select="choose('newtab')"
>
{{ t('agent.openNewTab') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

View File

@@ -1,83 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import {
AGENT_PANEL_FLAG,
createPostHogFlagSource
} from './useAgentFeatureGate'
import type { PostHogLike } from './useAgentFeatureGate'
function fakePostHog(initial: boolean | undefined): {
posthog: PostHogLike
setFlag: (value: boolean | undefined) => void
} {
let value = initial
let listener: (() => void) | undefined
return {
posthog: {
isFeatureEnabled: () => value,
onFeatureFlags: (fn) => {
listener = fn
return () => {
listener = undefined
}
}
},
setFlag: (next) => {
value = next
listener?.()
}
}
}
describe('createPostHogFlagSource', () => {
it('maps an unloaded posthog flag (undefined) to false (fail closed)', () => {
const { posthog } = fakePostHog(undefined)
expect(createPostHogFlagSource(posthog).isEnabled()).toBe(false)
})
it('notifies onChange listeners and reflects flag flips', () => {
const { posthog, setFlag } = fakePostHog(undefined)
const source = createPostHogFlagSource(posthog)
const onChange = vi.fn()
source.onChange?.(onChange)
setFlag(true)
expect(onChange).toHaveBeenCalledTimes(1)
expect(source.isEnabled()).toBe(true)
setFlag(false)
expect(source.isEnabled()).toBe(false)
})
it('unsubscribing stops further notifications', () => {
const { posthog, setFlag } = fakePostHog(undefined)
const source = createPostHogFlagSource(posthog)
const onChange = vi.fn()
const unsubscribe = source.onChange?.(onChange)
unsubscribe?.()
setFlag(true)
expect(onChange).not.toHaveBeenCalled()
})
it('tolerates a void-returning onFeatureFlags', () => {
const posthog: PostHogLike = {
isFeatureEnabled: () => true,
onFeatureFlags: vi.fn()
}
const source = createPostHogFlagSource(posthog)
const unsubscribe = source.onChange?.(() => {})
expect(source.isEnabled()).toBe(true)
expect(() => unsubscribe?.()).not.toThrow()
})
it('queries the agent flag by default', () => {
const isFeatureEnabled = vi.fn(() => true)
const source = createPostHogFlagSource({
isFeatureEnabled,
onFeatureFlags: () => {}
})
expect(source.isEnabled()).toBe(true)
expect(isFeatureEnabled).toHaveBeenCalledWith(AGENT_PANEL_FLAG)
})
})

View File

@@ -1,24 +0,0 @@
export const AGENT_PANEL_FLAG = 'agent-in-app-experience'
export interface AgentFlagSource {
isEnabled(): boolean
onChange?(listener: () => void): () => void
}
export interface PostHogLike {
isFeatureEnabled(flag: string): boolean | undefined
onFeatureFlags(listener: () => void): (() => void) | void
}
export function createPostHogFlagSource(
posthog: PostHogLike,
flag: string = AGENT_PANEL_FLAG
): AgentFlagSource {
return {
isEnabled: () => posthog.isFeatureEnabled(flag) === true,
onChange: (listener) => {
const unsubscribe = posthog.onFeatureFlags(listener)
return typeof unsubscribe === 'function' ? unsubscribe : () => {}
}
}
}

View File

@@ -1,371 +0,0 @@
import { computed, ref } from 'vue'
import { i18n } from '@/i18n'
import type { AgentActiveTabData, TurnId } from '../../schemas/agentApiSchema'
import { isAgentEvent, parseAgentWsEvent } from '../../schemas/agentApiSchema'
import { AgentApiError } from '../../services/agent/agentRestClient'
import type {
AgentRestClient,
DraftUpload,
OpenTabsSnapshot
} from '../../services/agent/agentRestClient'
import { useAgentConversationStore } from '../../stores/agent/agentConversationStore'
import { useAgentDraftStore } from '../../stores/agent/agentDraftStore'
export interface AgentEventSource {
subscribe(listener: (raw: unknown) => void): () => void
onStatus?(listener: (live: boolean) => void): () => void
}
export interface SessionNotice {
level: 'error'
text: string
}
interface SentAttachment {
ref: string
name: string
previewUrl?: string
}
interface SentTag {
id: string
title: string
}
export interface WorkflowTurnContext {
id: string
tabPath: string
}
export interface AgentSessionDeps {
rest: AgentRestClient
events: AgentEventSource
workflow?: {
current(): WorkflowTurnContext | undefined
adopted(
workflowId: string,
sent: WorkflowTurnContext | undefined,
uploaded: boolean
): void
prepare?(): Promise<void>
snapshot?(): DraftUpload | undefined
uploadSkipped?(): void
tabs?(): OpenTabsSnapshot | undefined
activeTab?(data: AgentActiveTabData): void
}
}
const THREAD_STORAGE_KEY = 'Comfy.Agent.ThreadId'
const PREPARE_TIMEOUT_MS = 3000
export function useAgentSession(deps: AgentSessionDeps) {
const { rest, events, workflow } = deps
const conversationStore = useAgentConversationStore()
const draftStore = useAgentDraftStore()
const notices = ref<SessionNotice[]>([])
let resyncing = false
const sending = ref(false)
let localErrorCount = 0
function nextLocalErrorId(): TurnId {
return `local-error-${++localErrorCount}` as TurnId
}
let unsubscribe: (() => void) | null = null
let unsubscribeStatus: (() => void) | null = null
function pushError(text: string): void {
notices.value.push({ level: 'error', text })
}
async function resyncDraft(): Promise<void> {
const id = draftStore.workflowId
if (id === null || resyncing) return
resyncing = true
try {
const snapshot = await rest.getDraft(id)
if (draftStore.workflowId === id) draftStore.adoptSnapshot(snapshot)
} catch (error) {
if (error instanceof AgentApiError) {
if (error.status === 404) return
pushError(error.message)
return
}
pushError(error instanceof Error ? error.message : String(error))
} finally {
resyncing = false
}
}
function start(): void {
unsubscribe = events.subscribe(onRaw)
if (events.onStatus) unsubscribeStatus = events.onStatus(onStatus)
const surviving = conversationStore.threadId
if (surviving !== null) {
const generation = ++loadGeneration
void hydrateFromServer(surviving, () => generation === loadGeneration)
return
}
if (conversationStore.messages.length === 0) {
const stored = localStorage.getItem(THREAD_STORAGE_KEY)
if (stored !== null) {
const generation = ++loadGeneration
conversationStore.setThreadId(stored)
void hydrateFromServer(stored, () => generation === loadGeneration)
}
}
}
async function hydrateFromServer(
threadId: string,
isCurrent: () => boolean = () => true
): Promise<boolean> {
try {
const history = await rest.getMessages(threadId)
if (conversationStore.threadId !== threadId || !isCurrent()) return false
conversationStore.hydrate(history)
return true
} catch (error) {
if (!isCurrent()) return false
if (error instanceof AgentApiError && error.status === 404) {
if (conversationStore.threadId === threadId)
conversationStore.setThreadId(null)
localStorage.removeItem(THREAD_STORAGE_KEY)
return false
}
pushError(error instanceof Error ? error.message : String(error))
return false
}
}
function stop(): void {
unsubscribe?.()
unsubscribeStatus?.()
unsubscribe = null
unsubscribeStatus = null
conversationStore.abortActiveTurn()
conversationStore.dropBackgroundTurns()
}
async function sendMessage(
text: string,
attachments?: SentAttachment[],
tags?: SentTag[]
): Promise<boolean> {
if (sending.value) {
conversationStore.recordFailedSend(
nextLocalErrorId(),
text,
i18n.global.t('agent.sendBusy')
)
return false
}
sending.value = true
if (workflow?.prepare)
await Promise.race([
workflow.prepare().catch(() => undefined),
new Promise<void>((resolve) => setTimeout(resolve, PREPARE_TIMEOUT_MS))
])
const wfContext = workflow?.current()
const upload = workflow?.snapshot?.()
const tabs = workflow?.tabs?.()
function buildInput(draft: DraftUpload | undefined) {
return {
content: text,
tabs,
selection:
tags !== undefined && tags.length > 0
? { node_ids: tags.map((tag) => tag.id) }
: undefined,
attachments: attachments?.map((attachment) => attachment.ref),
draft
}
}
async function post(threadId: string, draft: DraftUpload | undefined) {
const input = buildInput(draft)
return rest.postMessage(
threadId,
wfContext ? { ...input, workflowId: wfContext.id } : input
)
}
let uploaded = upload !== undefined
async function postTurn(threadId: string) {
try {
return await post(threadId, upload)
} catch (error) {
if (!(error instanceof AgentApiError)) throw error
const serverVersion = (error.body as { version?: unknown } | null)
?.version
if (
error.status === 409 &&
upload !== undefined &&
typeof serverVersion === 'number'
) {
return await post(threadId, { ...upload, version: serverVersion })
}
if (upload !== undefined && error.status >= 500) {
console.warn(
'[agent] draft upload rejected by the server, sending without it',
error.message
)
const ack = await post(threadId, undefined)
uploaded = false
workflow?.uploadSkipped?.()
return ack
}
throw error
}
}
try {
const ack = await postTurn(conversationStore.threadId ?? 'new')
conversationStore.setThreadId(ack.thread_id)
localStorage.setItem(THREAD_STORAGE_KEY, ack.thread_id)
if (ack.workflow_id !== undefined) {
draftStore.bind(ack.workflow_id)
workflow?.adopted(ack.workflow_id, wfContext, uploaded)
}
const turnId = ack.message_id as TurnId
conversationStore.recordUser(
turnId,
text,
attachments?.map(({ name, previewUrl }) => ({ name, previewUrl })),
tags?.map((tag) => tag.title)
)
conversationStore.startTurn(turnId)
return true
} catch (error) {
const message =
error instanceof AgentApiError
? error.message
: error instanceof Error
? error.message
: String(error)
conversationStore.recordFailedSend(
nextLocalErrorId(),
text,
`${i18n.global.t('agent.sendFailed')}: ${message}`
)
return false
} finally {
sending.value = false
}
}
async function stopTurn(): Promise<void> {
const threadId = conversationStore.threadId
const turnId = conversationStore.activeTurnId
if (threadId === null || turnId === null) return
try {
await rest.cancelMessage(threadId, turnId)
} catch (error) {
if (error instanceof AgentApiError) {
if (error.status === 409) return
pushError(error.message)
return
}
pushError(error instanceof Error ? error.message : String(error))
}
}
let loadGeneration = 0
function newChat(): void {
loadGeneration++
conversationStore.stashActiveTurn()
conversationStore.reset()
draftStore.reset()
localStorage.removeItem(THREAD_STORAGE_KEY)
}
function listThreads() {
return rest.listThreads()
}
async function loadThread(threadId: string): Promise<void> {
const generation = ++loadGeneration
conversationStore.stashActiveTurn()
draftStore.reset()
conversationStore.setThreadId(threadId)
localStorage.setItem(THREAD_STORAGE_KEY, threadId)
const hydrated = await hydrateFromServer(
threadId,
() => generation === loadGeneration
)
if (hydrated && generation === loadGeneration)
conversationStore.resumeBackgroundTurn()
}
function onRaw(raw: unknown): void {
if (typeof raw !== 'object' || raw === null) return
const type = (raw as { type?: unknown }).type
if (typeof type !== 'string' || !isAgentEvent(type)) return
const parsed = parseAgentWsEvent(raw)
if (!parsed.success) {
const messageId = (raw as { data?: { message_id?: unknown } }).data
?.message_id
if (type === 'agent_message_done') {
if (
typeof messageId !== 'string' ||
messageId === conversationStore.activeTurnId
) {
conversationStore.abortActiveTurn()
pushError(i18n.global.t('agent.malformedEvent'))
} else {
conversationStore.settleBackgroundTurn(messageId)
}
}
console.warn('[agent] dropping malformed agent event', parsed.error)
return
}
const event = parsed.data
switch (event.type) {
case 'draft_patch':
if (
event.data.thread_id === undefined ||
event.data.thread_id === conversationStore.threadId
)
draftStore.applyPatch(event.data)
return
case 'draft_version':
if (draftStore.checkHeartbeat(event.data) === 'behind')
void resyncDraft()
return
case 'agent_active_tab':
if (
event.data.thread_id === undefined ||
event.data.thread_id === conversationStore.threadId
)
workflow?.activeTab?.(event.data)
return
default:
conversationStore.ingest(event)
}
}
function onStatus(live: boolean): void {
if (live) {
void resyncDraft()
return
}
conversationStore.abortActiveTurn()
conversationStore.dropBackgroundTurns()
}
return {
start,
stop,
sendMessage,
stopTurn,
newChat,
listThreads,
loadThread,
entries: computed(() => conversationStore.entries),
status: computed(() => conversationStore.status),
isStreaming: computed(() => conversationStore.isStreaming),
notices: computed(() => notices.value),
threadId: computed(() => conversationStore.threadId)
}
}

View File

@@ -1,118 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { ComposerAttachment } from './useComposer'
import { MAX_ATTACHMENT_BYTES, useAttachment } from './useAttachment'
function fileOfSize(name: string, size: number): File {
const file = new File(['x'], name, { type: 'image/png' })
Object.defineProperty(file, 'size', { value: size })
return file
}
function chipRegistry() {
const chips: ComposerAttachment[] = []
return {
chips,
stage: (attachment: ComposerAttachment) => chips.push(attachment),
update: (id: string, patch: Partial<ComposerAttachment>) => {
const index = chips.findIndex((chip) => chip.id === id)
if (index >= 0) chips[index] = { ...chips[index], ...patch }
},
remove: (id: string) => {
const index = chips.findIndex((chip) => chip.id === id)
if (index >= 0) chips.splice(index, 1)
}
}
}
describe('useAttachment', () => {
it('rejects files over 20MB before staging or uploading', async () => {
const upload = vi.fn()
const onError = vi.fn()
const registry = chipRegistry()
const { addFiles } = useAttachment({ upload, onError, ...registry })
await addFiles([fileOfSize('huge.png', MAX_ATTACHMENT_BYTES + 1)])
expect(registry.chips).toEqual([])
expect(upload).not.toHaveBeenCalled()
expect(onError).toHaveBeenCalledOnce()
expect(onError).toHaveBeenCalledWith('huge.png is larger than 20MB')
})
it('stages an uploading chip immediately, then settles it with the server ref', async () => {
let resolveUpload: (result: { ref: string }) => void = () => {}
const upload = vi.fn(
() =>
new Promise<{ ref: string }>((resolve) => {
resolveUpload = resolve
})
)
const registry = chipRegistry()
const { addFiles } = useAttachment({ upload, ...registry })
const batch = addFiles([fileOfSize('cat.png', 1024)])
expect(registry.chips).toHaveLength(1)
expect(registry.chips[0]).toMatchObject({
name: 'cat.png',
ref: '',
uploading: true
})
expect(registry.chips[0].previewUrl).toBeTruthy()
resolveUpload({ ref: 'uploaded_cat.png' })
await batch
expect(registry.chips[0]).toMatchObject({
ref: 'uploaded_cat.png',
uploading: false
})
})
it('removes the chip and surfaces the error when the upload fails', async () => {
const upload = vi.fn().mockRejectedValue(new Error('network down'))
const onError = vi.fn()
const registry = chipRegistry()
const { addFiles } = useAttachment({ upload, onError, ...registry })
await addFiles([fileOfSize('cat.png', 1024)])
expect(registry.chips).toEqual([])
expect(onError).toHaveBeenCalledOnce()
})
it('keeps earlier settled chips and continues the batch when one upload fails', async () => {
const upload = vi
.fn()
.mockResolvedValueOnce({ ref: 'a.png' })
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValueOnce({ ref: 'c.png' })
const onError = vi.fn()
const registry = chipRegistry()
const { addFiles } = useAttachment({ upload, onError, ...registry })
await addFiles([
fileOfSize('a.png', 10),
fileOfSize('b.png', 10),
fileOfSize('c.png', 10)
])
expect(registry.chips.map((chip) => chip.ref)).toEqual(['a.png', 'c.png'])
expect(registry.chips.every((chip) => chip.uploading === false)).toBe(true)
expect(onError).toHaveBeenCalledWith('b.png could not be uploaded')
})
it('calls preventDefault before doing any work on drop', async () => {
const upload = vi.fn().mockResolvedValue({ ref: 'r' })
const registry = chipRegistry()
const { onDrop } = useAttachment({ upload, ...registry })
const preventDefault = vi.fn()
const event = {
preventDefault,
dataTransfer: { files: [] as unknown as FileList }
} as unknown as DragEvent
await onDrop(event)
expect(preventDefault).toHaveBeenCalledOnce()
})
})

View File

@@ -1,63 +0,0 @@
import { i18n } from '@/i18n'
import type { ComposerAttachment } from './useComposer'
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
const MAX_ATTACHMENT_LABEL = `${MAX_ATTACHMENT_BYTES / 1024 / 1024}MB`
interface UploadResult {
ref: string
url?: string
}
export interface UseAttachmentOptions {
upload: (file: File) => Promise<UploadResult>
onError?: (message: string) => void
stage: (attachment: ComposerAttachment) => void
update: (id: string, patch: Partial<ComposerAttachment>) => void
remove: (id: string) => void
}
let stagedCount = 0
export function useAttachment(options: UseAttachmentOptions) {
async function addFiles(files: Iterable<File>): Promise<void> {
for (const file of files) {
if (file.size > MAX_ATTACHMENT_BYTES) {
options.onError?.(
i18n.global.t('agent.attachmentTooLarge', {
name: file.name,
limit: MAX_ATTACHMENT_LABEL
})
)
continue
}
const id = `upload-${++stagedCount}:${file.name}`
options.stage({
id,
name: file.name,
ref: '',
previewUrl: URL.createObjectURL(file),
uploading: true
})
try {
const result = await options.upload(file)
options.update(id, { ref: result.ref, uploading: false })
} catch {
options.onError?.(
i18n.global.t('agent.attachmentUploadFailed', { name: file.name })
)
options.remove(id)
}
}
}
function onDrop(event: DragEvent): Promise<void> {
event.preventDefault()
const files = event.dataTransfer?.files
return files && files.length > 0
? addFiles(Array.from(files))
: Promise.resolve()
}
return { addFiles, onDrop }
}

View File

@@ -1,57 +0,0 @@
import { ref } from 'vue'
import { describe, expect, it } from 'vitest'
import type { SelectedNode } from './useCanvasSelection'
import { useCanvasSelection } from './useCanvasSelection'
const nodeA: SelectedNode = { id: '1', title: 'Load Checkpoint' }
const nodeB: SelectedNode = { id: '2', title: 'KSampler' }
describe('useCanvasSelection', () => {
it('stages the current selection only while live', () => {
const selection = ref<SelectedNode[]>([nodeA])
const isLive = ref(false)
const { staged } = useCanvasSelection({ selection, isLive })
expect(staged.value).toEqual([])
isLive.value = true
expect(staged.value).toEqual([nodeA])
})
it('clears on submit and does not re-stage the same selection', async () => {
const selection = ref<SelectedNode[]>([nodeA])
const { staged, consume } = useCanvasSelection({
selection,
isLive: ref(true)
})
expect(staged.value).toEqual([nodeA])
expect(consume()).toEqual([nodeA])
expect(staged.value).toEqual([])
selection.value = [nodeA]
await Promise.resolve()
expect(staged.value).toEqual([])
})
it('re-stages when the selection changes', () => {
const selection = ref<SelectedNode[]>([nodeA])
const { staged, consume } = useCanvasSelection({
selection,
isLive: ref(true)
})
consume()
selection.value = [nodeA, nodeB]
expect(staged.value).toEqual([nodeA, nodeB])
})
it('drops a tag on remove but keeps the rest', () => {
const selection = ref<SelectedNode[]>([nodeA, nodeB])
const { staged, remove } = useCanvasSelection({
selection,
isLive: ref(true)
})
remove('1')
expect(staged.value).toEqual([nodeB])
})
})

View File

@@ -1,62 +0,0 @@
import { ref, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
export interface SelectedNode {
id: string
title: string
}
export interface UseCanvasSelectionOptions {
selection: MaybeRefOrGetter<SelectedNode[]>
isLive: MaybeRefOrGetter<boolean>
}
function signature(nodes: SelectedNode[]): string {
return nodes
.map((node) => node.id)
.sort()
.join(',')
}
export function useCanvasSelection(options: UseCanvasSelectionOptions) {
const staged = ref<SelectedNode[]>([])
const consumedSig = ref<string | null>(null)
const stagedSig = ref<string | null>(null)
watch(
() => (toValue(options.isLive) ? toValue(options.selection) : []),
(nodes) => {
if (nodes.length === 0) {
staged.value = []
consumedSig.value = null
stagedSig.value = null
return
}
const sig = signature(nodes)
if (sig === consumedSig.value || sig === stagedSig.value) return
consumedSig.value = null
stagedSig.value = sig
staged.value = [...nodes]
},
{ immediate: true, deep: true, flush: 'sync' }
)
function consume(): SelectedNode[] {
const tags = staged.value
consumedSig.value = signature(toValue(options.selection))
staged.value = []
return tags
}
function remove(id: string): void {
staged.value = staged.value.filter((node) => node.id !== id)
}
function add(node: SelectedNode): void {
if (staged.value.some((tag) => tag.id === node.id)) return
staged.value = [...staged.value, node]
}
return { staged, consume, remove, add }
}

View File

@@ -1,133 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { ComposerAttachment } from './useComposer'
import { useComposer } from './useComposer'
function setup(streaming = false) {
const onSend =
vi.fn<(text: string, attachments: ComposerAttachment[]) => void>()
const onStop = vi.fn()
const composer = useComposer({
onSend,
onStop,
isStreaming: () => streaming
})
return { composer, onSend, onStop }
}
describe('useComposer', () => {
it('submit trims the draft, sends it, and clears draft + attachments', () => {
const { composer, onSend } = setup()
const attachment: ComposerAttachment = {
id: 'a1',
name: 'cat.png',
ref: 'uploaded_cat.png'
}
composer.draft.value = ' make a cat '
composer.addAttachment(attachment)
composer.submit()
expect(onSend).toHaveBeenCalledWith('make a cat', [attachment])
expect(composer.draft.value).toBe('')
expect(composer.attachments.value).toEqual([])
})
it('blocks send while any attachment is uploading, unblocks on settle', () => {
const { composer, onSend } = setup()
composer.draft.value = 'wire it in'
composer.addAttachment({
id: 'u1',
name: 'cat.png',
ref: '',
uploading: true
})
expect(composer.canSend.value).toBe(false)
composer.submit()
expect(onSend).not.toHaveBeenCalled()
composer.updateAttachment('u1', {
ref: 'uploaded_cat.png',
uploading: false
})
expect(composer.canSend.value).toBe(true)
})
it('revokes a dismissed blob preview but never a submitted one', () => {
const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const { composer } = setup()
composer.addAttachment({
id: 'a1',
name: 'a.png',
ref: 'r1',
previewUrl: 'blob:a'
})
composer.removeAttachment('a1')
expect(revoke).toHaveBeenCalledWith('blob:a')
revoke.mockClear()
composer.draft.value = 'send it'
composer.addAttachment({
id: 'a2',
name: 'b.png',
ref: 'r2',
previewUrl: 'blob:b'
})
composer.submit()
expect(revoke).not.toHaveBeenCalled()
revoke.mockRestore()
})
it('allows an attachment-only send with empty text', () => {
const { composer, onSend } = setup()
composer.addAttachment({ id: 'a1', name: 'cat.png', ref: 'r' })
expect(composer.canSend.value).toBe(true)
composer.submit()
expect(onSend).toHaveBeenCalledWith('', [
{ id: 'a1', name: 'cat.png', ref: 'r' }
])
})
it('does not send when there is neither text nor an attachment', () => {
const { composer, onSend } = setup()
composer.draft.value = ' '
expect(composer.canSend.value).toBe(false)
composer.submit()
expect(onSend).not.toHaveBeenCalled()
})
it('routes submit to stop while streaming, without sending', () => {
const { composer, onSend, onStop } = setup(true)
composer.draft.value = 'ignored while streaming'
composer.submit()
expect(onStop).toHaveBeenCalledOnce()
expect(onSend).not.toHaveBeenCalled()
expect(composer.draft.value).toBe('ignored while streaming')
})
it('insert appends to the draft without sending', () => {
const { composer, onSend } = setup()
composer.insert('first')
composer.insert('second')
expect(composer.draft.value).toBe('first second')
expect(onSend).not.toHaveBeenCalled()
})
it('removeAttachment drops the matching staged attachment', () => {
const { composer } = setup()
composer.addAttachment({ id: 'a1', name: 'a.png', ref: 'ra' })
composer.addAttachment({ id: 'a2', name: 'b.png', ref: 'rb' })
composer.removeAttachment('a1')
expect(composer.attachments.value.map((a) => a.id)).toEqual(['a2'])
})
})

View File

@@ -1,72 +0,0 @@
import { computed, ref } from 'vue'
export interface ComposerAttachment {
id: string
name: string
ref: string
previewUrl?: string
uploading?: boolean
}
export interface UseComposerOptions {
onSend: (text: string, attachments: ComposerAttachment[]) => void
isStreaming: () => boolean
onStop: () => void
}
export function useComposer(options: UseComposerOptions) {
const draft = ref('')
const attachments = ref<ComposerAttachment[]>([])
const canSend = computed(
() =>
(draft.value.trim().length > 0 || attachments.value.length > 0) &&
!attachments.value.some((item) => item.uploading)
)
function submit(): void {
if (options.isStreaming()) {
options.onStop()
return
}
if (!canSend.value) return
options.onSend(draft.value.trim(), attachments.value)
draft.value = ''
attachments.value = []
}
function insert(text: string): void {
draft.value = draft.value ? `${draft.value} ${text}` : text
}
function addAttachment(attachment: ComposerAttachment): void {
attachments.value = [...attachments.value, attachment]
}
function updateAttachment(
id: string,
patch: Partial<ComposerAttachment>
): void {
attachments.value = attachments.value.map((item) =>
item.id === id ? { ...item, ...patch } : item
)
}
function removeAttachment(id: string): void {
const removed = attachments.value.find((item) => item.id === id)
if (removed?.previewUrl?.startsWith('blob:'))
URL.revokeObjectURL(removed.previewUrl)
attachments.value = attachments.value.filter((item) => item.id !== id)
}
return {
draft,
attachments,
canSend,
submit,
insert,
addAttachment,
updateAttachment,
removeAttachment
}
}

View File

@@ -1,22 +0,0 @@
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it } from 'vitest'
import { useOnboarding } from './useOnboarding'
const KEY = 'test.onboarded'
describe('useOnboarding', () => {
beforeEach(() => window.localStorage.clear())
it('is active until finished, then persists as seen', async () => {
const first = useOnboarding(KEY)
expect(first.active.value).toBe(true)
first.finish()
expect(first.active.value).toBe(false)
await nextTick()
const second = useOnboarding(KEY)
expect(second.active.value).toBe(false)
})
})

View File

@@ -1,20 +0,0 @@
import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
export interface CoachStep {
target: string
title: string
body: string
}
export function useOnboarding(storageKey = 'Comfy.AgentPanel.onboarded') {
const seen = useStorage(storageKey, false)
const active = computed(() => !seen.value)
function finish(): void {
seen.value = true
}
return { active, finish }
}

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +0,0 @@
{"capturedAt":1783367145226,"frame":{"data":{"delta":"Let me pull the current node list and every settable widget value so I can walk through them precisely.","message_id":"5d7c81a9-31f5-42f8-81c0-7525473da046","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_thinking"}}
{"capturedAt":1783367145346,"frame":{"data":{"delta":"Stopped at your request.","message_id":"5d7c81a9-31f5-42f8-81c0-7525473da046","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_message_delta"}}
{"capturedAt":1783367145351,"frame":{"data":{"message_id":"5d7c81a9-31f5-42f8-81c0-7525473da046","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","usage":null},"type":"agent_message_done"}}

View File

@@ -1,20 +0,0 @@
{"capturedAt":1783366827411,"frame":{"type":"status","data":{"status":{"exec_info":{"queue_remaining":0}},"sid":"c3bf440f-8131-481d-b46d-fffbf799d950"}}}
{"capturedAt":1783366831396,"frame":{"data":{"version":24,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
{"capturedAt":1783366843413,"frame":{"data":{"version":24,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
{"capturedAt":1783366855397,"frame":{"data":{"version":24,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
{"capturedAt":1783366867397,"frame":{"data":{"version":24,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
{"capturedAt":1783366879318,"frame":{"data":{"delta":"I'll find the exact widget addresses first, then edit those two values in place.","message_id":"172a6ede-7ab7-4b01-83b6-5b15f66dee4b","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_thinking"}}
{"capturedAt":1783366881383,"frame":{"data":{"args":["workflow","slots","workflow.json"],"message_id":"172a6ede-7ab7-4b01-83b6-5b15f66dee4b","status":"ok","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","tool_name":"list_slots"},"type":"agent_tool_call"}}
{"capturedAt":1783366883967,"frame":{"data":{"delta":"Let me get the full slot list to find the exact node addresses.","message_id":"172a6ede-7ab7-4b01-83b6-5b15f66dee4b","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_thinking"}}
{"capturedAt":1783366884944,"frame":{"data":{"args":["workflow","ls-nodes","workflow.json"],"message_id":"172a6ede-7ab7-4b01-83b6-5b15f66dee4b","status":"ok","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","tool_name":"ls_nodes"},"type":"agent_tool_call"}}
{"capturedAt":1783366889500,"frame":{"data":{"base_version":24,"content":{"last_node_id":4415264681630046,"links":[[3157652320648586,2356360821950521,1,3610737701189037,0,"CLIP"],[2980960744320733,2356360821950521,1,4415264681630046,0,"CLIP"],[3311070635787049,2356360821950521,0,1906165638342264,0,"MODEL"],[2849829752322859,3610737701189037,0,1906165638342264,1,"CONDITIONING"],[302636556962155,4415264681630046,0,1906165638342264,2,"CONDITIONING"],[2921696816813485,3095725229108875,0,1906165638342264,3,"LATENT"],[4443285092645398,1906165638342264,0,4241707905201179,0,"LATENT"],[3478849875736289,2356360821950521,2,4241707905201179,1,"VAE"],[2519317245573755,4241707905201179,0,28442142756119,0,"IMAGE"]],"nodes":[{"flags":{},"id":2356360821950521,"inputs":[],"mode":0,"order":0,"outputs":[{"links":[3311070635787049],"name":"MODEL","type":"MODEL"},{"links":[3157652320648586,2980960744320733],"name":"CLIP","type":"CLIP"},{"links":[3478849875736289],"name":"VAE","type":"VAE"}],"pos":[100,300],"properties":{},"size":[210,100],"type":"CheckpointLoaderSimple","widgets_values":["sd_xl_base_1.0.safetensors"]},{"flags":{},"id":3610737701189037,"inputs":[{"link":3157652320648586,"name":"clip","type":"CLIP"}],"mode":0,"order":0,"outputs":[{"links":[2849829752322859],"name":"CONDITIONING","type":"CONDITIONING"}],"pos":[450,150],"properties":{},"size":[210,100],"type":"CLIPTextEncode","widgets_values":["a scenic mountain landscape at sunset, highly detailed, sharp focus, vibrant colors"]},{"flags":{},"id":4415264681630046,"inputs":[{"link":2980960744320733,"name":"clip","type":"CLIP"}],"mode":0,"order":0,"outputs":[{"links":[302636556962155],"name":"CONDITIONING","type":"CONDITIONING"}],"pos":[450,400],"properties":{},"size":[210,100],"type":"CLIPTextEncode","widgets_values":["blurry, low quality, distorted, watermark, text"]},{"flags":{},"id":3095725229108875,"inputs":[],"mode":0,"order":0,"outputs":[{"links":[2921696816813485],"name":"LATENT","type":"LATENT"}],"pos":[450,600],"properties":{},"size":[210,100],"type":"EmptyLatentImage","widgets_values":[1024,1024,1]},{"flags":{},"id":1906165638342264,"inputs":[{"link":3311070635787049,"name":"model","type":"MODEL"},{"link":2849829752322859,"name":"positive","type":"CONDITIONING"},{"link":302636556962155,"name":"negative","type":"CONDITIONING"},{"link":2921696816813485,"name":"latent_image","type":"LATENT"}],"mode":0,"order":0,"outputs":[{"links":[4443285092645398],"name":"LATENT","type":"LATENT"}],"pos":[800,300],"properties":{},"size":[210,100],"type":"KSampler","widgets_values":[0,"fixed",12,7,"euler","normal",1]},{"flags":{},"id":4241707905201179,"inputs":[{"link":4443285092645398,"name":"samples","type":"LATENT"},{"link":3478849875736289,"name":"vae","type":"VAE"}],"mode":0,"order":0,"outputs":[{"links":[2519317245573755],"name":"IMAGE","type":"IMAGE"}],"pos":[1150,300],"properties":{},"size":[210,100],"type":"VAEDecode","widgets_values":[]},{"flags":{},"id":28442142756119,"inputs":[{"link":2519317245573755,"name":"images","type":"IMAGE"}],"mode":0,"order":0,"outputs":[{"links":[],"name":"images","type":"IMAGE"}],"pos":[1400,300],"properties":{},"size":[210,100],"type":"SaveImage","widgets_values":["ComfyUI"]}]},"message_id":"172a6ede-7ab7-4b01-83b6-5b15f66dee4b","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","version":25,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_patch"}}
{"capturedAt":1783366889506,"frame":{"data":{"args":["workflow","set-widget","workflow.json","--actor","MjVkuhzz6vSuA19LWfBa9lMQdKv2","--base-version","24","--where","cloud","--","1906165638342264.steps","12"],"message_id":"172a6ede-7ab7-4b01-83b6-5b15f66dee4b","status":"ok","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","tool_name":"set_widget"},"type":"agent_tool_call"}}
{"capturedAt":1783366891336,"frame":{"data":{"base_version":25,"content":{"last_node_id":4415264681630046,"links":[[3157652320648586,2356360821950521,1,3610737701189037,0,"CLIP"],[2980960744320733,2356360821950521,1,4415264681630046,0,"CLIP"],[3311070635787049,2356360821950521,0,1906165638342264,0,"MODEL"],[2849829752322859,3610737701189037,0,1906165638342264,1,"CONDITIONING"],[302636556962155,4415264681630046,0,1906165638342264,2,"CONDITIONING"],[2921696816813485,3095725229108875,0,1906165638342264,3,"LATENT"],[4443285092645398,1906165638342264,0,4241707905201179,0,"LATENT"],[3478849875736289,2356360821950521,2,4241707905201179,1,"VAE"],[2519317245573755,4241707905201179,0,28442142756119,0,"IMAGE"]],"nodes":[{"flags":{},"id":2356360821950521,"inputs":[],"mode":0,"order":0,"outputs":[{"links":[3311070635787049],"name":"MODEL","type":"MODEL"},{"links":[3157652320648586,2980960744320733],"name":"CLIP","type":"CLIP"},{"links":[3478849875736289],"name":"VAE","type":"VAE"}],"pos":[100,300],"properties":{},"size":[210,100],"type":"CheckpointLoaderSimple","widgets_values":["sd_xl_base_1.0.safetensors"]},{"flags":{},"id":3610737701189037,"inputs":[{"link":3157652320648586,"name":"clip","type":"CLIP"}],"mode":0,"order":0,"outputs":[{"links":[2849829752322859],"name":"CONDITIONING","type":"CONDITIONING"}],"pos":[450,150],"properties":{},"size":[210,100],"type":"CLIPTextEncode","widgets_values":["a scenic mountain landscape at sunset, highly detailed, sharp focus, vibrant colors"]},{"flags":{},"id":4415264681630046,"inputs":[{"link":2980960744320733,"name":"clip","type":"CLIP"}],"mode":0,"order":0,"outputs":[{"links":[302636556962155],"name":"CONDITIONING","type":"CONDITIONING"}],"pos":[450,400],"properties":{},"size":[210,100],"type":"CLIPTextEncode","widgets_values":["blurry, low quality, distorted, watermark, text"]},{"flags":{},"id":3095725229108875,"inputs":[],"mode":0,"order":0,"outputs":[{"links":[2921696816813485],"name":"LATENT","type":"LATENT"}],"pos":[450,600],"properties":{},"size":[210,100],"type":"EmptyLatentImage","widgets_values":[1024,1024,1]},{"flags":{},"id":1906165638342264,"inputs":[{"link":3311070635787049,"name":"model","type":"MODEL"},{"link":2849829752322859,"name":"positive","type":"CONDITIONING"},{"link":302636556962155,"name":"negative","type":"CONDITIONING"},{"link":2921696816813485,"name":"latent_image","type":"LATENT"}],"mode":0,"order":0,"outputs":[{"links":[4443285092645398],"name":"LATENT","type":"LATENT"}],"pos":[800,300],"properties":{},"size":[210,100],"type":"KSampler","widgets_values":[0,"fixed",12,6.5,"euler","normal",1]},{"flags":{},"id":4241707905201179,"inputs":[{"link":4443285092645398,"name":"samples","type":"LATENT"},{"link":3478849875736289,"name":"vae","type":"VAE"}],"mode":0,"order":0,"outputs":[{"links":[2519317245573755],"name":"IMAGE","type":"IMAGE"}],"pos":[1150,300],"properties":{},"size":[210,100],"type":"VAEDecode","widgets_values":[]},{"flags":{},"id":28442142756119,"inputs":[{"link":2519317245573755,"name":"images","type":"IMAGE"}],"mode":0,"order":0,"outputs":[{"links":[],"name":"images","type":"IMAGE"}],"pos":[1400,300],"properties":{},"size":[210,100],"type":"SaveImage","widgets_values":["ComfyUI"]}]},"message_id":"172a6ede-7ab7-4b01-83b6-5b15f66dee4b","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","version":26,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_patch"}}
{"capturedAt":1783366891339,"frame":{"data":{"args":["workflow","set-widget","workflow.json","--actor","MjVkuhzz6vSuA19LWfBa9lMQdKv2","--base-version","25","--where","cloud","--","1906165638342264.cfg","6.5"],"message_id":"172a6ede-7ab7-4b01-83b6-5b15f66dee4b","status":"ok","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","tool_name":"set_widget"},"type":"agent_tool_call"}}
{"capturedAt":1783366891396,"frame":{"data":{"version":26,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
{"capturedAt":1783366894953,"frame":{"data":{"delta":"Done — both values updated in place, nothing else touched:\n\n- **Steps:** 20 → **12**\n- **Guidance (CFG):** 7 → **6.5**\n\nThe rest of the graph is unchanged, and I didn't run anything. Let me know when you'd like to generate.","message_id":"172a6ede-7ab7-4b01-83b6-5b15f66dee4b","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_message_delta"}}
{"capturedAt":1783366894960,"frame":{"data":{"message_id":"172a6ede-7ab7-4b01-83b6-5b15f66dee4b","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":35596,"input_tokens":4493,"output_tokens":425,"total_tokens":4918}},"type":"agent_message_done"}}
{"capturedAt":1783366903400,"frame":{"data":{"version":26,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
{"capturedAt":1783366915398,"frame":{"data":{"version":26,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
{"capturedAt":1783366927396,"frame":{"data":{"version":26,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
{"capturedAt":1783366939396,"frame":{"data":{"version":26,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}

View File

@@ -1,185 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
AGENT_WS_EVENT_TYPES,
isAgentEvent,
parseAgentWsEvent,
zAgentCancelAccepted,
zAgentDraftSnapshot,
zAgentError,
zAgentMessage,
zAgentMessages,
zAgentTurnAccepted,
zAgentWsEvent
} from './agentApiSchema'
import type { ZodTypeAny } from 'zod'
const fixtureText = import.meta.glob('./__fixtures__/agent/*.jsonl', {
query: '?raw',
import: 'default',
eager: true
}) as Record<string, string>
function jsonlLines(path: string): unknown[] {
return fixtureText[path]
.split('\n')
.filter((line) => line.trim().length > 0)
.map((line) => JSON.parse(line) as unknown)
}
const wsPaths = Object.keys(fixtureText).filter((path) => path.includes('/ws-'))
const restPath = './__fixtures__/agent/rest-responses.jsonl'
interface WsLine {
frame: { type: string }
}
interface RestLine {
op: string
status: number
body: unknown
}
describe('agentApiSchema fixture gate', () => {
describe('ws frames: every line is a valid agent event or a recognized-foreign frame', () => {
it.for(wsPaths)('%s', (path) => {
const lines = jsonlLines(path) as WsLine[]
lines.forEach((line, index) => {
const { frame } = line
if (isAgentEvent(frame.type)) {
const result = zAgentWsEvent.safeParse(frame)
if (!result.success) {
throw new Error(
`${path} line ${index} (${frame.type}) failed: ${result.error.message}`
)
}
} else {
expect(
zAgentWsEvent.safeParse(frame).success,
`${path} line ${index} (${frame.type}) must stay foreign to the agent union`
).toBe(false)
}
})
})
})
describe('rest responses: each line parses through its op/status schema', () => {
const restLines = jsonlLines(restPath) as RestLine[]
it.for(restLines.map((line, index) => [index, line] as const))(
'line %i',
([index, line]) => {
const schema = restSchemaFor(line)
const result = schema.safeParse(line.body)
if (!result.success) {
throw new Error(
`rest line ${index} (op=${line.op} status=${line.status}) failed: ${result.error.message}`
)
}
}
)
})
})
function restSchemaFor(line: RestLine): ZodTypeAny {
if (line.status >= 400) return zAgentError
if (line.op.startsWith('postMessage')) return zAgentTurnAccepted
if (line.op.startsWith('getMessages')) return zAgentMessages
if (line.op.startsWith('getDraft')) return zAgentDraftSnapshot
if (line.op.startsWith('cancelMessage')) return zAgentCancelAccepted
throw new Error(`no schema mapped for op=${line.op} status=${line.status}`)
}
describe('agentApiSchema contract subtleties', () => {
const doneBase = {
type: 'agent_message_done',
data: { message_id: 'm1', thread_id: 't1' }
}
it('accepts agent_message_done with usage null (cancelled turn)', () => {
expect(
zAgentWsEvent.safeParse({
...doneBase,
data: { ...doneBase.data, usage: null }
}).success
).toBe(true)
})
it('rejects draft_patch missing base_version', () => {
expect(
zAgentWsEvent.safeParse({
type: 'draft_patch',
data: { version: 2, content: {}, workflow_id: 'w1' }
}).success
).toBe(false)
})
it('rejects an unknown event type in the union while isAgentEvent stays false', () => {
expect(zAgentWsEvent.safeParse({ type: 'status', data: {} }).success).toBe(
false
)
expect(isAgentEvent('status')).toBe(false)
})
it('accepts extra additive keys in event data', () => {
const parsed = parseAgentWsEvent({
type: 'draft_version',
data: { version: 5, workflow_id: 'w1', future_field: true }
})
expect(parsed.success).toBe(true)
})
it('exposes exactly the seven agent event types', () => {
expect([...AGENT_WS_EVENT_TYPES].sort()).toEqual(
[
'agent_active_tab',
'agent_message_delta',
'agent_message_done',
'agent_thinking',
'agent_tool_call',
'draft_patch',
'draft_version'
].sort()
)
})
it('parses agent_active_tab with an optional name and rejects a missing workflow_id', () => {
const parsed = zAgentWsEvent.safeParse({
type: 'agent_active_tab',
data: { workflow_id: 'wf-1', thread_id: 'th-1' }
})
expect(parsed.success).toBe(true)
expect(
zAgentWsEvent.safeParse({
type: 'agent_active_tab',
data: { thread_id: 'th-1' }
}).success
).toBe(false)
})
it('accepts an AgentMessage with status interrupted', () => {
expect(
zAgentMessage.safeParse({
id: 'm1',
thread_id: 't1',
seq: 3,
role: 'assistant',
status: 'interrupted',
turn_id: 'x1'
}).success
).toBe(true)
})
it('accepts an AgentMessage with content omitted', () => {
expect(
zAgentMessage.safeParse({
id: 'm1',
thread_id: 't1',
seq: 0,
role: 'user',
status: 'complete',
turn_id: 'x1'
}).success
).toBe(true)
})
})

View File

@@ -1,208 +0,0 @@
import { zWorkflowListResponse } from '@comfyorg/ingest-types/zod'
import { z } from 'zod'
const zTurnId = z.string().brand<'TurnId'>()
export type TurnId = z.infer<typeof zTurnId>
export const zAgentTurnAccepted = z
.object({
thread_id: z.string(),
message_id: z.string(),
workflow_id: z.string().optional()
})
.passthrough()
export type AgentTurnAccepted = z.infer<typeof zAgentTurnAccepted>
export const zAgentMessage = z
.object({
id: z.string(),
thread_id: z.string(),
seq: z.number().int(),
role: z.enum(['user', 'assistant', 'tool', 'system']),
status: z.enum(['streaming', 'complete', 'error', 'interrupted']),
turn_id: z.string(),
content: z.record(z.string(), z.unknown()).optional()
})
.passthrough()
export const zAgentMessages = z.array(zAgentMessage)
export type AgentMessages = z.infer<typeof zAgentMessages>
const zAgentThreadSummary = z
.object({
id: z.string(),
title: z.string(),
preview: z.string().optional(),
last_message_at: z.string().optional(),
updated_at: z.string().optional(),
created_at: z.string().optional()
})
.passthrough()
export type AgentThreadSummary = z.infer<typeof zAgentThreadSummary>
export const zAgentThreads = z
.object({ threads: z.array(zAgentThreadSummary) })
.passthrough()
export const zCloudWorkflowIndex = zWorkflowListResponse
.pick({ pagination: true })
.extend({
data: z.array(
z.object({ id: z.string(), name: z.string().optional() }).passthrough()
)
})
export type CloudWorkflowEntry = z.infer<
typeof zCloudWorkflowIndex
>['data'][number]
export const zAgentCancelAccepted = z.object({
status: z.literal('cancelling')
})
export type AgentCancelAccepted = z.infer<typeof zAgentCancelAccepted>
export const zAgentDraftSnapshot = z.object({
content: z.record(z.string(), z.unknown()),
version: z.number().int()
})
export type AgentDraftSnapshot = z.infer<typeof zAgentDraftSnapshot>
export const zAgentError = z.object({
error: z.string()
})
export const zUploadImageResult = z.object({
name: z.string(),
subfolder: z.string(),
type: z.string()
})
export type UploadImageResult = z.infer<typeof zUploadImageResult>
const zAgentThinkingData = z
.object({
delta: z.string(),
message_id: z.string(),
thread_id: z.string()
})
.passthrough()
const zAgentToolCallData = z
.object({
tool_name: z.string(),
status: z.string(),
args: z.array(z.string()),
message_id: z.string(),
thread_id: z.string()
})
.passthrough()
const zDraftPatchData = z
.object({
base_version: z.number().int(),
version: z.number().int(),
content: z.record(z.string(), z.unknown()),
message_id: z.string().optional(),
thread_id: z.string().optional(),
workflow_id: z.string()
})
.passthrough()
export type DraftPatchData = z.infer<typeof zDraftPatchData>
const zAgentMessageDeltaData = z
.object({
delta: z.string(),
message_id: z.string(),
thread_id: z.string()
})
.passthrough()
const zAgentMessageDoneData = z
.object({
message_id: z.string(),
thread_id: z.string(),
usage: z.unknown().nullish()
})
.passthrough()
const zDraftVersionData = z
.object({
version: z.number().int(),
workflow_id: z.string()
})
.passthrough()
export type DraftVersionData = z.infer<typeof zDraftVersionData>
const zAgentActiveTabData = z
.object({
workflow_id: z.string(),
name: z.string().optional(),
thread_id: z.string().optional(),
message_id: z.string().optional()
})
.passthrough()
export type AgentActiveTabData = z.infer<typeof zAgentActiveTabData>
const zAgentThinkingEvent = z.object({
type: z.literal('agent_thinking'),
data: zAgentThinkingData
})
const zAgentToolCallEvent = z.object({
type: z.literal('agent_tool_call'),
data: zAgentToolCallData
})
const zDraftPatchEvent = z.object({
type: z.literal('draft_patch'),
data: zDraftPatchData
})
const zAgentMessageDeltaEvent = z.object({
type: z.literal('agent_message_delta'),
data: zAgentMessageDeltaData
})
const zAgentMessageDoneEvent = z.object({
type: z.literal('agent_message_done'),
data: zAgentMessageDoneData
})
const zDraftVersionEvent = z.object({
type: z.literal('draft_version'),
data: zDraftVersionData
})
const zAgentActiveTabEvent = z.object({
type: z.literal('agent_active_tab'),
data: zAgentActiveTabData
})
export const zAgentWsEvent = z.discriminatedUnion('type', [
zAgentThinkingEvent,
zAgentToolCallEvent,
zDraftPatchEvent,
zAgentMessageDeltaEvent,
zAgentMessageDoneEvent,
zDraftVersionEvent,
zAgentActiveTabEvent
])
export type AgentWsEvent = z.infer<typeof zAgentWsEvent>
export const AGENT_WS_EVENT_TYPES: ReadonlySet<string> = new Set([
'agent_thinking',
'agent_tool_call',
'draft_patch',
'agent_message_delta',
'agent_message_done',
'draft_version',
'agent_active_tab'
])
export function isAgentEvent(type: string): boolean {
return AGENT_WS_EVENT_TYPES.has(type)
}
export function parseAgentWsEvent(
value: unknown
): z.SafeParseReturnType<unknown, AgentWsEvent> {
return zAgentWsEvent.safeParse(value)
}

View File

@@ -1,97 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { AGENT_WS_EVENT_TYPES } from '../../schemas/agentApiSchema'
import type { AgentEventHost } from './agentEventSource'
import { createAgentEventSource } from './agentEventSource'
function fakeHost(readyState?: number) {
const target = new EventTarget()
const registered = new Set<string>()
const host: AgentEventHost = {
socket: readyState === undefined ? null : { readyState },
addCustomEventListener(type, listener) {
registered.add(type)
target.addEventListener(type, listener as EventListener)
},
removeCustomEventListener(type, listener) {
registered.delete(type)
target.removeEventListener(type, listener as EventListener)
},
addEventListener(type, listener) {
target.addEventListener(type, listener)
},
removeEventListener(type, listener) {
target.removeEventListener(type, listener)
}
}
const emit = (type: string, data?: unknown): void => {
target.dispatchEvent(new CustomEvent(type, { detail: data }))
}
return { host, emit, registered }
}
describe('createAgentEventSource', () => {
it('registers every agent event type and delivers frames as {type, data}', () => {
const { host, emit, registered } = fakeHost()
const seen = vi.fn()
createAgentEventSource(host).subscribe(seen)
expect(registered).toEqual(new Set(AGENT_WS_EVENT_TYPES))
emit('agent_message_delta', { delta: 'hi' })
expect(seen).toHaveBeenCalledWith({
type: 'agent_message_delta',
data: { delta: 'hi' }
})
})
it('stops delivering after unsubscribe', () => {
const { host, emit, registered } = fakeHost()
const seen = vi.fn()
const unsubscribe = createAgentEventSource(host).subscribe(seen)
unsubscribe()
emit('agent_message_delta', { delta: 'late' })
expect(seen).not.toHaveBeenCalled()
expect(registered.size).toBe(0)
})
it('maps reconnecting/reconnected to liveness', () => {
const { host, emit } = fakeHost()
const status = vi.fn()
createAgentEventSource(host).onStatus?.(status)
expect(status).not.toHaveBeenCalled()
emit('reconnecting')
expect(status).toHaveBeenLastCalledWith(false)
emit('reconnected')
expect(status).toHaveBeenLastCalledWith(true)
})
it('reports live once when the socket is already open at bind time', () => {
const { host } = fakeHost(WebSocket.OPEN)
const status = vi.fn()
createAgentEventSource(host).onStatus?.(status)
expect(status).toHaveBeenCalledTimes(1)
expect(status).toHaveBeenCalledWith(true)
})
it('stays quiet for a connecting socket and after status unsubscribe', () => {
const { host, emit } = fakeHost(WebSocket.CONNECTING)
const status = vi.fn()
const unsubscribe = createAgentEventSource(host).onStatus?.(status)
expect(status).not.toHaveBeenCalled()
unsubscribe?.()
emit('reconnected')
expect(status).not.toHaveBeenCalled()
})
})

View File

@@ -1,49 +0,0 @@
import type { AgentEventSource } from '../../composables/agent/useAgentSession'
import { AGENT_WS_EVENT_TYPES } from '../../schemas/agentApiSchema'
export interface AgentEventHost {
socket: { readyState: number } | null
addCustomEventListener(
type: string,
listener: (event: CustomEvent<unknown>) => void
): void
removeCustomEventListener(
type: string,
listener: (event: CustomEvent<unknown>) => void
): void
addEventListener(
type: 'reconnecting' | 'reconnected',
listener: () => void
): void
removeEventListener(
type: 'reconnecting' | 'reconnected',
listener: () => void
): void
}
const OPEN = 1
export function createAgentEventSource(host: AgentEventHost): AgentEventSource {
return {
subscribe(listener) {
const unbinders = [...AGENT_WS_EVENT_TYPES].map((type) => {
const onEvent = (event: CustomEvent<unknown>): void =>
listener({ type, data: event.detail })
host.addCustomEventListener(type, onEvent)
return () => host.removeCustomEventListener(type, onEvent)
})
return () => unbinders.forEach((unbind) => unbind())
},
onStatus(listener) {
const onDown = (): void => listener(false)
const onUp = (): void => listener(true)
host.addEventListener('reconnecting', onDown)
host.addEventListener('reconnected', onUp)
if (host.socket?.readyState === OPEN) listener(true)
return () => {
host.removeEventListener('reconnecting', onDown)
host.removeEventListener('reconnected', onUp)
}
}
}
}

View File

@@ -1,228 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentWsEvent, TurnId } from '../../schemas/agentApiSchema'
import { zAgentWsEvent } from '../../schemas/agentApiSchema'
import type { AgentChatEvent } from './agentEventTransport'
import { createAgentEventTransport } from './agentEventTransport'
import type { AssistantMessage, TextPart, ToolPart } from './agentMessageParts'
import { createAssistantMessage } from './agentMessageParts'
const fixtureText = import.meta.glob(
'../../schemas/__fixtures__/agent/*.jsonl',
{ query: '?raw', import: 'default', eager: true }
) as Record<string, string>
function fixtureFor(name: string): string {
const path = Object.keys(fixtureText).find((p) => p.endsWith(`/${name}`))
if (!path) throw new Error(`fixture not found: ${name}`)
return fixtureText[path]
}
interface WsLine {
frame: unknown
}
function chatEventsFor(fixture: string, messageId: string): AgentChatEvent[] {
const events: AgentChatEvent[] = []
for (const line of fixtureFor(fixture).split('\n')) {
if (!line.trim()) continue
const { frame } = JSON.parse(line) as WsLine
const parsed = zAgentWsEvent.safeParse(frame)
if (!parsed.success) continue
const event = parsed.data
if (!isChatEvent(event)) continue
if (event.data.message_id !== messageId) continue
events.push(event)
}
return events
}
function isChatEvent(event: AgentWsEvent): event is AgentChatEvent {
return (
event.type === 'agent_thinking' ||
event.type === 'agent_tool_call' ||
event.type === 'agent_message_delta' ||
event.type === 'agent_message_done'
)
}
const T = 't1' as TurnId
function drive(events: AgentChatEvent[]): AssistantMessage {
const message = createAssistantMessage(T)
const emit = vi.fn<(m: AssistantMessage) => void>()
const transport = createAgentEventTransport(message, emit)
for (const event of events) transport.ingest(event)
return emit.mock.calls.at(-1)?.[0] ?? message
}
function thinking(delta: string): AgentChatEvent {
return {
type: 'agent_thinking',
data: { delta, message_id: 'm', thread_id: 't' }
}
}
function toolCall(tool_name: string, status: string): AgentChatEvent {
return {
type: 'agent_tool_call',
data: { tool_name, status, args: [], message_id: 'm', thread_id: 't' }
}
}
function delta(text: string): AgentChatEvent {
return {
type: 'agent_message_delta',
data: { delta: text, message_id: 'm', thread_id: 't' }
}
}
const parts = (m: AssistantMessage) => m.parts
const toolParts = (m: AssistantMessage): ToolPart[] =>
m.parts.filter((p): p is ToolPart => p.type === 'tool')
const textParts = (m: AssistantMessage): TextPart[] =>
m.parts.filter((p): p is TextPart => p.type === 'text')
describe('agentEventTransport fixture replay', () => {
it('ws-turn-edit: four settled tools then the reply text', () => {
const events = chatEventsFor(
'ws-turn-edit.jsonl',
'172a6ede-7ab7-4b01-83b6-5b15f66dee4b'
)
const replyText = events
.filter((e) => e.type === 'agent_message_delta')
.map((e) => (e.type === 'agent_message_delta' ? e.data.delta : ''))
.join('')
const message = drive(events)
expect(
toolParts(message).map((p) => ({
name: p.name,
ok: p.ok,
state: p.state
}))
).toEqual([
{ name: 'list_slots', ok: true, state: 'done' },
{ name: 'ls_nodes', ok: true, state: 'done' },
{ name: 'set_widget', ok: true, state: 'done' },
{ name: 'set_widget', ok: true, state: 'done' }
])
const texts = textParts(message)
expect(texts).toHaveLength(1)
expect(texts[0]).toMatchObject({ text: replyText, state: 'done' })
expect(parts(message).at(-1)).toBe(texts[0])
expect(message.streaming).toBe(false)
expect(message.thinking).toBe(false)
})
it('ws-turn-cancelled: one reply text part, settled', () => {
const events = chatEventsFor(
'ws-turn-cancelled.jsonl',
'5d7c81a9-31f5-42f8-81c0-7525473da046'
)
const message = drive(events)
const texts = textParts(message)
expect(texts).toHaveLength(1)
expect(texts[0]).toMatchObject({
text: 'Stopped at your request.',
state: 'done'
})
expect(toolParts(message)).toHaveLength(0)
expect(message.streaming).toBe(false)
})
})
describe('agentEventTransport thinking chip', () => {
it('thinking before any text flips the chip and creates no part', () => {
const message = drive([thinking('planning')])
expect(message.thinking).toBe(true)
expect(message.parts).toHaveLength(0)
})
it('thinking after text does not re-flip the chip', () => {
const message = drive([delta('hello'), thinking('second thought')])
expect(message.thinking).toBe(false)
})
})
describe('agentEventTransport text and tool parts', () => {
it('two deltas append into one text part', () => {
const message = drive([delta('foo '), delta('bar')])
const texts = textParts(message)
expect(texts).toHaveLength(1)
expect(texts[0].text).toBe('foo bar')
})
it('a delta with no prior thinking opens a text part directly', () => {
const message = drive([delta('hi')])
expect(textParts(message)).toHaveLength(1)
expect(textParts(message)[0].text).toBe('hi')
})
it('delta -> tool -> delta yields text, tool, text as three parts', () => {
const message = drive([
delta('before'),
toolCall('run', 'ok'),
delta('after')
])
expect(parts(message).map((p) => p.type)).toEqual(['text', 'tool', 'text'])
expect(textParts(message).map((p) => p.text)).toEqual(['before', 'after'])
})
it('a tool with status error settles ok false, state done', () => {
const message = drive([toolCall('run', 'error')])
expect(toolParts(message)[0]).toMatchObject({
state: 'done',
ok: false
})
})
})
describe('agentEventTransport settle lifecycle', () => {
it('settle mid-stream closes the open text part and clears streaming', () => {
const message = createAssistantMessage(T)
const emit = vi.fn<(m: AssistantMessage) => void>()
const transport = createAgentEventTransport(message, emit)
transport.ingest(delta('partial'))
transport.settle()
const final = emit.mock.calls.at(-1)?.[0] ?? message
expect(textParts(final)[0]).toMatchObject({
text: 'partial',
state: 'done'
})
expect(final.streaming).toBe(false)
expect(final.thinking).toBe(false)
})
it('a second settle is a no-op after the first', () => {
const message = createAssistantMessage(T)
const emit = vi.fn<(m: AssistantMessage) => void>()
const transport = createAgentEventTransport(message, emit)
transport.settle()
const callsAfterFirst = emit.mock.calls.length
transport.settle()
expect(emit.mock.calls.length).toBe(callsAfterFirst)
})
it('events arriving after settle are ignored', () => {
const message = createAssistantMessage(T)
const emit = vi.fn<(m: AssistantMessage) => void>()
const transport = createAgentEventTransport(message, emit)
transport.ingest(delta('final'))
transport.settle()
const callsAfterSettle = emit.mock.calls.length
transport.ingest(delta(' late'))
transport.ingest(toolCall('late_tool', 'ok'))
expect(emit.mock.calls.length).toBe(callsAfterSettle)
expect(textParts(message)[0]).toMatchObject({
text: 'final',
state: 'done'
})
expect(toolParts(message)).toHaveLength(0)
})
})

View File

@@ -1,85 +0,0 @@
import type { AgentWsEvent } from '../../schemas/agentApiSchema'
import type { AssistantMessage, TextPart, ToolPart } from './agentMessageParts'
import { snapshotMessage } from './agentMessageParts'
export type AgentChatEvent = Extract<
AgentWsEvent,
{
type:
| 'agent_thinking'
| 'agent_tool_call'
| 'agent_message_delta'
| 'agent_message_done'
}
>
export interface AgentEventTransport {
ingest: (event: AgentChatEvent) => void
settle: () => void
}
export function createAgentEventTransport(
message: AssistantMessage,
emit: (m: AssistantMessage) => void
): AgentEventTransport {
let openText: TextPart | null = null
let gotText = false
let toolCount = 0
let settled = false
function closeOpenText(): void {
if (openText) {
openText.state = 'done'
openText = null
}
}
function openNewText(): TextPart {
const part: TextPart = { type: 'text', text: '', state: 'streaming' }
message.parts.push(part)
openText = part
return part
}
function ingest(event: AgentChatEvent): void {
if (settled) return
switch (event.type) {
case 'agent_thinking':
if (!gotText) message.thinking = true
break
case 'agent_tool_call': {
closeOpenText()
const part: ToolPart = {
type: 'tool',
callId: `tool_${toolCount++}`,
name: event.data.tool_name,
state: 'done',
ok: event.data.status === 'ok'
}
message.parts.push(part)
break
}
case 'agent_message_delta':
message.thinking = false
gotText = true
;(openText ?? openNewText()).text += event.data.delta
break
case 'agent_message_done':
settle()
return
}
emit(snapshotMessage(message))
}
function settle(): void {
if (settled) return
settled = true
closeOpenText()
message.thinking = false
message.streaming = false
emit(snapshotMessage(message))
}
return { ingest, settle }
}

View File

@@ -1,47 +0,0 @@
import type { TurnId } from '../../schemas/agentApiSchema'
export type PartState = 'streaming' | 'done'
export interface TextPart {
type: 'text'
text: string
state: PartState
}
export interface ToolPart {
type: 'tool'
callId: string
name: string
state: PartState
ok?: boolean
}
export interface NoticePart {
type: 'notice'
level: 'info' | 'warning' | 'error'
text: string
}
type MessagePart = TextPart | ToolPart | NoticePart
export interface AssistantMessage {
id: TurnId
role: 'assistant'
parts: MessagePart[]
streaming: boolean
thinking: boolean
}
export function createAssistantMessage(id: TurnId): AssistantMessage {
return {
id,
role: 'assistant',
parts: [],
streaming: true,
thinking: false
}
}
export function snapshotMessage(message: AssistantMessage): AssistantMessage {
return { ...message, parts: [...message.parts] }
}

View File

@@ -1,235 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const fetchApi = vi.hoisted(() =>
vi.fn<(route: string, init?: RequestInit) => Promise<Response>>()
)
vi.mock('@/scripts/api', () => ({ api: { fetchApi } }))
import { AgentApiError, createAgentRestClient } from './agentRestClient'
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' }
})
}
function respond(response: Response) {
fetchApi.mockResolvedValueOnce(response)
}
function lastCall(): { route: string; init: RequestInit } {
const [route, init] = fetchApi.mock.calls.at(-1)!
return { route, init: init ?? {} }
}
function contentType(init: RequestInit): string | undefined {
return (init.headers as Record<string, string> | undefined)?.['Content-Type']
}
const makeClient = createAgentRestClient
const turnAccepted = {
message_id: 'm1',
thread_id: 't1',
workflow_id: 'w1'
}
beforeEach(() => {
fetchApi.mockReset()
})
describe('agentRestClient route + method', () => {
it('postMessage targets the literal "new" thread path to open a thread', async () => {
respond(jsonResponse(202, turnAccepted))
await makeClient().postMessage('new', { content: 'hi' })
const { route, init } = lastCall()
expect(route).toBe('/agent/threads/new/messages')
expect(init.method).toBe('POST')
})
it('getMessages GETs the thread messages path', async () => {
respond(jsonResponse(200, []))
await makeClient().getMessages('t7')
const { route, init } = lastCall()
expect(route).toBe('/agent/threads/t7/messages')
expect(init.method).toBe('GET')
})
it('cancelMessage POSTs the cancel path with an empty JSON body', async () => {
respond(jsonResponse(202, { status: 'cancelling' }))
await makeClient().cancelMessage('t7', 'm3')
const { route, init } = lastCall()
expect(route).toBe('/agent/threads/t7/messages/m3/cancel')
expect(init.method).toBe('POST')
expect(init.body).toBe('{}')
})
it('listCloudWorkflows GETs the paginated workflows path until has_more is false', async () => {
const page = (data: unknown[], hasMore: boolean) =>
jsonResponse(200, {
data,
pagination: {
offset: 0,
limit: 100,
total: data.length,
has_more: hasMore
}
})
respond(page([{ id: 'wf-1', name: 'one' }], true))
respond(page([{ id: 'wf-2', name: 'two' }], false))
const workflows = await makeClient().listCloudWorkflows()
expect(fetchApi.mock.calls[0][0]).toBe('/workflows?limit=100&offset=0')
expect(fetchApi.mock.calls[1][0]).toBe('/workflows?limit=100&offset=100')
expect(workflows.map((w) => w.id)).toEqual(['wf-1', 'wf-2'])
})
})
describe('getDraft query encoding', () => {
it('encodes a workflow id containing a space', async () => {
respond(jsonResponse(200, { content: {}, version: 1 }))
await makeClient().getDraft('my workflow')
expect(lastCall().route).toBe('/agent/draft?workflow_id=my%20workflow')
})
it('encodes a workflow id containing a slash', async () => {
respond(jsonResponse(200, { content: {}, version: 1 }))
await makeClient().getDraft('a/b')
expect(lastCall().route).toBe('/agent/draft?workflow_id=a%2Fb')
})
})
describe('postMessage wire body', () => {
it('uses snake_case workflow_id and includes only the keys provided', async () => {
respond(jsonResponse(202, turnAccepted))
await makeClient().postMessage('t1', {
content: 'build it',
workflowId: 'wf-9',
selection: { nodeId: 3 },
attachments: ['a1']
})
const { init } = lastCall()
const parsed = JSON.parse(init.body as string) as Record<string, unknown>
expect(parsed).toEqual({
content: 'build it',
workflow_id: 'wf-9',
selection: { nodeId: 3 },
attachments: ['a1']
})
expect(contentType(init)).toBe('application/json')
})
it('omits absent optionals rather than sending them as undefined keys', async () => {
respond(jsonResponse(202, turnAccepted))
await makeClient().postMessage('t1', { content: 'just text' })
const parsed = JSON.parse(lastCall().init.body as string) as Record<
string,
unknown
>
expect(Object.keys(parsed)).toEqual(['content'])
})
})
describe('uploadImage multipart', () => {
it('posts FormData with the blob appended under "image" with the filename, no manual Content-Type', async () => {
respond(jsonResponse(200, { name: 'x.png', subfolder: '', type: 'input' }))
const appendSpy = vi.spyOn(FormData.prototype, 'append')
const blob = new Blob(['bytes'], { type: 'image/png' })
await makeClient().uploadImage(blob, 'x.png')
const { route, init } = lastCall()
expect(route).toBe('/upload/image')
expect(init.method).toBe('POST')
expect(init.body).toBeInstanceOf(FormData)
expect(appendSpy).toHaveBeenCalledWith('image', blob, 'x.png')
expect(contentType(init)).toBeUndefined()
appendSpy.mockRestore()
})
})
describe('success response parsing', () => {
it('parses the postMessage 202 through zAgentTurnAccepted, keeping extra workflow_id', async () => {
respond(jsonResponse(202, turnAccepted))
const result = await makeClient().postMessage('t1', { content: 'hi' })
expect(result.message_id).toBe('m1')
expect(result.thread_id).toBe('t1')
expect((result as Record<string, unknown>).workflow_id).toBe('w1')
})
it('parses a getDraft 200 snapshot', async () => {
respond(jsonResponse(200, { content: { nodes: [] }, version: 24 }))
const result = await makeClient().getDraft('wf-1')
expect(result.version).toBe(24)
expect(result.content).toEqual({ nodes: [] })
})
})
describe('error mapping', () => {
it('maps a plain-string error body to its message with the status and parsed body', async () => {
respond(jsonResponse(409, { error: 'turn is not running' }))
const error = await makeClient()
.cancelMessage('t1', 'm1')
.catch((e: unknown) => e)
expect(error).toBeInstanceOf(AgentApiError)
const apiError = error as AgentApiError
expect(apiError.message).toBe('turn is not running')
expect(apiError.status).toBe(409)
expect(apiError.body).toEqual({ error: 'turn is not running' })
})
it('reads the ingest-shaped {error:{message,type}} nested message', async () => {
respond(
jsonResponse(403, {
error: { message: 'access denied', type: 'forbidden' }
})
)
const error = await makeClient()
.getDraft('wf-x')
.catch((e: unknown) => e)
expect((error as AgentApiError).message).toBe('access denied')
expect((error as AgentApiError).status).toBe(403)
})
it('falls back to statusText and undefined body for a non-JSON error response', async () => {
respond(
new Response('gateway boom', { status: 502, statusText: 'Bad Gateway' })
)
const error = await makeClient()
.getMessages('t1')
.catch((e: unknown) => e)
const apiError = error as AgentApiError
expect(apiError.message).toBe('Bad Gateway')
expect(apiError.status).toBe(502)
expect(apiError.body).toBeUndefined()
})
it('throws zod when a success body violates the response schema (anti-drift)', async () => {
respond(jsonResponse(200, { wrong: 'shape' }))
const error = await makeClient()
.getDraft('wf-1')
.catch((e: unknown) => e)
expect(error).toBeInstanceOf(Error)
expect(error).not.toBeInstanceOf(AgentApiError)
})
})

View File

@@ -1,216 +0,0 @@
import type { z } from 'zod'
import { api } from '@/scripts/api'
import {
zAgentCancelAccepted,
zAgentDraftSnapshot,
zAgentError,
zAgentMessages,
zAgentThreads,
zAgentTurnAccepted,
zCloudWorkflowIndex,
zUploadImageResult
} from '../../schemas/agentApiSchema'
import type {
AgentCancelAccepted,
AgentDraftSnapshot,
AgentMessages,
AgentThreadSummary,
AgentTurnAccepted,
CloudWorkflowEntry,
UploadImageResult
} from '../../schemas/agentApiSchema'
const CLOUD_WORKFLOW_PAGE_SIZE = 100
const CLOUD_WORKFLOW_MAX_PAGES = 5
export class AgentApiError extends Error {
readonly status: number
readonly body: unknown
constructor(message: string, status: number, body: unknown) {
super(message)
this.name = 'AgentApiError'
this.status = status
this.body = body
}
}
export interface DraftUpload {
content: unknown
version: number | null
}
interface OpenTabEntry {
workflow_id: string
name: string
}
export interface OpenTabsSnapshot {
open_tabs: OpenTabEntry[]
current_tab?: string
}
export interface PostMessageInput {
content: string
workflowId?: string
selection?: Record<string, unknown>
attachments?: string[]
draft?: DraftUpload
tabs?: OpenTabsSnapshot
}
interface IngestErrorBody {
error: { message: string }
}
function isIngestErrorBody(body: unknown): body is IngestErrorBody {
if (typeof body !== 'object' || body === null) return false
const { error } = body as { error?: unknown }
return (
typeof error === 'object' &&
error !== null &&
typeof (error as { message?: unknown }).message === 'string'
)
}
export function createAgentRestClient() {
async function toApiError(response: Response): Promise<AgentApiError> {
const text = await response.text()
let body: unknown
try {
body = text.length > 0 ? JSON.parse(text) : undefined
} catch {
body = undefined
}
const plain = zAgentError.safeParse(body)
const message = plain.success
? plain.data.error
: isIngestErrorBody(body)
? body.error.message
: response.statusText
return new AgentApiError(message, response.status, body)
}
async function request<T>(
route: string,
init: RequestInit,
schema: z.ZodType<T>
): Promise<T> {
const response = await api.fetchApi(route, init)
if (!response.ok) throw await toApiError(response)
return schema.parse(await response.json())
}
function jsonInit(method: string, body: unknown): RequestInit {
return {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}
}
async function postMessage(
threadId: string,
req: PostMessageInput
): Promise<AgentTurnAccepted> {
const body: Record<string, unknown> = { content: req.content }
if (req.workflowId !== undefined) body.workflow_id = req.workflowId
if (req.tabs !== undefined) {
body.open_tabs = req.tabs.open_tabs
if (req.tabs.current_tab !== undefined)
body.current_tab = req.tabs.current_tab
}
if (req.selection !== undefined) body.selection = req.selection
if (req.attachments !== undefined) body.attachments = req.attachments
if (req.draft !== undefined) body.draft = req.draft
return request(
`/agent/threads/${threadId}/messages`,
jsonInit('POST', body),
zAgentTurnAccepted
)
}
async function getMessages(threadId: string): Promise<AgentMessages> {
return request(
`/agent/threads/${threadId}/messages`,
{ method: 'GET' },
zAgentMessages
)
}
async function listThreads(): Promise<AgentThreadSummary[]> {
const page = await request(
'/agent/threads',
{ method: 'GET' },
zAgentThreads
)
return page.threads
}
async function listCloudWorkflows(): Promise<CloudWorkflowEntry[]> {
const entries: CloudWorkflowEntry[] = []
let hasMore = false
for (let page = 0; page < CLOUD_WORKFLOW_MAX_PAGES; page++) {
const result = await request(
`/workflows?limit=${CLOUD_WORKFLOW_PAGE_SIZE}&offset=${page * CLOUD_WORKFLOW_PAGE_SIZE}`,
{ method: 'GET' },
zCloudWorkflowIndex
)
entries.push(...result.data)
hasMore = result.pagination.has_more
if (!hasMore) break
}
if (hasMore)
console.warn(
`[agent] cloud workflow index truncated at ${entries.length} entries`
)
return entries
}
async function cancelMessage(
threadId: string,
messageId: string
): Promise<AgentCancelAccepted> {
return request(
`/agent/threads/${threadId}/messages/${messageId}/cancel`,
jsonInit('POST', {}),
zAgentCancelAccepted
)
}
async function getDraft(workflowId: string): Promise<AgentDraftSnapshot> {
const query = encodeURIComponent(workflowId)
return request(
`/agent/draft?workflow_id=${query}`,
{ method: 'GET' },
zAgentDraftSnapshot
)
}
async function uploadImage(
image: Blob,
filename: string
): Promise<UploadImageResult> {
const form = new FormData()
form.append('image', image, filename)
return request(
'/upload/image',
{ method: 'POST', body: form },
zUploadImageResult
)
}
return {
postMessage,
getMessages,
listThreads,
listCloudWorkflows,
cancelMessage,
getDraft,
uploadImage
}
}
export type AgentRestClient = ReturnType<typeof createAgentRestClient>

View File

@@ -1,81 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { ChatSession } from './agentChatHistoryStore'
import {
groupSessionsByRecency,
useAgentChatHistoryStore
} from './agentChatHistoryStore'
const NOW = new Date(2026, 2, 15, 12, 0, 0).getTime()
const DAY = 86_400_000
const session = (id: string, updatedAt: number): ChatSession => ({
id,
title: id,
updatedAt
})
describe('groupSessionsByRecency', () => {
it('buckets by recency, newest first, with the active session as Current', () => {
const sessions = [
session('now', NOW - 1_000),
session('active', NOW - 5 * DAY),
session('earlyToday', NOW - 6 * 3_600_000),
session('yesterday', NOW - DAY),
session('lastWeek', NOW - 4 * DAY)
]
const groups = groupSessionsByRecency(sessions, 'active', NOW)
expect(groups.current.map((s) => s.id)).toEqual(['active'])
expect(groups.today.map((s) => s.id)).toEqual(['now', 'earlyToday'])
expect(groups.yesterday.map((s) => s.id)).toEqual(['yesterday'])
expect(groups.earlier.map((s) => s.id)).toEqual(['lastWeek'])
})
it('places everything in earlier when nothing is recent and none is active', () => {
const groups = groupSessionsByRecency(
[session('old', NOW - 30 * DAY)],
null,
NOW
)
expect(groups.current).toHaveLength(0)
expect(groups.earlier.map((s) => s.id)).toEqual(['old'])
})
it('buckets the prior evening as yesterday across a spring-forward midnight', () => {
const now = new Date(2026, 2, 8, 2, 30).getTime()
const priorEvening = new Date(2026, 2, 7, 23, 30).getTime()
const groups = groupSessionsByRecency(
[session('priorEvening', priorEvening)],
null,
now
)
expect(groups.yesterday.map((s) => s.id)).toEqual(['priorEvening'])
expect(groups.earlier).toHaveLength(0)
})
})
describe('useAgentChatHistoryStore', () => {
beforeEach(() => setActivePinia(createPinia()))
it('clears the active id when the active session is removed', () => {
const store = useAgentChatHistoryStore()
store.replaceAll([session('a', 1)])
store.setActive('a')
store.remove('a')
expect(store.activeId).toBeNull()
expect(store.sessions).toHaveLength(0)
})
it('keeps the active id when a different session is removed', () => {
const store = useAgentChatHistoryStore()
store.replaceAll([session('a', 1), session('b', 2)])
store.setActive('a')
store.remove('b')
expect(store.activeId).toBe('a')
})
})

View File

@@ -1,77 +0,0 @@
import { useTimestamp } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export interface ChatSession {
id: string
title: string
updatedAt: number
}
export interface HistoryGroups {
current: ChatSession[]
today: ChatSession[]
yesterday: ChatSession[]
earlier: ChatSession[]
}
function startOfLocalDay(now: number): number {
const date = new Date(now)
date.setHours(0, 0, 0, 0)
return date.getTime()
}
export function groupSessionsByRecency(
sessions: ChatSession[],
activeId: string | null,
now: number
): HistoryGroups {
const startToday = startOfLocalDay(now)
const startYesterday = startOfLocalDay(startToday - 1)
const groups: HistoryGroups = {
current: [],
today: [],
yesterday: [],
earlier: []
}
const ordered = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt)
for (const session of ordered) {
if (session.id === activeId) groups.current.push(session)
else if (session.updatedAt >= startToday) groups.today.push(session)
else if (session.updatedAt >= startYesterday) groups.yesterday.push(session)
else groups.earlier.push(session)
}
return groups
}
export const useAgentChatHistoryStore = defineStore('agentChatHistory', () => {
const sessions = ref<ChatSession[]>([])
const activeId = ref<string | null>(null)
const now = useTimestamp({ interval: 60_000 })
const grouped = computed(() =>
groupSessionsByRecency(sessions.value, activeId.value, now.value)
)
function remove(id: string): void {
sessions.value = sessions.value.filter((item) => item.id !== id)
if (activeId.value === id) activeId.value = null
}
function replaceAll(next: ChatSession[]): void {
sessions.value = next
}
function setActive(id: string | null): void {
activeId.value = id
}
return {
sessions,
activeId,
grouped,
remove,
replaceAll,
setActive
}
})

View File

@@ -1,48 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { TurnId } from '../../schemas/agentApiSchema'
import { zAgentWsEvent } from '../../schemas/agentApiSchema'
import type { AgentChatEvent } from '../../services/agent/agentEventTransport'
import { useAgentConversationStore } from './agentConversationStore'
const done = (id: string): AgentChatEvent =>
zAgentWsEvent.parse({
type: 'agent_message_done',
data: { message_id: id, thread_id: 'th', usage: null }
}) as AgentChatEvent
const T1 = 't1' as TurnId
const T2 = 't2' as TurnId
describe('agentConversationStore entries (user + assistant interleave)', () => {
beforeEach(() => setActivePinia(createPinia()))
it('pairs each recorded user prompt before its assistant turn, in order', () => {
const store = useAgentConversationStore()
store.recordUser(T1, 'first prompt')
store.startTurn(T1)
store.ingest(done('t1'))
store.recordUser(T2, 'second prompt')
store.startTurn(T2)
const roles = store.entries.map((e) => e.role)
expect(roles).toEqual(['user', 'assistant', 'user', 'assistant'])
const first = store.entries[0]
expect(first.role === 'user' && first.text).toBe('first prompt')
})
it('renders an assistant turn with no recorded prompt as a bare assistant entry', () => {
const store = useAgentConversationStore()
store.startTurn(T1)
expect(store.entries.map((e) => e.role)).toEqual(['assistant'])
})
it('reset clears recorded user prompts too', () => {
const store = useAgentConversationStore()
store.recordUser(T1, 'gone')
store.startTurn(T1)
store.reset()
expect(store.entries).toHaveLength(0)
})
})

View File

@@ -1,205 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, watch } from 'vue'
import type { TurnId } from '../../schemas/agentApiSchema'
import { zAgentWsEvent } from '../../schemas/agentApiSchema'
import type { AgentChatEvent } from '../../services/agent/agentEventTransport'
import { useAgentConversationStore } from './agentConversationStore'
const chat = (raw: unknown): AgentChatEvent =>
zAgentWsEvent.parse(raw) as AgentChatEvent
const thinking = (id: string, delta: string): AgentChatEvent =>
chat({
type: 'agent_thinking',
data: { delta, message_id: id, thread_id: 'th' }
})
const delta = (id: string, text: string): AgentChatEvent =>
chat({
type: 'agent_message_delta',
data: { delta: text, message_id: id, thread_id: 'th' }
})
const toolCall = (id: string, name: string, status: string): AgentChatEvent =>
chat({
type: 'agent_tool_call',
data: { tool_name: name, status, args: [], message_id: id, thread_id: 'th' }
})
const done = (id: string): AgentChatEvent =>
chat({
type: 'agent_message_done',
data: { message_id: id, thread_id: 'th', usage: null }
})
const T1 = 't1' as TurnId
const T2 = 't2' as TurnId
describe('useAgentConversationStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('(M1) fires a deep watch on messages when a MID-turn delta event lands', async () => {
const store = useAgentConversationStore()
const spy = vi.fn()
watch(() => store.messages, spy, { deep: true })
store.startTurn(T1)
await nextTick()
spy.mockClear()
store.ingest(delta('t1', 'streaming delta'))
await nextTick()
expect(spy).toHaveBeenCalled()
expect(store.messages[0].parts.map((p) => p.type)).toEqual(['text'])
})
it('(M2) isStreaming is false after abortActiveTurn() with no done', () => {
const store = useAgentConversationStore()
store.startTurn(T1)
store.ingest(delta('t1', 'half a th'))
expect(store.isStreaming).toBe(true)
store.abortActiveTurn()
expect(store.isStreaming).toBe(false)
expect(store.messages[0].streaming).toBe(false)
expect(store.messages).toHaveLength(1)
store.abortActiveTurn()
expect(store.messages).toHaveLength(1)
})
it('settles the turn on done and reports idle', () => {
const store = useAgentConversationStore()
store.startTurn(T1)
store.ingest(delta('t1', 'answer'))
store.ingest(done('t1'))
expect(store.isStreaming).toBe(false)
expect(store.status).toBe('idle')
expect(store.activeTurnId).toBeNull()
})
it('reports thinking vs streaming status', () => {
const store = useAgentConversationStore()
store.startTurn(T1)
store.ingest(thinking('t1', 'planning'))
expect(store.status).toBe('thinking')
store.ingest(delta('t1', 'go'))
expect(store.status).toBe('streaming')
})
it('drops events for a foreign message_id (store owns turn filtering)', () => {
const store = useAgentConversationStore()
store.startTurn(T1)
store.ingest(delta('t1', 'keep'))
store.ingest(delta('t2', 'DROP ME'))
const parts = store.messages[0].parts
expect(parts).toHaveLength(1)
expect(parts[0]).toMatchObject({ type: 'text', text: 'keep' })
})
it('starting a new turn aborts a prior in-flight turn', () => {
const store = useAgentConversationStore()
store.startTurn(T1)
store.ingest(delta('t1', 'unfinished'))
store.startTurn(T2)
expect(store.messages).toHaveLength(2)
expect(store.messages[0].streaming).toBe(false)
expect(store.messages[1].streaming).toBe(true)
expect(store.activeTurnId).toBe(T2)
})
it('ignores ingest with no active turn', () => {
const store = useAgentConversationStore()
store.ingest(delta('t1', 'orphan'))
expect(store.messages).toHaveLength(0)
})
it('folds a tool_call into the active turn', () => {
const store = useAgentConversationStore()
store.startTurn(T1)
store.ingest(toolCall('t1', 'add_node', 'ok'))
expect(store.messages[0].parts[0]).toMatchObject({
type: 'tool',
name: 'add_node',
ok: true
})
})
it('recordFailedSend renders [user, assistant(notice)] and leaves the turn idle', () => {
const store = useAgentConversationStore()
store.recordFailedSend('local-error-1' as TurnId, 'boom', 'send failed')
const entries = store.entries
expect(entries.map((e) => e.role)).toEqual(['user', 'assistant'])
expect(entries[0]).toMatchObject({ role: 'user', text: 'boom' })
const assistant = entries[1]
expect(assistant.role).toBe('assistant')
if (assistant.role === 'assistant') {
expect(assistant.streaming).toBe(false)
expect(assistant.parts).toEqual([
{ type: 'notice', level: 'error', text: 'send failed' }
])
}
expect(store.activeTurnId).toBeNull()
expect(store.isStreaming).toBe(false)
})
it('recordFailedSend does not disturb an already-active turn', () => {
const store = useAgentConversationStore()
store.startTurn(T1)
store.ingest(delta('t1', 'live'))
store.recordFailedSend('local-error-1' as TurnId, 'oops', 'send failed')
expect(store.activeTurnId).toBe(T1)
expect(store.isStreaming).toBe(true)
})
it('reset wipes the whole conversation, distinct from abortActiveTurn', () => {
const store = useAgentConversationStore()
store.startTurn(T1)
store.ingest(delta('t1', 'gone'))
store.reset()
expect(store.messages).toHaveLength(0)
expect(store.activeTurnId).toBeNull()
expect(store.isStreaming).toBe(false)
})
it('holds the thread id and clears it on reset', () => {
const store = useAgentConversationStore()
store.setThreadId('th-7')
expect(store.threadId).toBe('th-7')
store.reset()
expect(store.threadId).toBeNull()
})
it('revokes transcript blob previews on reset and on hydrate', () => {
const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const store = useAgentConversationStore()
store.startTurn(T1)
store.recordUser(T1, 'with picture', [
{ name: 'a.png', previewUrl: 'blob:a' }
])
store.reset()
expect(revoke).toHaveBeenCalledWith('blob:a')
revoke.mockClear()
store.startTurn(T2)
store.recordUser(T2, 'again', [{ name: 'b.png', previewUrl: 'blob:b' }])
store.hydrate([])
expect(revoke).toHaveBeenCalledWith('blob:b')
expect(
store.entries.every(
(entry) => entry.role !== 'user' || entry.attachments === undefined
)
).toBe(true)
revoke.mockRestore()
})
})

View File

@@ -1,296 +0,0 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type { AgentMessages, TurnId } from '../../schemas/agentApiSchema'
import type {
AgentChatEvent,
AgentEventTransport
} from '../../services/agent/agentEventTransport'
import { createAgentEventTransport } from '../../services/agent/agentEventTransport'
import type { AssistantMessage } from '../../services/agent/agentMessageParts'
import { createAssistantMessage } from '../../services/agent/agentMessageParts'
export type ConversationStatus = 'idle' | 'thinking' | 'streaming'
export interface UserAttachment {
name: string
previewUrl?: string
}
interface UserEntry {
id: TurnId
role: 'user'
text: string
attachments?: UserAttachment[]
tags?: string[]
}
export type ConversationEntry = UserEntry | AssistantMessage
interface BackgroundTurn {
turnId: TurnId
message: AssistantMessage
transport: AgentEventTransport
userText: string | undefined
settled: boolean
}
export const useAgentConversationStore = defineStore(
'agentConversation',
() => {
const messages = ref<AssistantMessage[]>([])
const activeTurnId = ref<TurnId | null>(null)
const threadId = ref<string | null>(null)
const userTexts = ref(new Map<TurnId, string>())
const userAttachments = ref(new Map<TurnId, UserAttachment[]>())
const userTags = ref(new Map<TurnId, string[]>())
let transport: AgentEventTransport | null = null
let liveMessage: AssistantMessage | null = null
const backgroundTurns = new Map<string, BackgroundTurn>()
const activeIndex = ref(-1)
function replaceActive(message: AssistantMessage): void {
const index = activeIndex.value
if (index >= 0 && messages.value[index]?.id === message.id)
messages.value[index] = message
}
function recordUser(
turnId: TurnId,
text: string,
attachments?: UserAttachment[],
tags?: string[]
): void {
userTexts.value.set(turnId, text)
if (attachments !== undefined && attachments.length > 0)
userAttachments.value.set(turnId, attachments)
if (tags !== undefined && tags.length > 0)
userTags.value.set(turnId, tags)
}
function setThreadId(id: string | null): void {
threadId.value = id
}
function recordFailedSend(
turnId: TurnId,
text: string,
noticeText: string
): void {
userTexts.value.set(turnId, text)
const message = createAssistantMessage(turnId)
message.streaming = false
message.parts = [{ type: 'notice', level: 'error', text: noticeText }]
messages.value.push(message)
}
function startTurn(turnId: TurnId): void {
if (transport) abortActiveTurn()
const message = createAssistantMessage(turnId)
liveMessage = message
activeIndex.value = messages.value.push(message) - 1
activeTurnId.value = turnId
transport = createAgentEventTransport(message, replaceActive)
}
function ingest(event: AgentChatEvent): void {
if (transport && event.data.message_id === activeTurnId.value) {
if (event.type === 'agent_message_done') {
transport.settle()
clearActive()
return
}
transport.ingest(event)
return
}
const entry = backgroundTurns.get(event.data.thread_id)
if (!entry || entry.turnId !== event.data.message_id) return
if (event.type === 'agent_message_done') {
entry.transport.settle()
entry.settled = true
return
}
entry.transport.ingest(event)
}
function abortActiveTurn(): void {
if (!transport) return
transport.settle()
clearActive()
}
function stashActiveTurn(): void {
if (!transport || liveMessage === null) return
if (threadId.value === null || activeTurnId.value === null) {
abortActiveTurn()
return
}
backgroundTurns.set(threadId.value, {
turnId: activeTurnId.value,
message: liveMessage,
transport,
userText: userTexts.value.get(activeTurnId.value),
settled: false
})
clearActive()
}
function resumeBackgroundTurn(): void {
if (threadId.value === null) return
const entry = backgroundTurns.get(threadId.value)
if (!entry) return
backgroundTurns.delete(threadId.value)
const kept = messages.value.filter((m) => m.id !== entry.turnId)
const last = kept.at(-1)
let poppedHydratedCopy = false
if (
kept.length === messages.value.length &&
last &&
entry.userText !== undefined &&
userTexts.value.get(last.id) === entry.userText
) {
kept.pop()
userTexts.value.delete(last.id)
poppedHydratedCopy = true
}
if (entry.settled && !poppedHydratedCopy) {
if (entry.userText === undefined) return
const hydratedElsewhere = [...userTexts.value.values()].includes(
entry.userText
)
if (hydratedElsewhere) return
}
if (entry.userText !== undefined && !userTexts.value.has(entry.turnId))
userTexts.value.set(entry.turnId, entry.userText)
const index = kept.push(entry.message) - 1
messages.value = kept
if (entry.settled) return
activeIndex.value = index
activeTurnId.value = entry.turnId
transport = entry.transport
liveMessage = entry.message
}
function settleBackgroundTurn(turnId: string): void {
for (const [key, entry] of backgroundTurns) {
if (entry.turnId !== turnId) continue
entry.transport.settle()
backgroundTurns.delete(key)
return
}
}
function dropBackgroundTurns(): void {
for (const entry of backgroundTurns.values()) entry.transport.settle()
backgroundTurns.clear()
}
function clearActive(): void {
transport = null
liveMessage = null
activeIndex.value = -1
activeTurnId.value = null
}
function dropAttachmentPreviews(): void {
for (const attachments of userAttachments.value.values()) {
for (const { previewUrl } of attachments) {
if (previewUrl?.startsWith('blob:')) URL.revokeObjectURL(previewUrl)
}
}
userAttachments.value = new Map()
}
function reset(): void {
messages.value = []
userTexts.value = new Map()
userTags.value = new Map()
dropAttachmentPreviews()
threadId.value = null
clearActive()
}
function hydrate(history: AgentMessages): void {
clearActive()
const texts = new Map<TurnId, string>()
const assistants = new Map<TurnId, AssistantMessage>()
const turnOrder: TurnId[] = []
for (const row of [...history].sort((a, b) => a.seq - b.seq)) {
const turnId = row.turn_id as TurnId
if (!turnOrder.includes(turnId)) turnOrder.push(turnId)
const text =
typeof row.content?.text === 'string' ? row.content.text : ''
if (row.role === 'user') texts.set(turnId, text)
if (row.role === 'assistant') {
const message =
assistants.get(turnId) ?? createAssistantMessage(turnId)
message.streaming = false
if (text)
message.parts = [
...message.parts,
{ type: 'text', text, state: 'done' }
]
assistants.set(turnId, message)
}
}
messages.value = turnOrder.map((turnId) => {
const message = assistants.get(turnId) ?? createAssistantMessage(turnId)
message.streaming = false
return message
})
userTexts.value = texts
userTags.value = new Map()
dropAttachmentPreviews()
}
const entries = computed<ConversationEntry[]>(() =>
messages.value.flatMap((message) => {
const text = userTexts.value.get(message.id)
return text === undefined
? [message]
: [
{
id: message.id,
role: 'user',
text,
attachments: userAttachments.value.get(message.id),
tags: userTags.value.get(message.id)
},
message
]
})
)
const activeMessage = computed(() =>
activeIndex.value >= 0 ? messages.value[activeIndex.value] : null
)
const isStreaming = computed(() => activeMessage.value?.streaming ?? false)
const status = computed<ConversationStatus>(() => {
const message = activeMessage.value
if (!message?.streaming) return 'idle'
return message.thinking ? 'thinking' : 'streaming'
})
return {
messages,
entries,
activeTurnId,
threadId,
isStreaming,
status,
recordUser,
setThreadId,
recordFailedSend,
startTurn,
ingest,
abortActiveTurn,
stashActiveTurn,
resumeBackgroundTurn,
settleBackgroundTurn,
dropBackgroundTurns,
reset,
hydrate
}
}
)

View File

@@ -1,217 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type {
AgentDraftSnapshot,
DraftPatchData,
DraftVersionData
} from '../../schemas/agentApiSchema'
import { useAgentDraftStore } from './agentDraftStore'
const WORKFLOW = 'a81718a4-02ae-41e6-ae85-c33b7bb880f6'
const OTHER_WORKFLOW = 'b90000a0-0000-0000-0000-000000000000'
function graphAt(cfg: number): Record<string, unknown> {
return {
last_node_id: 1,
links: [],
nodes: [
{
id: 1,
type: 'KSampler',
widgets_values: [0, 'fixed', 12, cfg, 'euler', 'normal', 1]
}
]
}
}
function patch(
version: number,
baseVersion: number,
workflow_id = WORKFLOW
): DraftPatchData {
return {
base_version: baseVersion,
version,
content: graphAt(version),
workflow_id
}
}
function heartbeat(version: number, workflow_id = WORKFLOW): DraftVersionData {
return { version, workflow_id }
}
function snapshot(version: number): AgentDraftSnapshot {
return { content: graphAt(version), version }
}
describe('useAgentDraftStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('applyPatch monotonic adoption', () => {
it('adopts increasing versions 24 -> 25 -> 26 (base_version gap tolerated)', () => {
const store = useAgentDraftStore()
store.bind(WORKFLOW)
store.adoptSnapshot(snapshot(24))
expect(store.applyPatch(patch(25, 24))).toBe(true)
expect(store.version).toBe(25)
expect(store.applyPatch(patch(27, 26))).toBe(true)
expect(store.version).toBe(27)
expect(store.content).toEqual(graphAt(27))
})
it('adopts the first patch when unversioned but bound', () => {
const store = useAgentDraftStore()
store.bind(WORKFLOW)
expect(store.version).toBeNull()
expect(store.applyPatch(patch(25, 24))).toBe(true)
expect(store.version).toBe(25)
expect(store.content).toEqual(graphAt(25))
})
it('ignores a duplicate version (same version re-sent)', () => {
const store = useAgentDraftStore()
store.bind(WORKFLOW)
store.applyPatch(patch(25, 24))
expect(store.applyPatch(patch(25, 24))).toBe(false)
expect(store.version).toBe(25)
expect(store.content).toEqual(graphAt(25))
})
it('ignores a lower version (stale patch)', () => {
const store = useAgentDraftStore()
store.bind(WORKFLOW)
store.applyPatch(patch(26, 25))
expect(store.applyPatch(patch(25, 24))).toBe(false)
expect(store.version).toBe(26)
expect(store.content).toEqual(graphAt(26))
})
it('ignores a foreign workflow_id and does not mutate state', () => {
const store = useAgentDraftStore()
store.bind(WORKFLOW)
store.applyPatch(patch(25, 24))
expect(store.applyPatch(patch(26, 25, OTHER_WORKFLOW))).toBe(false)
expect(store.version).toBe(25)
expect(store.content).toEqual(graphAt(25))
})
it('ignores every patch while unbound', () => {
const store = useAgentDraftStore()
expect(store.applyPatch(patch(25, 24))).toBe(false)
expect(store.content).toBeNull()
expect(store.version).toBeNull()
})
})
describe('bind', () => {
it('clears content/version when binding a different workflow', () => {
const store = useAgentDraftStore()
store.bind(WORKFLOW)
store.applyPatch(patch(25, 24))
expect(store.content).toEqual(graphAt(25))
store.bind(OTHER_WORKFLOW)
expect(store.workflowId).toBe(OTHER_WORKFLOW)
expect(store.content).toBeNull()
expect(store.version).toBeNull()
})
it('re-binding the same workflow preserves state', () => {
const store = useAgentDraftStore()
store.bind(WORKFLOW)
store.applyPatch(patch(25, 24))
store.bind(WORKFLOW)
expect(store.version).toBe(25)
expect(store.content).toEqual(graphAt(25))
})
})
describe('checkHeartbeat', () => {
it('is foreign when unbound', () => {
const store = useAgentDraftStore()
expect(store.checkHeartbeat(heartbeat(24))).toBe('foreign')
})
it('is foreign when the workflow mismatches', () => {
const store = useAgentDraftStore()
store.bind(WORKFLOW)
expect(store.checkHeartbeat(heartbeat(24, OTHER_WORKFLOW))).toBe(
'foreign'
)
})
it('is behind when we hold no version yet', () => {
const store = useAgentDraftStore()
store.bind(WORKFLOW)
expect(store.checkHeartbeat(heartbeat(24))).toBe('behind')
})
it('is behind when the server is ahead', () => {
const store = useAgentDraftStore()
store.bind(WORKFLOW)
store.applyPatch(patch(25, 24))
expect(store.checkHeartbeat(heartbeat(26))).toBe('behind')
})
it('is in-sync at the same version', () => {
const store = useAgentDraftStore()
store.bind(WORKFLOW)
store.applyPatch(patch(26, 25))
expect(store.checkHeartbeat(heartbeat(26))).toBe('in-sync')
})
it('is in-sync when the heartbeat trails our version', () => {
const store = useAgentDraftStore()
store.bind(WORKFLOW)
store.applyPatch(patch(26, 25))
expect(store.checkHeartbeat(heartbeat(24))).toBe('in-sync')
})
})
describe('adoptSnapshot', () => {
it('adopts onto an empty store', () => {
const store = useAgentDraftStore()
store.bind(WORKFLOW)
store.adoptSnapshot(snapshot(24))
expect(store.version).toBe(24)
expect(store.content).toEqual(graphAt(24))
})
it('re-adopts at an equal version (idempotent refresh)', () => {
const store = useAgentDraftStore()
store.bind(WORKFLOW)
store.adoptSnapshot(snapshot(25))
store.adoptSnapshot(snapshot(25))
expect(store.version).toBe(25)
expect(store.content).toEqual(graphAt(25))
})
it('ignores an older snapshot', () => {
const store = useAgentDraftStore()
store.bind(WORKFLOW)
store.adoptSnapshot(snapshot(26))
store.adoptSnapshot(snapshot(24))
expect(store.version).toBe(26)
expect(store.content).toEqual(graphAt(26))
})
})
describe('reset', () => {
it('clears all three refs', () => {
const store = useAgentDraftStore()
store.bind(WORKFLOW)
store.applyPatch(patch(25, 24))
store.reset()
expect(store.workflowId).toBeNull()
expect(store.content).toBeNull()
expect(store.version).toBeNull()
})
})
})

View File

@@ -1,60 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type {
AgentDraftSnapshot,
DraftPatchData,
DraftVersionData
} from '../../schemas/agentApiSchema'
export type HeartbeatState = 'in-sync' | 'behind' | 'foreign'
export const useAgentDraftStore = defineStore('agentDraft', () => {
const workflowId = ref<string | null>(null)
const content = ref<Record<string, unknown> | null>(null)
const version = ref<number | null>(null)
function bind(id: string): void {
if (id === workflowId.value) return
workflowId.value = id
content.value = null
version.value = null
}
function applyPatch(data: DraftPatchData): boolean {
if (data.workflow_id !== workflowId.value) return false
if (version.value !== null && data.version <= version.value) return false
content.value = data.content
version.value = data.version
return true
}
function checkHeartbeat(data: DraftVersionData): HeartbeatState {
if (data.workflow_id !== workflowId.value) return 'foreign'
if (version.value === null || version.value < data.version) return 'behind'
return 'in-sync'
}
function adoptSnapshot(snapshot: AgentDraftSnapshot): void {
if (version.value !== null && snapshot.version < version.value) return
content.value = snapshot.content
version.value = snapshot.version
}
function reset(): void {
workflowId.value = null
content.value = null
version.value = null
}
return {
workflowId,
content,
version,
bind,
applyPatch,
checkHeartbeat,
adoptSnapshot,
reset
}
})

View File

@@ -1,73 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const telemetry = vi.hoisted(() => ({
trackAgentPanelOpened: vi.fn(),
trackAgentPanelClosed: vi.fn()
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => telemetry
}))
import { useAgentPanelStore } from './agentPanelStore'
describe('agentPanelStore engagement telemetry', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('emits opened on toggle-open and closed with the open duration', () => {
const store = useAgentPanelStore()
store.toggle()
expect(store.isOpen).toBe(true)
expect(telemetry.trackAgentPanelOpened).toHaveBeenCalledWith({
source: 'topbar_button'
})
vi.advanceTimersByTime(5000)
store.close('close_button')
expect(store.isOpen).toBe(false)
expect(telemetry.trackAgentPanelClosed).toHaveBeenCalledWith({
source: 'close_button',
open_duration_ms: 5000
})
})
it('toggling an open panel closes it attributed to the topbar button', () => {
const store = useAgentPanelStore()
store.toggle()
vi.advanceTimersByTime(250)
store.toggle()
expect(telemetry.trackAgentPanelClosed).toHaveBeenCalledWith({
source: 'topbar_button',
open_duration_ms: 250
})
})
it('reports a null duration when the panel was opened by a direct state write', () => {
const store = useAgentPanelStore()
store.isOpen = true
store.close('close_button')
expect(telemetry.trackAgentPanelClosed).toHaveBeenCalledWith({
source: 'close_button',
open_duration_ms: null
})
})
it('ignores a close while already closed so flag re-syncs emit nothing', () => {
const store = useAgentPanelStore()
store.close('flag_disabled')
store.close('flag_disabled')
expect(telemetry.trackAgentPanelClosed).not.toHaveBeenCalled()
})
})

View File

@@ -1,60 +0,0 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useTelemetry } from '@/platform/telemetry'
import type { AgentPanelCloseSource } from '@/platform/telemetry/types'
const PANEL_MIN_WIDTH = 420
const PANEL_MAX_WIDTH = 960
export const useAgentPanelStore = defineStore('agentPanel', () => {
const enabled = ref(false)
const isOpen = ref(false)
const width = ref(PANEL_MIN_WIDTH)
let openedAt: number | null = null
const isMaximized = computed(() => width.value === PANEL_MAX_WIDTH)
function open(): void {
if (isOpen.value) return
isOpen.value = true
openedAt = Date.now()
useTelemetry()?.trackAgentPanelOpened({ source: 'topbar_button' })
}
function close(source: AgentPanelCloseSource): void {
if (!isOpen.value) return
isOpen.value = false
const openDurationMs = openedAt === null ? null : Date.now() - openedAt
openedAt = null
useTelemetry()?.trackAgentPanelClosed({
source,
open_duration_ms: openDurationMs
})
}
function toggle(): void {
if (isOpen.value) close('topbar_button')
else open()
}
function setWidth(px: number): void {
width.value = Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, px))
}
function toggleMaximize(): void {
setWidth(isMaximized.value ? PANEL_MIN_WIDTH : PANEL_MAX_WIDTH)
}
return {
enabled,
isOpen,
width,
isMaximized,
toggle,
close,
setWidth,
toggleMaximize
}
})

View File

@@ -1,54 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { useAgentWorkflowTabBindingStore } from './agentWorkflowTabBindingStore'
describe('agentWorkflowTabBindingStore', () => {
beforeEach(() => {
localStorage.clear()
setActivePinia(createPinia())
})
it('resolves both directions after a bind', () => {
const store = useAgentWorkflowTabBindingStore()
store.bind('wf-1', 'workflows/a.json')
expect(store.tabPathFor('wf-1')).toBe('workflows/a.json')
expect(store.workflowIdFor('workflows/a.json')).toBe('wf-1')
})
it('rebinding a workflow to a new tab releases the old tab', () => {
const store = useAgentWorkflowTabBindingStore()
store.bind('wf-1', 'workflows/a.json')
store.bind('wf-1', 'workflows/b.json')
expect(store.tabPathFor('wf-1')).toBe('workflows/b.json')
expect(store.workflowIdFor('workflows/a.json')).toBeUndefined()
expect(store.workflowIdFor('workflows/b.json')).toBe('wf-1')
})
it('binding another workflow to an occupied tab steals it', () => {
const store = useAgentWorkflowTabBindingStore()
store.bind('wf-1', 'workflows/a.json')
store.bind('wf-2', 'workflows/a.json')
expect(store.workflowIdFor('workflows/a.json')).toBe('wf-2')
expect(store.tabPathFor('wf-1')).toBeUndefined()
expect(store.tabPathFor('wf-2')).toBe('workflows/a.json')
})
it('bindings survive a reload', async () => {
useAgentWorkflowTabBindingStore().bind('wf-1', 'workflows/a.json')
await nextTick()
setActivePinia(createPinia())
const reloaded = useAgentWorkflowTabBindingStore()
expect(reloaded.tabPathFor('wf-1')).toBe('workflows/a.json')
expect(reloaded.workflowIdFor('workflows/a.json')).toBe('wf-1')
})
it('does not resolve prototype-inherited names as bindings', () => {
const store = useAgentWorkflowTabBindingStore()
expect(store.tabPathFor('constructor')).toBeUndefined()
expect(store.workflowIdFor('workflows/missing.json')).toBeUndefined()
})
})

Some files were not shown because too many files have changed in this diff Show More