Compare commits

...

65 Commits

Author SHA1 Message Date
Nathaniel Parson Koroso
2c66b22392 test(agent): close the senior-qa coverage gaps
- ToolCallGroup: streaming auto-open, same-name merge, failure reopen,
  and the AgentMessage adjacent-merge branch with pluralized header.
- WorkflowTabs: the Ask Comfy Agent CTA renders when enabled and
  toggles the panel (mock rewired ref-backed for storeToRefs).
- useAgentSession: non-object and foreign host frames drop silently
  mid-turn.
- MarkdownStream: fence label picks the first info-string word.
2026-07-09 21:57:01 -07:00
Nathaniel Parson Koroso
7655e58d20 test(agent): pin one error per oversize attachment alongside the message 2026-07-09 21:26:16 -07:00
Nathaniel Parson Koroso
91e0ebeba0 fix(agent): restore namespaced collapsible animation; close review gaps
The trim deleted the collapsible keyframes as duplicates of
tw-animate-css, but that package defines no collapsible-* keys, so the
tool-disclosure animation silently no-opd. Restored under agent-*
names: the globally-imported sheet can no longer retarget the host's
animate-collapsible-* utilities (undefined on main) in sharing dialogs.
Adds the three review-flagged test gaps: indented code stays prose,
bare fences label as text, and the oversize-attachment message pins
its i18n interpolation.
2026-07-09 21:19:01 -07:00
Nathaniel Parson Koroso
8c8b78def4 refactor(agent): sweep orphaned locale keys, i18n attachment strings
- 17 locale keys with zero usages removed from en (other locales are
  lobe-i18n generated and never carried them); error-catalog and
  design-locked keys kept.
- useAttachment strings go through i18n; the 20MB label derives from
  the named constant (max_upload_size flag exists but has no production
  consumer, so the constant stays).
- A failed thread-list fetch now raises the shared error modal instead
  of silently showing empty history, with a regression test.
2026-07-09 21:02:04 -07:00
Nathaniel Parson Koroso
bddb1a22cb refactor(agent): delete dormant and dead code
- Safety UI that nothing drives (ApprovalCard, LockBanner, RevertButton
  and their AgentPanel plumbing); ConflictDialog stays.
- Dead message-part clusters: file/asset rendering and reasoning
  disclosure that no transport path constructs; the parts union and
  AgentMessage grouping shrink to text/tool/notice.
- Unwired composer pieces (AssetTray, SelectionActionChips,
  useCloudAssets) with zero non-test importers.
- Dead exports (createThread, history.upsert, useAgentFeatureGate fn,
  hasDraft, onboarding multi-step machinery) - each re-verified for
  zero production consumers on the current head; parseAgentWsEvent /
  isAgentEvent kept, they are live imports since the event rework.
- Write-only token pipeline, info/warning notice machinery, and the
  transport's no-op finalize loop removed; finalize/abort merged into
  one settle().
2026-07-09 20:37:53 -07:00
Nathaniel Parson Koroso
ac572f3937 refactor(agent): shared UI kit, api.fetchApi transport, drop shiki
- The agent REST client rides api.fetchApi (Firebase init-wait, auth
  headers, 401 remint, Comfy-User) instead of a hand-rolled fetch with
  workspace-token-only auth; zod parsing and AgentApiError stay.
- Agent-local Button/Textarea forks replaced by the shared UI kit with
  variant mappings verified pixel-equivalent through tailwind-merge;
  dialogs inline reka primitives (the shared kit's entrance animations
  and sizing cannot be neutralized). ScrollArea/CollapsibleContent
  wrappers inlined per the sibling idiom.
- WorkflowTabs CTA uses the shared Button.
- agentTheme.css no longer redefines tw-animate-css collapsible
  keyframes (they globally retargeted three sharing-dialog consumers).
- CodeBlock keeps the framed header + copy but renders plain monospace;
  the shiki dependency and catalog entry are removed. MarkdownStream
  tokenizes fences via marked.lexer, fixing the 4-backtick gap.
2026-07-09 20:14:45 -07:00
Nathaniel Parson Koroso
9e74c435a0 refactor(agent): reuse platform markdown + typed WS dispatch; fix host regressions
- MarkdownStream renders through the shared markdownRendererUtil; the
  agent copy (and its module-load DOMPurify hook that gave every host
  anchor target=_blank) is deleted.
- The agent event source is now a thin adapter over the api singleton's
  typed custom-event dispatch: no second socket follower, no re-parse of
  every /ws frame, no "Unknown message type" throw noise. Zod payload
  validation and the malformed-event notice stay.
- AgentPanelRoot loads via defineAsyncComponent, keeping the flag-gated
  subtree out of the GraphCanvas chunk.
- Prompt-error cards show details only for the agent's own error types;
  host cards restored to pre-PR behavior, both pinned by tests.
2026-07-09 19:38:12 -07:00
Nathaniel Parson Koroso
fe35a35d02 feat(agent): PostHog engagement metrics for the agent panel
Adds the V0 FE metric set through the existing telemetry layer (typed
events, optional provider methods, cloud PostHog provider):

- app:agent_panel_opened / app:agent_panel_closed with source
  (topbar_button | close_button | flag_disabled) and open_duration_ms,
  so open-vs-closed share per session is computable
- app:agent_entry_button_clicked (resulting_state) and
  app:agent_close_button_clicked for the raw clicks
- app:agent_message_sent (attachment_count, node_tag_count),
  app:agent_node_tagged (mention_picker), app:agent_attach_button_clicked
- app:agent_workflow_applied (workflow_id, new_tab | existing_tab) for
  agent-created vs agent-edited workflow attribution
- app:agent_message_feedback now carries workflow_id (PM-98)

The panel store owns open/close state, so it emits the state events with
an idempotency guard (the flag gate re-syncs close on every evaluation).
2026-07-09 18:36:21 -07:00
Nathaniel Parson Koroso
3eb30788f0 fix(agent): autosave agent-applied drafts and always show the message action bar
Draft applies now re-baseline the bound tab's change tracker to the canvas
as loaded and clear isModified, on both the in-place and minted-new-tab
paths. A minted tab's stored baseline carries an ensureWorkflowId id the
canvas never adopts, so the first capture flipped isModified and every
following draft_patch raised the conflict dialog with zero user edits.
Manual edits still re-arm the dialog.

The thumbs up/down + copy row under a completed agent message no longer
hides until hover (V0: always display).
2026-07-09 18:22:24 -07:00
Nathaniel Parson Koroso
f37a4dd022 refactor(agent): clearer names for the workflow upload fields
Rename the WorkflowUpload fields so the wire contract reads plainly:
content -> graph (the prompt is already the top-level content field), and
base_version -> last_seen_version (the draft version the client last
received; draft_version would have collided with the existing WS event
type of that name).
2026-07-09 14:12:42 -07:00
Nathaniel Parson Koroso
a2e870ee6c refactor(agent): drop per-tag definition inlining from selection
Tags send bare node_ids again; they resolve against the full graph the
same POST already uploads (workflow.content) once the server seeds the
draft from it. No client-side stopgap.
2026-07-09 13:51:00 -07:00
Nathaniel Parson Koroso
4dabd238c1 fix(agent): route all agent errors through the host error modal
REST failures (e.g. Bad Gateway on hydrate), malformed events, and
upload failures rendered as bespoke toasts floating over the docked
panel. They now share the one host error modal already used for
draft-apply failures; warnings/info stay transient toasts.
2026-07-09 13:43:11 -07:00
Nathaniel Parson Koroso
a5020b0b69 feat(agent): upload the canvas with sends to seed the server draft
postMessage now carries the serialized active graph when it changed
since the last upload (2MB cap, re-upload after failed sends or thread
switches), per the agreed cloud contract; current servers ignore the
field. The ack binds the uploaded tab to the thread's workflow id, so
agent patches apply in place on the user's own tab. Empty drafts park
everywhere, and the conflict dialog still guards modified tabs.
2026-07-09 13:41:18 -07:00
Nathaniel Parson Koroso
c4fc77b514 fix(agent): keep the panel title on one line beside the tab strip 2026-07-09 12:50:25 -07:00
Nathaniel Parson Koroso
497c8e18e6 style(agent): enlarge chip remove buttons to a usable hit target 2026-07-09 12:48:17 -07:00
Nathaniel Parson Koroso
8aba29b72f feat(agent): inline tagged node definitions in the selection payload
The server-side draft never mirrors the live canvas (it starts empty
even for saved workflows), so @-tagged node_ids resolved to nothing and
the agent replied that the canvas was empty. Each tagged node now rides
the POST with its serialized definition from the graph the canvas is
showing (root or an open subgraph), plus a context note that claims
inline definitions only for tags that resolved, names the gap when some
did not, and nudges a save when the tab has no server-side workflow.
2026-07-09 12:04:53 -07:00
Nathaniel Parson Koroso
b7c4256653 feat(agent): @ node picker, park empty unbound drafts
- The composer @ button now opens a dropup listing the active graph's
  nodes (title + id); picking one stages it as a selection tag.
- An unbound draft patch with no nodes no longer opens a blank tab;
  the server-minted empty draft parks until a patch carries content.
- Selections sent from a tab with no server-side workflow carry a
  context note so the agent asks for a save instead of guessing.
2026-07-09 11:35:38 -07:00
Nathaniel Parson Koroso
b6326c3e52 feat(agent): keep sent @-tags on the transcript and move the tab name into the header
Sent node tags now persist as small pills on the user message (same
rationale as attachments: proof they were sent), with the node ids still
riding the POST selection. The tag titles travel through sendMessage
alongside attachments, replacing the session's selection dep.

The active tab name moves out of its own row into the panel header next
to the ALPHA badge (truncating), reclaiming a strip of vertical space.
2026-07-09 11:11:02 -07:00
Nathaniel Parson Koroso
0157adeb51 fix(e2e): make the mocked workspace token schema-valid
A bare {token} fails WorkspaceTokenResponseSchema, sending workspace auth
into its recovery/retry loop whose error toasts stack over the docked
panel and swallow the suggested-prompt click (the intermittent cloud
failure). The mock now carries expires_at/workspace/role/permissions, so
the app authenticates cleanly and nothing toasts.
2026-07-08 21:15:34 -07:00
Nathaniel Parson Koroso
260b7d1a8f test(e2e): widen the two long agent specs to 30s
Boot + panel open + a full streamed turn can exceed the cloud project's
15s budget on a loaded CI runner (observed: test-timeout at the chip
click with everything before it slow but passing). Same precedent as
versionMismatchWarnings.spec.ts.
2026-07-08 20:56:35 -07:00
Nathaniel Parson Koroso
f4fc05f35a fix(agent): release transcript previews and harden the feature-set tests
Review pipeline hardening: attachment object URLs now revoke on
conversation reset/hydrate (sent previews no longer outlive their
conversation); the conflict dialog's dropdown trigger reuses Button
instead of hand-copied variant classes; the dead useCanvasContext
composable is removed (ActiveTab lives with the strip).

New regression tests: draft-patch coalescing under a slow canvas apply,
the full preview-revoke lifecycle (dismiss, upload-fail, reset, hydrate,
never on submit), two-turn attachment keying, tab-strip reactivity,
dialog X-close semantics, uploading send-block, UserMessage render arms,
and loadThread dropping the prior draft binding.
2026-07-08 20:42:48 -07:00
Nathaniel Parson Koroso
e1a13ae9c2 feat(agent): name the active tab in the panel strip
Renders the ported ActiveTabStrip under the session bar with the active
workflow's display name, so it is clear which tab the agent acts on.
2026-07-08 19:04:32 -07:00
Nathaniel Parson Koroso
9e9f63ad1a feat(agent): restyle the edit-conflict dialog to DES-502
Compact card with the design's copy ("Agent changes are ready") and a
horizontal action row: Cancel/close defer the decision and the next turn
re-asks; "Keep my changes" retires the pending draft version so only a
newer agent edit asks again; "Accept agent changes" is the primary with
"Open in new tab" in its attached dropdown.
2026-07-08 18:50:51 -07:00
Nathaniel Parson Koroso
3048178d1b feat(agent): keep sent attachments in the transcript and show upload progress
Sent images now stay visible as a thumbnail grid on the user message (they
previously vanished on send, reading as lost). Attachment chips stage the
moment a file is picked, spin while uploading, and block the send until the
ref exists server-side; failures remove the chip and toast. Dismissed chips
release their local preview buffers.
2026-07-08 18:38:23 -07:00
Nathaniel Parson Koroso
939529a109 feat(agent): bind drafts to tabs, send workflow_id per turn, @-tag nodes
Draft patches now route to the canvas tab bound to their workflow_id:
in place when the tab is active (was: a new tab per patch), lazily on
refocus when backgrounded, and through the merge-conflict dialog when
the user edited the tab (keep mine / let agent continue / open new tab,
per B16). An unbound workflow opens and binds its own tab on first patch.

Every message POST carries the active tab's workflow_id (bound id, else
the saved workflow's uuid; a 403 on a speculative id retries once
without). Selected canvas nodes stage removable @-chips above the
composer and ride the turn as selection.node_ids, per B7.
2026-07-08 17:46:51 -07:00
Nathaniel Parson Koroso
fe466d8d88 fix(e2e): mock billing and assets so error toasts cannot cover the agent panel
Unmocked, both reject in CI and their sticky toasts stack over the docked
panel, swallowing chip and Send clicks. Reuses the shared mockBilling helper
and the shared jsonRoute in place of a local duplicate.
2026-07-08 15:28:31 -07:00
Nathaniel Parson Koroso
5b11576778 fix(e2e): seed agent onboarding as seen so the coach scrim does not block clicks 2026-07-08 14:29:58 -07:00
Nathaniel Parson Koroso
b2fe62d5b4 fix(e2e): install agent boot mocks before the app boots in cloud specs
The mocks lived in a sibling fixture with no ordering guarantee against
comfyPage, so in CI the app booted against the real /api/features (no
PostHog token) and the flag gate kept the panel button hidden. Override
the page fixture instead, the same pattern as workspaceSwitcher.spec.ts.
2026-07-08 13:45:52 -07:00
Nathaniel Parson Koroso
a505890f5a refactor(agent): single source for draft-apply failure copy
The PromptError message now reuses the catalog desc that renders; drops the
duplicate agent.draftApplyFailed string.
2026-07-08 12:32:42 -07:00
Nathaniel Parson Koroso
fe0cf34dd1 feat(agent): surface draft-apply failures through the workflow error overlay
A failed draft-to-canvas apply is a workflow error, so it now uses the
existing production error surface instead of a bespoke toast: the panel
writes lastPromptError (type agent_draft_apply_failed, details = the
zod/load failure) and opens the error overlay, whose View details opens
the Errors tab. Once per failure streak, reset when a draft applies; a
loadGraphData rejection surfaces the same way. Chat/session errors
(failed send, malformed event, cancel, history) keep their existing
inline/notice paths.

- promptErrorResolver: register agent_draft_apply_failed; catalog copy
  under errorCatalog.promptErrors
- useErrorGroups: pass prompt-error details through to the card item so
  View details shows the raw failure (TabErrors expectation updated)
2026-07-08 12:07:10 -07:00
Nathaniel Parson Koroso
5022ee8979 test: fix CI failures from the agent-panel seams
- agentPanel.spec: the bare main.json import crashed Playwright's Node
  loader at collection time, failing every project; add the
  'with { type: json }' attribute (same as WorkflowTabs.test)
- WorkflowTabs.test: mock the agent panel store like the component's
  other stores; its new useAgentPanelStore() call threw no-active-pinia
2026-07-08 11:43:41 -07:00
Nathaniel Parson Koroso
a02279ec92 refactor(agent): purge comments to the AGENTS.md bar
Comment-only sweep of the subtree (zero executable lines changed,
verified by diff filter): 424 -> 107 comment lines across 18 files.
Deleted every restatement of the code, symbol-paraphrasing JSDoc,
design-doc narration, and story references. Survivors are 1-2 line
facts the code cannot express: wire-contract behavior (409 = settled,
usage null on cancel, title empty until server names the thread),
prevented bug classes (send/ack race, stale-highlight race, IME,
drop navigation, id collisions, reactivity traps), and safety
invariants (fail-closed flag gate, cancel-on-unmount billing).
2026-07-08 11:05:52 -07:00
Nathaniel Parson Koroso
517d6d3df8 fix(agent): address CodeRabbit review findings
- CodeBlock: cancel superseded async highlights via watch onCleanup (a
  stale resolution could overwrite a newer one during streaming) and
  sanitize shiki output with DOMPurify before v-html, matching the
  markdown pipeline
- AgentPanelRoot: unsubscribe the event source only after the unmount
  cancel settles (stopTurn().finally(stop))
- ConversationView: replace the deep entries watch (O(all entries) per
  streamed token) with a signal over the last entry only
- ApprovalCard: guard Approve/Deny against double-click double-emit
- useAttachment: pending spans the whole batch instead of flickering
  per file; add a mixed-batch test (fail one, keep the rest)
- Lightbox: muted + playsinline so video autoplay works cross-browser
- agentApiSchema: zAgentMessage tolerates additive fields like its
  sibling wire objects
- Composer.test: type the mount helper via ComponentProps
- README: fix the stale opening (right dock, not a sidebar tab)
2026-07-08 10:52:55 -07:00
Nathaniel Parson Koroso
128e8f7bd7 refactor(agent): apply AGENTS.md rule-audit findings
Adversarially verified audit of the agent subtree against the repo rules:

- MarkdownStream, UserMessage: compress comments that restated the
  Tailwind classes below them; keep only intent and provenance
- useCloudAssets: replace the hand-rolled Set dedup loop with
  es-toolkit uniqBy (same keep-first-by-id semantics)
- agentPanel.test: move the mutable mock state the vi.mock factories
  close over into vi.hoisted, drop the agentClose alias for the
  store's own vi.fn
2026-07-08 10:37:36 -07:00
Nathaniel Parson Koroso
63690cca50 Merge remote-tracking branch 'origin/main' into nathaniel/fe-1187-agent-side-panel-fe-implementation
# Conflicts:
#	src/components/LiteGraphCanvasSplitterOverlay.vue
2026-07-07 21:29:36 -07:00
Nathaniel Parson Koroso
887e3a587a style(agent): composer caption says workflow, not graph
"The agent edits your graph as a draft." -> "The agent edits your
workflow as a draft." — user-facing surfaces say workflow; graph is
internal vocabulary. Still a single line at the 420px panel width.
2026-07-07 20:14:54 -07:00
Nathaniel Parson Koroso
37a5554962 fix(agent): pin the thread-list schema to the live envelope
GET /api/agent/threads returns {threads, pagination}, not a bare array,
so the speculative z.array schema written before the endpoint existed
failed at the root and Chat History rendered empty against a live 200.
Validated against a real response:

- zAgentThreads: {threads: [...]} envelope; rows are {id, title,
  preview, last_message_at, updated_at, created_at, ...} with title
  "" until the server names the thread
- listThreads unwraps the envelope and returns the rows
- toChatSession drops the guessed field spellings; an unnamed thread
  titles its row from preview (the first prompt), then untitledChat
- refreshHistory logs a fetch/parse failure instead of swallowing it
2026-07-07 20:14:23 -07:00
Nathaniel Parson Koroso
a0983fd2a4 fix(agent): surface schema-rejected drafts instead of dropping them
A draft_patch whose content fails the host workflow schema (e.g. a
graph missing its required version field) was dropped with only a
console.warn: the agent narrated a finished graph while the canvas
stayed stale and the user saw no error. Raise an error toast on
rejection (new agent.draftApplyFailed key), once per failure streak
rather than per patch, reset when a draft applies.
2026-07-07 20:06:11 -07:00
Nathaniel Parson Koroso
375e808973 fix(agent): name the back action on the history screen
The history screen's "<- Chat history" control is the current screen
title with a back arrow, but clicking it returns to the chat, so the
label reads like a destination. Add a "Back to chat" hover tooltip
(new agent.backToChat key), mirroring the session bar's "Show chat
history" tooltip.
2026-07-07 19:41:21 -07:00
Nathaniel Parson Koroso
5ee3576062 feat(agent): untitled session default and history tooltip (DES-455)
- Session-bar default title "New chat" -> "Untitled"; it still renames
  to the first prompt on submit. agent.untitledChat is consolidated to
  "Untitled" so the titleless fallback reads the same everywhere.
- Session-bar hover tooltip "Chat history" -> "Show chat history" (new
  agent.showChatHistory key) so the pill names its action; the
  new-chat action stays on the header icon.

Matches the DES-455 chat-history flow (Figma node 2442-9503).
2026-07-07 19:33:34 -07:00
Nathaniel Parson Koroso
7aee27ebfb style(agent): keep secondary text on agent-fg-subtle
CodeBlock and MessageFeedback drifted to text-agent-fg-muted while
every sibling secondary-text run uses text-agent-fg-subtle, the alias
agentTheme.css documents for secondary text. Both aliases resolve to
muted-foreground today, so this is a visual no-op, but it keeps the
panel on a single token if the aliases ever diverge.
2026-07-07 19:27:35 -07:00
Nathaniel Parson Koroso
30ef8b3b8c style(agent): correct code-block sizing
- body: 11px -> text-sm, p-3 -> p-4
- frame radius: rounded-lg -> rounded-md
- header label and copy button: 11px -> text-xs

Colors were already correct (secondary-background-hover header,
border-default frame, base-background body).
2026-07-07 18:30:01 -07:00
Nathaniel Parson Koroso
c931e75550 style(agent): empty-state greeting spacing and type
- Session-bar fallback title "New Chat" -> "New chat"
- Greeting block: tile-to-text gap-6 -> gap-4
- Greeting wrapper: tracking-tight, snug line-height at both text
  sizes, gap-2 between the two lines, max-w-sm
2026-07-07 18:22:02 -07:00
Nathaniel Parson Koroso
749e473192 fix(agent): surface send failures once, inline only
A failed send raised both a host toast (pushError) and an inline
conversation notice (recordFailedSend), rendering one error twice; the
top-right toast also overlapped the right-docked panel. Session notices
are reserved for errors with no inline row, so drop the toast on the
send path and keep the inline row. Test (c) now asserts a send failure
raises no session notice.
2026-07-07 18:06:34 -07:00
Nathaniel Parson Koroso
d5aae1a24c style(agent): conversation-detail design parity
- ThinkingStatus: brain icon and a shimmer on the "Thinking…" label
  (new agent-shimmer keyframes; static muted fallback under
  prefers-reduced-motion and .disable-animations)
- CodeBlock: bordered chrome; header gains a file-code icon, the
  language, and a bordered copy button; mono body
- MessageFeedback: gap-0.5 row, size-6 buttons, size-3.5 icons
- Composer: send button shows a loader-circle spinner while the turn
  is thinking, via a new submitting prop threaded
  AgentPanelRoot -> AgentPanel -> Composer
2026-07-07 17:45:01 -07:00
Nathaniel Parson Koroso
b83df7704e feat(agent): server-backed Chat History via GET /api/agent/threads
Kishore shipped the list-threads endpoint, so Chat History is no longer a
single local 'current' row.

- New listThreads() on the REST client + a tolerant zAgentThreads schema
  (every field optional + passthrough): the endpoint shipped after the openapi
  extract, so the client normalizes id / title / timestamp across the likely
  spellings and a shape drift degrades to a best-effort row rather than a hard
  zod failure that would blank the list.
- useAgentSession gains listThreads() and loadThread(id): loadThread cancels
  any in-flight turn, adopts + persists the thread id, and hydrates its
  transcript (reusing the B17 resume path; a stale id 404s and is forgotten).
- AgentPanelRoot fetches the list on mount and each time history opens
  (best-effort: a failed fetch keeps the last-known list), maps threads to
  ChatSession rows, tracks the active row off the live threadId, and loads a
  picked chat via loadThread. Removed the old local 'current row' mirror.
- History store gains replaceAll() for the authoritative server list.

Verified live: the panel calls GET /api/agent/threads and, because the
endpoint 404s on the pr-4432 preview env (Kishore's build not deployed there
yet), the history screen shows 'No conversations yet' with no crash — the
graceful-degradation path. Real rows will populate on an env with the
endpoint; the field mapping should be reconfirmed against the live shape.
Typecheck/knip clean; agent tests 180/180 (added loadThread + listThreads +
server-history-population coverage).
2026-07-07 17:24:04 -07:00
Nathaniel Parson Koroso
944ff2c009 fix(agent): working in-panel history, clean new chat, textarea border
Three reported bugs:

- History did nothing: it was a reka Dialog teleported to <body> that opened
  behind the docked panel's z-999 stacking context, so it never appeared.
  Replace it with an in-panel Chat History screen (new ChatHistoryScreen)
  that the session bar swaps in over the conversation, with a back arrow;
  picking a chat or starting a new one returns. No teleport, no stacking
  fight. Deleted ChatHistoryDrawer + its DropdownMenu* deps.
- No way to make a new chat: the header new-chat button opened the
  out-of-V0-scope 'starting point' onboarding modal instead of resetting.
  It now just clears to the empty state; deleted StartingPointModal.
- Textarea border wrong: the panel-scoped Preflight reset in agentTheme.css
  covered <button> but not <textarea>/<input>/<select>, so the textarea kept
  its UA border/font inside the composer's own border (double border). Extend
  the reset to those elements (border 0, transparent bg, inherit font).

Verified live: session bar -> in-panel history -> back; new-chat -> empty
state; textarea border now 0 (single border on the composer container).
Typecheck/knip clean; agent tests 177/177.
2026-07-07 17:07:32 -07:00
Nathaniel Parson Koroso
17bf3643e9 style(agent): round 2a conversation parity (bubble, markdown, tool row)
Computed-style diff of our conversation vs the design reference (prototype);
re-probed live after the fix for an exact match on the user bubble.

- User message: right-aligned pill, no avatar (dropped the Avatar and the
  name/userName props that fed it) — w-fit rounded-lg bg-agent-surface-raised
  px-4 py-3 text-xs (was rounded-agent bg-agent-pill max-w-sm px-3 py-2
  text-sm with an avatar; bubble was 14px/#303036/radius12, now
  12px/rgb(38,39,41)/radius8/py-3 px-4 to match).
- Markdown prose: h1 text-2xl/600, h2 text-base/600, p 14/1.625, decimal/disc
  lists, accent underlined links, 3px muted blockquote, bordered
  secondary-surface tables (were entirely missing), bordered inline code.
- Tool-call summary row: wrench + 'Ran N…' summary + right chevron on a
  borderless h-8 rounded-md muted row (was a bordered card with a left
  chevron); expanded steps render as a plain gap list.
- Conversation column: mx-auto max-w-[640px] p-4.

Verified live on localhost against the prototype. Typecheck clean; agent
tests 177/177.
2026-07-07 16:54:23 -07:00
Nathaniel Parson Koroso
37632ad3b8 style(agent): round 1 parity with the design reference
Iterative style pass against the design prototype (visual reference only; all
changes are tweaks to our own components):

- Dock: the panel is now a full-viewport-height right column beside the tab
  bar (was a splitter percentage panel below it), 420px default <-> 960px
  maximized with a drag handle on its left edge; 8px inset wrapper with a
  rounded interface-stroke border.
- Entry: the 'Ask Comfy Agent' button moves from the actionbar row into the
  workflow tab bar (plum-bordered ink pill, yellow Comfy-C, open state
  highlights); still flag-gated fail-closed. The extension is now gate-only.
- Header: h-12/px-4, regular-weight title, neutral ALPHA pill (no
  semibold/tracking), icons message-circle-plus + maximize-2/minimize-2 +
  x with tooltips.
- Session bar: compact h-6 rounded-sm pill (align-justify icon, truncated
  title, no chevron) instead of a full-width bordered row.
- Empty state: ink-700 tile with plum-600 border and size-6 mark; greeting
  scales text-base -> text-2xl via container query; suggested prompts are
  rounded-full pill chips (size-3 icons) that wrap centered at >=460px;
  4th prompt icon corrected to message-circle-warning.
- Composer: rounded-2xl container owning all padding, focus-within border
  brighten, min-h-20 textarea (px-4 py-3), toolbar px-3 py-2, Auto gets its
  chevron, send is rounded-xl light-on-dark with opacity-50 disabled state;
  placeholder is now 'Ask Comfy Agent…'.
- Footer: p-4 with a mx-auto max-w-[640px] column and my-0 caption.
- Tokens: agent-* aliases retuned to the reference's host tokens
  (base-background surface, secondary-background raised/hover,
  muted-foreground for all secondary text, component-node-border dividers).

Running mismatch list: temp/plans/fe-1187-figma-acceptance.md
2026-07-07 16:37:29 -07:00
Nathaniel Parson Koroso
ad150c88b3 fix(agent): resume the conversation across page reloads (B17)
The thread id lived only in the in-memory store, so a reload always opened a
blank panel and the next send even split the transcript onto a new server
thread. Per the backend contract, GET /api/agent/threads/{id}/messages is the
page-load history endpoint (seq ascending; 404 = thread not found).

- Persist the thread id (localStorage) when a send's ack adopts it; clear it
  on new chat.
- On session start with an empty store, restore the persisted thread and
  hydrate its transcript through the new conversationStore.hydrate(): rows
  pair by turn_id in seq order and arrive settled; a turn whose assistant row
  is missing still renders its user prompt. A 404 forgets the stale id
  silently; reopening the panel within the same page session leaves the live
  conversation untouched.
- Tests: hydrate-on-start, stale-404 forget, persist-on-send/clear-on-new-
  chat, no-clobber-on-reopen; the harness clears localStorage between tests.
2026-07-07 16:18:48 -07:00
Nathaniel Parson Koroso
88cdef79f5 feat(agent): dock the panel on the right, not in the left sidebar
The Figma docks the agent on the right of the canvas (pushing the canvas
left) and opens it from the top-bar "Ask Comfy Agent" button — the left rail
keeps Assets/Nodes/etc. and has no agent icon. It was previously a left
sidebar tab.

- New agentPanelStore holds the panel's { enabled, isOpen }. agentPanel.ts no
  longer registers a sidebar tab; the top-bar button toggles isOpen, and the
  PostHog flag gate drives enabled (fail-closed: flag off hides the button and
  closes the panel).
- GraphCanvas renders AgentPanelRoot in the host right-side-panel slot when the
  panel is enabled + open (taking precedence over the node-properties panel),
  and the splitter shows that offside panel while the agent is open, so the
  canvas shrinks to the left. The panel's close button closes the store.
- Update the unit + e2e specs to open via the top-bar button.

Verified live: opens/closes on the right, canvas pushes left, left rail
intact, no agent icon in the rail. Typecheck + unit tests pass.
2026-07-07 14:59:19 -07:00
Nathaniel Parson Koroso
500a6d9c00 fix(agent): match the finalized Figma for the panel empty state
Against the finalized "In-App Agent V0 - Design & Requirements" doc and its
Figma frames (fileKey G7ZjPEAFJB8cqb7halGEfa):

- Empty state: the Comfy mark sits on a dark rounded tile; both greeting
  lines are bold; all five suggested prompts are pinned just above the
  composer (mark + greeting center above them) instead of overflowing, each
  a light row with its Figma leading icon (lightbulb, list, search,
  message-circle-question, workflow).
- Composer bottom row (B6): add the @ (mention) button beside the paperclip,
  add the "Auto" model label, and make the send button white with a dark
  up-arrow when active / grey when empty (was always the blue accent).
- Header (B3): ALPHA is a neutral outline badge (was blue-tinted), new chat
  uses the speech-bubble icon, and the history clock is removed.
- Add the session bar (B4): a "New Chat" row under the header that opens
  Chat History (the history entry point the header clock used to be).
- Rename the sidebar tab label from "AI Agent" to "Comfy Agent" to match the
  panel title and branding.

Verified live against the Figma frames.
2026-07-07 14:47:38 -07:00
Nathaniel Parson Koroso
028b15c3c9 fix(agent): stop panel buttons rendering the UA grey buttonface
Tailwind Preflight is disabled host-wide (PrimeVue + litegraph coexistence),
so a raw <button> in the panel fell back to the browser default: a grey
buttonface fill and a 2px outset border. That is what made the suggested-
prompt rows, the header history/new-chat buttons, and the composer attach
button look like heavy grey cards instead of the clean rows in the Figma
frames.

Re-establish Preflight's button normalization (background transparent,
border 0 solid) in one @layer base rule scoped to #agent-panel-root and the
teleported reka surfaces (dialogs, dropdowns, drawer now carry .agent-scope).
The selector is wrapped in :where() so it stays at zero specificity and every
button's own bg-* / border utilities still win, so filled/bordered buttons
(the blue send button, bordered chips) render exactly as authored.

Verified live: no grey buttonface or outset border remains on any panel
button, and the send button keeps its accent background.
2026-07-07 14:24:33 -07:00
Nathaniel Parson Koroso
442b49b3e5 fix(agent): match the Figma empty state (mark color, title, prompt rows)
Bring the panel closer to the Figma design (it had been built to the
written requirements, not the frames):

- The Comfy mark renders in brand yellow (text-brand-yellow), not the
  generic blue accent, in both the header and the centered empty-state
  mark.
- The panel title is "Comfy Agent" (Figma) rather than "AI Agent" (the
  written doc, which disagreed with the design).
- Suggested prompts are light rows with a leading icon instead of heavy
  bordered cards, per Figma B5 ("leading icon + label"). The per-prompt
  icons are best-fit lucide icons pending the exact Figma icon set.

Typecheck, oxlint, and eslint clean; agent and core tests pass.
2026-07-07 14:00:48 -07:00
Nathaniel Parson Koroso
50d510f344 fix(agent): show the panel in local dev and fix the empty-state overflow
- Local dev visibility: the panel is gated on the PostHog flag
  agent-in-app-experience, but that flag lives in a cloud PostHog project
  the local dev build does not read, so the panel never appeared on
  localhost. Force the gate open when the mode is development, so the
  panel is visible on the dev server without wiring the flag; test and
  production builds still gate on it (fail-closed unchanged).

- Empty-state overflow: the centered Comfy mark made the empty state
  taller than the panel at the narrow default width, and justify-end
  pushed the overflow up into the header (the greeting overlapped the
  title). Use a scroll container with mt-auto so the mark + greeting
  center when they fit and the whole thing scrolls when they do not,
  instead of overlapping the header.

Flag-gate tests still pass with the mode guard; typecheck, oxlint, and
eslint clean.
2026-07-07 12:58:59 -07:00
Nathaniel Parson Koroso
2675779e20 fix(agent): fill the host sidebar so the panel resizes and pushes the canvas
Design B2 wants the panel to push the canvas and be drag-resizable (the
~30% / ~50% docks). The host sidebar already provides both - it is a
splitter panel (LiteGraphCanvasSplitterOverlay) that pushes the canvas
and drag-resizes, the same as the Assets panel. The agent panel was
fighting that by capping its own content width (max-w-md / max-w-2xl,
mx-auto): dragging the sidebar wider only added gutters instead of
widening the panel, and the Medium/Large toggle changed the cap rather
than the dock.

Remove the self-imposed width cap so the panel fills the resizable
sidebar, and drop the Medium/Large size toggle (the design struck the
width-toggle from B3; the host's native drag-resize replaces it). The
sizeMode prop drove nothing else - the empty-state layout never used it -
so this is a net removal: the toggle button, its emit chain across
AgentPanel and PanelHeader, the panelWidth computed, the now-unused cn
import, and the orphaned expand/collapse locale keys all go.

Typecheck, oxlint, eslint, and knip clean; 900 agent/core tests pass.
2026-07-07 12:06:42 -07:00
Nathaniel Parson Koroso
084fe439c0 fix(agent): match the design spec on branding and greeting
From a refresh of the V0 design spec (Notion "In-App Agent V0 - Design &
Requirements", Section A + B5) against the shipped panel:

- Branding: the design pins "the original Comfy mark" everywhere; the
  panel used a lucide sparkles placeholder. Swap to the square Comfy
  mark (icon-[comfy--comfy-c]) on all four surfaces - the sidebar tab,
  the top-bar "Ask Comfy Agent" button, the panel header title row, and
  the empty-state mark.
- Empty state: B5 requires a centered Comfy mark with a glow above the
  greeting; it was missing. Add it (accent-tinted, currentColor glow).
- Greeting: B5/Section A want the account first name ("Hello Jo,"); the
  panel rendered "Hello there," because the name was never wired. Pull
  the first name from useCurrentUser and pass it through to the greeting,
  falling back to the existing neutral default when there is no name.

Tests: assert the Comfy mark on both the tab registration and the top-bar
button; add a greeting-personalization test; loosen the e2e greeting
assertion to the stable "Hello" prefix so it survives personalization.

Typecheck, browser typecheck, oxlint, eslint, and knip clean; 1159 unit
tests pass.
2026-07-07 11:53:59 -07:00
Nathaniel Parson Koroso
42a7681a3a fix(agent): adopt the server's workflow id from the message ack
The server owns the workflow: it creates one when a message opens a
thread and returns its id in the 202 ack (confirmed by the live-captured
wire fixtures, where every postMessage ack carries the same
workflow_id). The panel previously minted its own session workflow via
POST /api/workflows before the first send, which was redundant and
carried a latent bug: resuming a persisted thread would mint a fresh
workflow whose id never matches the thread's real one, so every
draft_patch would be foreign-dropped and the canvas would never update.

Now sendMessage posts without a workflow id and binds the draft store to
ack.workflow_id (bind is idempotent for an unchanged id, so re-binding
per ack never wipes an in-progress draft). The whole minting subsystem
goes away: agentWorkflowBinding and its tests, the ensure-before-send
in the root, the mint-failure toast and its i18n key, and the
/api/workflows route mock in the e2e mocks. start() no longer baselines
the draft; resync still fires on reconnect and behind-heartbeats, both
of which no-op until a send has bound a workflow, matching the previous
effective behavior.

zAgentTurnAccepted declares the optional workflow_id it always carried
through passthrough. Session and root tests rebind via the ack; the
root draft-binding test proves ack id -> draft_patch -> loadGraphData
and that a foreign-workflow patch is ignored.

Net -248 lines. Typecheck, browser typecheck, oxlint, eslint, and knip
clean; 1158 unit tests pass.
2026-07-07 11:13:08 -07:00
Nathaniel Parson Koroso
fd83e792b4 fix(agent): apply final CORE review findings
Findings from a four-hat panel, a QA-coverage gate, and three fail-fast
gates, each fixed with its test:

- Button.vue uses the existing cva (object API) instead of the branch's
  class-variance-authority add, matching the rest of src; the root drops
  that dependency (apps/website keeps its own pre-existing use). shiki
  stays as the genuinely new dep for CodeBlock highlighting.
- copy-as-markdown now works: the active conversation registers as the
  one current history row, so the drawer is not empty mid-chat and the
  copy action has a target. Past sessions remain a documented V0 limit.
- onSend warns once via a toast when the session workflow cannot be
  minted, so the draft loop no longer fails silently while the send
  still proceeds unbound.
- the cloud e2e spec sources its suggested-prompt assertion from the
  bundled locale, so it cannot drift from the rendered prompts again.
- the thread id moves into the conversation store so it survives a
  panel remount (a sidebar toggle unmounts the panel), and the in-flight
  turn is cancelled on unmount; together these stop a reopened panel
  from splitting the transcript across two threads or leaving a turn
  billing unheard.
- remove dead surface: createWebSocketEventSource, the workflow
  binding's unused reset() and workflowName knob, an unused apply
  version parameter, four unreferenced i18n keys, and a stale comment.

Typecheck, browser typecheck, oxlint, eslint, and knip are clean; the
unit suite is 1163 passing.
2026-07-06 22:17:05 -07:00
Nathaniel Parson Koroso
4a6a877823 test(agent): add cloud e2e coverage for the panel
Three @cloud Playwright scenarios in browser_tests/tests/agent, using
the comfyPageFixture + webSocketFixture merge and typed mocks copied
from the live-captured wire fixtures:

- fail-closed flag gate: no sidebar tab or top-bar button without the
  PostHog flag; both appear with it (flag seeded via
  posthog_config.bootstrap.featureFlags with flags disabled-fetch, so
  the gate resolves synchronously and deterministically)
- a full streamed turn: composer submit posts the message, the 202 ack
  adopts the server message id, injected agent_thinking, tool-call,
  delta, and done frames render thinking, tool cards, and markdown
  (bold asserted as rendered strong)
- the draft loop: the first send mints the session workflow (mocked
  POST /api/workflows), the bound draft_patch applies through
  validateComfyWorkflow into the canvas, asserted by node count via
  window.app.graph

The spec sends before pushing draft frames because the draft store
drops patches for an unbound workflow id, matching the binding order in
AgentPanelRoot. Draft content is a minimal schema-valid v0.4 graph
(the raw captured draft omits the top-level version field the validator
requires first).

Runs in the cloud project only (the extension is tree-shaken from
non-cloud builds); listed 3/3 there and 0 in chromium. Browser
typecheck, eslint, and format clean.
2026-07-06 20:47:21 -07:00
Nathaniel Parson Koroso
f414df4d14 feat(agent): close the V0 P0 gaps and bind drafts to a session workflow
Acceptance items from the approved In-App Agent design doc, plus PM-98
and the coverage-audit gaps:

- suggested prompts carry the five exact approved labels
- an Ask Comfy Agent top-bar button registers next to the feedback
  button, present only while the flag gate has the tab registered (same
  fail-closed source, reactive mirror ref); click toggles the sidebar
  tab
- attach is wired end-to-end: paperclip opens a picker (image/video),
  files upload through rest.uploadImage via useAttachment (20MB guard,
  failure does not stage), staged chips carry the server ref into the
  message attachments; the STAGED marker drops from useAttachment
- drafts bind to a session workflow: agentWorkflowBinding mints a
  server workflow (single-flight POST /api/workflows with the current
  canvas graph, tolerant id parse, degrade-to-unbound on any failure)
  and the root awaits ensure() before the first send so messages carry
  workflow_id and draft_patch events resolve against a bound draft; the
  FE has no host uuid surface today (path-keyed workflow classes,
  /api/workflows unused in src), so the binding mirrors the backend's
  own flow with a TODO to adopt the host id when one exists
- a muted composer caption states the agent-writes/user-runs contract
  (copy pending design sign-off, one-line locale swap)
- a scroll-to-latest pill floats over the conversation when scrolled up
- history copy-as-markdown works for the active conversation via a pure
  transcript builder; other sessions toast the V0 limitation
- message ratings capture app:agent_message_feedback through a typed
  telemetry method (types, registry dispatch, PostHog implementation),
  null vote forwarded as a retraction for the eval pipeline
- coverage gaps closed: DST-boundary recency bucketing, history store
  upsert/remove/setActive behaviors, stopTurn network-error notice,
  direct useComposer suite; the session error counter moves off module
  scope into the instance; the dead null-tab rerender line becomes a
  real assertion

Typecheck clean, full suite 12588 passed, oxlint, eslint, and knip
green on these files. 27 tests added.
2026-07-06 20:40:11 -07:00
Nathaniel Parson Koroso
b82d7d5c3a fix(agent): apply CORE panel pass-1 findings
Review fixes from the four-hat panel, each independently specified:

- the event source now follows the host socket across reconnects:
  createReconnectingEventSource rebinds message and open/close listeners
  to the fresh api.socket on the host reconnected event, reports live on
  an already-open rebind, warns once on a null socket and attaches on
  the first reconnect; AgentPanelRoot uses it in place of the
  instance-bound adapter (the panel no longer goes deaf after a routine
  /ws reconnect)
- the fail-closed flag gate in the extension entry gains a four-case
  test suite modeled on the previewAny sibling (no register while the
  flag is unknown, register once on true, unregister on flip-off, no
  double-register across toggles)
- session notices forward to the host toast store (new-entries-only
  watch), making cancel failures and malformed-event warnings visible;
  covered by a mount test driving a malformed frame through a fake
  socket
- the five staged M4 surfaces (tab context, attachments, canvas
  selection) carry explicit STAGED markers and the subtree gains a
  README recording them plus the intentional local ui-primitives
  decision
- onboarding coach copy moves to i18n keys; the storage key and root id
  drop their dev-harness names (Comfy.AgentPanel.onboarded,
  agent-panel-root)
- the composer paperclip hides behind a default-false canAttach prop
  until an attach flow is wired

The workflowId provider (panel-to-draft binding) is intentionally NOT
wired: no host surface exposes the server-side workflow uuid (the
workflow class is path-keyed; the uuid appears only in the agent's own
202 ack and per-job queue fields). Recorded as an open product/plumbing
decision.

Typecheck clean, full suite 12561 passed, oxlint and eslint clean, no
new knip findings from these files.
2026-07-06 20:05:07 -07:00
Nathaniel Parson Koroso
083cb95f1f refactor(agent): dissolve the agent-panel package into the workbench subtree
The panel now lives at src/workbench/extensions/agent (manager-extension
pattern) on host package versions, host pinia, and host vue-i18n; the
workspace package is gone. The sidebar tab becomes a plain type vue
registration (AgentPanelRoot rendered by the host app) and the
fail-closed PostHog flag gate in the extension entry is unchanged.

AgentPanelRoot is the host-context session root: workspace Cloud JWT as
the REST bearer, the host /ws socket wrapped as the event source (null
socket degrades to a warned no-op source), draft apply as a
schema-validated full-graph load, and the chrome the dev harness
carried (history drawer, starting-point modal, onboarding coach with
the same storage key and anchor) minus the dev fakes.

Theme tokens route through host semantics instead of hardcoded values:
the feature-owned agentTheme.css aliases every agent-* token to a host
token via @theme inline (pairing table documented inline), so the panel
tracks host theme flips including light mode, an intentional divergence
from the dark-only Figma frames deferred to the FE-1187 design pass.
Literals remain only for accent-fg, pill, and radius, each flagged as a
design-review item. The design-system master is untouched; the host
style entry gains one import line.

Locale keys merge under a top-level agent block in the host main.json.
cn comes from @comfyorg/tailwind-utils. shiki and
class-variance-authority join the catalog and root deps (CodeBlock
highlighting, Button variants). Dead-in-src modules are deleted rather
than knip-ignored: the shelved agentCore kernel and its tests, the
MinimizedBall dock (superseded by the sidebar form factor), and six
never-wired components knip flagged with zero consumers; unused schema
type exports trimmed likewise.

Gates: typecheck clean, moved tree 147/147 (197 minus the deleted
kernel's 50 cases; no surviving test lost), full unit suite 12550
passed, oxlint, eslint, oxfmt, and knip all green. Handoff invariants
re-verified in the moved tree: fixture schema gate, event transport
acceptance, draft monotonic adoption and canvas-apply seam, fail-closed
gate, entries interleave, send/stop/new-chat paths.

Known follow-ups (FE-1187): browser visual pass on the aliased tokens
and light mode, socket-reconnect rebinding, workflowId provider for
draft binding, design alignment.
2026-07-06 19:38:39 -07:00
Nathaniel Parson Koroso
c11f28f363 feat(agent-panel): register the agent side panel as a flag-gated extension
The panel now runs the way everything user-facing runs here: as an
extension. src/extensions/core/agentPanel.ts (cloud-only, loaded from
the core extension index) registers a sidebar tab gated by the PostHog
flag agent-in-app-experience, fail-closed: the tab is not registered
until the flag evaluates true and unregisters if it turns off. The tab
is type custom: it mounts the panel as its own Vue app via the new
mountAgentPanel entry, which keeps the package's vue-i18n major (11)
and pinia instance isolated from the host (vue-i18n 9). Host wiring in
the entry: workspace Cloud JWT as the REST bearer (store instance
captured in host context, since pinia's active-instance global is
shared with the panel app), the host /ws socket wrapped by
createWebSocketEventSource, and draft apply as a schema-validated
full-graph load (validateComfyWorkflow then app.loadGraphData) per the
V0 tech design.

To make the package importable from src, it is properly library-ized:
all internal @/ alias imports rewritten to relative paths (the alias
would have resolved against the HOST src when host tooling compiles the
package sources), the alias config dropped, and a public entry exposed
via package.json exports (mount + the host-facing seam builders and
types). Root package.json declares the workspace dependency like every
other @comfyorg package.

Known follow-ups tracked in FE-1187: socket-reconnect rebinding for the
event source, confirming agent_* frames reach raw socket listeners,
the workflowId provider for draft binding, panel styling inside the tab
(tailwind theme integration), and the design-alignment pass.

Package gates green (typecheck, 197 tests, build); host root typecheck
green with the entry included.
2026-07-06 19:38:39 -07:00
Nathaniel Parson Koroso
3d1c489b34 feat(agent-panel): relocate the In-App Agent panel as a workspace package
Bulk copy of the panel from Comfy-Org/comfy-inapp-agent (branch
feat/agent-panel-services at b3675fc) into packages/agent-panel
(@comfyorg/agent-panel), per the decision to home the panel in this
repo. The package is self-contained with its own dependency set and
configs; tooling versions align with the workspace catalog (vite 8,
vitest 4, zod ^3.23.8, typescript 5.9, vue-tsc 3.2), so the code
relocates verbatim. Excluded from the copy: the standalone project's
lockfile and nested workspace marker; the root allowBuilds already
carries vue-demi: false. The unused @ai-sdk/vue dependency was dropped.

Contents: the comfy-agent v1 client stack (zod wire contract with
live-captured fixtures, REST client for ingest /api/agent/*, WebSocket
event transport and event-source adapter, server-draft store plus the
useDraftCanvasApply seam, useAgentSession composition root), the
fail-closed PostHog gate for the agent-in-app-experience flag, the full
chat panel UI (message stream, composer, chrome, shelved safety
surfaces), i18n, and a dev harness with an offline scripted mode and a
live proxy mode verified against pr-4432.testenvs.comfy.org.

Package gates green in the workspace: typecheck clean, 197 tests
passing, build succeeds.
Next (B8): mount the panel in the app, wire the host socket, JWT,
PostHog client, and canvas draft-apply, then cut over from the legacy
extension.
2026-07-06 19:38:39 -07:00
93 changed files with 9926 additions and 153 deletions

View File

@@ -0,0 +1,211 @@
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'
/**
* In-App Agent panel e2e coverage (FE-1187).
*
* The panel is a CLOUD-ONLY workbench extension: `src/extensions/core/index.ts`
* imports `agentPanel.ts` only under `if (isCloud)`, and `isCloud` is the
* build-time `__DISTRIBUTION__ === 'cloud'` define. It therefore exists only in
* the cloud build, which the `cloud` Playwright project runs against
* (`frontend-dist-cloud` in CI; `playwright.config.ts` gives that project
* `grep: /@cloud/` while every other project greps it out). These specs are
* tagged `@cloud` so they run there and nowhere else. The `comfyPageFixture`
* itself already installs the cloud Firebase-auth mock for any `@cloud` test and
* boots the app, so tests only arrange agent mocks and act.
*
* The panel docks on the right and opens from the top-bar "Ask Comfy Agent"
* button (not a sidebar tab). It is gated FAIL-CLOSED by the PostHog flag
* `agent-in-app-experience`: `agentPanel.ts` reads `posthog.isFeatureEnabled`
* and only exposes that button once it is `true`. PostHog only initializes
* when `/api/features` supplies a `posthog_project_token`, so the mocks seed both
* the token and the flag (through PostHog `bootstrap.featureFlags`, which resolves
* the read synchronously with no `/decide` network call — see `agentPanelMocks.ts`).
* `agentFlagEnabled` (default true) is a fixture option a test flips off to model
* the fail-closed default.
*
* REST is mocked via `page.route` (POST messages -> 202, GET draft snapshot) and
* server->client WS frames are injected with the repo's `webSocketFixture`
* (`context.routeWebSocket(/\/ws/)`): `ws.send(JSON.stringify({type, data}))`
* pushes a frame onto the app's shared `/ws`, which is exactly where the panel's
* reconnecting event source listens.
*/
type AgentFixtures = {
agentFlagEnabled: boolean
postedMessages: string[]
}
// Override the `page` fixture to install the agent boot + REST mocks, the same
// pattern as workspaceSwitcher.spec.ts: `comfyPage` depends on `page`, so the
// override is guaranteed to finish before `comfyPage` navigates and boots the
// app. A sibling fixture carries no such ordering, so its mocks can land after
// boot and the real server answers /api/features (no PostHog token, gate stays
// closed).
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)
// The panel opens from the top-bar action button, exposed with aria-label = tooltip.
const OPEN_AGENT_LABEL = enMessages.agent.askComfyAgent
// Push one agent WS event onto the app's /ws in the ComfyUI envelope shape.
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)
// Fail-closed: with the PostHog flag false the top-bar button is never
// exposed, so the panel cannot be opened.
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
}) => {
// Boot + open + full turn can exceed the cloud project's 15s budget on a
// loaded CI runner; same widening precedent as versionMismatchWarnings:91.
test.setTimeout(30_000)
const page = comfyPage.page
// Flag on: the top-bar button is exposed. Open the panel.
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()
// Empty state: greeting + question and the five suggested-prompt chips. The greeting
// personalizes to the account's first name, so match the stable prefix, not a fixed name.
await expect(panel.getByText(/^Hello/)).toBeVisible()
await expect(panel.getByText('What do you want to make?')).toBeVisible()
// Sourced from the bundled locale so the spec cannot drift from the rendered prompts.
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' })
// Clicking a suggested prompt INSERTS it into the composer, it does not 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)
// Submitting sends the composed message: POST /api/agent/threads/new/messages.
const ws = await getWebSocket()
await sendButton.click()
await expect.poll(() => postedMessages.length).toBeGreaterThanOrEqual(1)
expect(postedMessages[0]).toContain(firstPrompt)
// The composer clears once the send is accepted.
await expect(composer).toHaveValue('')
// agent_thinking -> the "Thinking..." status is visible.
pushEvent(ws, THINKING_EVENT)
await expect(panel.getByText('Thinking...')).toBeVisible()
// agent_tool_call (ok) -> a tool-call group card appears ("Ran 1 tool call").
pushEvent(ws, TOOL_CALL_EVENT)
await expect(panel.getByText('Ran 1 tool call')).toBeVisible()
// agent_message_delta with markdown -> rendered agent message; the **bold**
// run renders as <strong>.
pushEvent(ws, MESSAGE_DELTA_EVENT)
await expect(
panel.locator('strong', { hasText: 'fully ready' })
).toBeVisible()
// Text arriving clears the thinking chip.
await expect(panel.getByText('Thinking...')).toBeHidden()
// agent_message_done -> the turn settles: the primary button returns to Send
// (it is Stop while streaming).
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
}) => {
// Boot + open + full turn can exceed the cloud project's 15s budget on a
// loaded CI runner; same widening precedent as versionMismatchWarnings:91.
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()
// The draft store only adopts a draft_patch whose workflow_id matches the
// server's workflow. The server returns that id in the message ack, and the
// panel binds the draft store to it. So send once to establish the bind before
// pushing the patch. A draft_patch is otherwise NEVER turn-filtered.
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 })
// The two-node draft graph is validated and handed to app.loadGraphData, so
// the canvas graph must reflect it.
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

@@ -0,0 +1,291 @@
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'
/**
* Typed mocks for the In-App Agent panel e2e spec. Every REST body and WS frame
* is annotated with the shared agent contract types
* (`@/workbench/extensions/agent/schemas/agentApiSchema`) and the workflow schema,
* so a wire-shape drift is a compile error here, not a flaky runtime failure. The
* payload values are copied from the captured fixtures under
* `src/workbench/extensions/agent/schemas/__fixtures__/agent/`.
*/
// The turn everything in scenario 2 & 3 is addressed by. The panel adopts this
// server-minted message_id (from the POST 202 ack) as the TurnId that keys the
// assistant message the WS frames stream into.
const THREAD_ID = 'd4c016c4-3b8c-44cf-97de-1ae27e43e718'
const TURN_ID = '3818ba00-d772-4a3f-98c1-9312725b577d'
const WORKFLOW_ID = 'a81718a4-02ae-41e6-ae85-c33b7bb880f6'
// The 202 body the panel's postMessage('new') expects: {thread_id, message_id}
// plus the captured additive workflow_id (the schema is passthrough).
const TURN_ACCEPTED: AgentTurnAccepted = {
message_id: TURN_ID,
thread_id: THREAD_ID,
workflow_id: WORKFLOW_ID
}
/**
* A minimal schema-valid v0.4 workflow. `validateComfyWorkflow` first parses a
* top-level `version: number` (the captured draft graphs omit it, so they would
* be rejected before ever reaching the canvas); this shape carries `version`,
* `last_node_id`, `last_link_id`, `nodes` and `links` so it survives validation
* and `app.loadGraphData` actually applies it. Two nodes so the assertion on the
* canvas node count is unambiguous.
*/
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: []
}
// The GET /api/agent/draft snapshot body: {content, version}. content rides the
// wire as an opaque object; the panel re-validates it before loading.
const DRAFT_SNAPSHOT: AgentDraftSnapshot = {
content: DRAFT_GRAPH as unknown as Record<string, unknown>,
version: 24
}
// A draft_patch WS event whose content is the minimal valid graph above. The panel
// adopts monotonically, so the version must exceed any snapshot already adopted.
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
}
// The chat-turn WS frames for scenario 2, in arrival order. Each rides the ComfyUI
// envelope {type, data}; values copied from ws-turn-ask-run.jsonl but retargeted to
// TURN_ID so they land on the turn the POST ack opened.
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
}
}
// Markdown delta: the **bold** run renders as <strong> through the sanitizing
// markdown pipeline, which the spec asserts on.
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
}
}
}
/**
* `/api/features` payload that boots the cloud telemetry provider with PostHog AND
* turns the agent gate on. PostHog only initializes when a `posthog_project_token`
* is present, and the panel's fail-closed gate reads `posthog.isFeatureEnabled`;
* seeding the flag through PostHog's `bootstrap.featureFlags` makes that read
* resolve `true` synchronously at init with no `/decide` round-trip, so the gate is
* deterministic. Omit `agentFlag` (or pass false) to model the fail-closed default.
*/
function agentFeatures(agentFlag: boolean): RemoteConfig {
return {
team_workspaces_enabled: true,
posthog_project_token: 'phc_e2e_agent_panel',
posthog_config: {
// Never let posthog-js reach its flags endpoint in the test env; bootstrap
// alone supplies the flag value the gate reads.
advanced_disable_flags: true,
bootstrap: {
featureFlags: { 'agent-in-app-experience': agentFlag }
}
}
}
}
/**
* Boots the cloud app against fully mocked boot + agent REST endpoints. Mirrors
* `CloudWorkspaceMockHelper.mockBoot`, adding the agent's own REST surface
* (`postMessage`, `getDraft`). Records POST bodies into the caller's
* `postedMessages` so the spec can assert the composed message reached the wire.
* Auth (Firebase) is mocked by the caller via `CloudAuthHelper` before navigation.
*/
export async function mockAgentBoot(
page: Page,
{
agentFlag,
postedMessages
}: { agentFlag: boolean; postedMessages: string[] }
): Promise<void> {
// Mark the coach as already seen: its full-viewport scrim otherwise blocks
// every click on the freshly-opened panel (fresh storage in CI).
await page.addInitScript(() => {
localStorage.setItem('Comfy.AgentPanel.onboarded', 'true')
})
// Billing + assets answer real requests in CI; unmocked they reject and their
// error toasts stack over the docked panel, swallowing clicks.
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' }
})
)
)
// TutorialCompleted marks the user as returning so the new-user Templates dialog
// never auto-opens over the sidebar rail; errors tab off suppresses a 401 toast.
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' }))
)
// Must satisfy WorkspaceTokenResponseSchema (workspaceAuthStore.ts): a bare
// {token} fails validation, and the auth recovery/retry loop then toasts
// over the docked panel, swallowing clicks.
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'
}
]
})
)
)
// POST /api/agent/threads/new/messages -> 202 {thread_id, message_id, workflow_id}.
// The server owns the workflow and returns its id in this ack; the panel binds the
// draft store to it and then only adopts draft_patch events whose workflow_id
// matches. Record the body so the spec can assert the composed text was sent, not
// just that a request fired. GET on the same path (history hydration) is empty.
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))
)
}

88
pnpm-lock.yaml generated
View File

@@ -3461,32 +3461,32 @@ packages:
pinia:
optional: true
'@shikijs/core@4.1.0':
resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==}
'@shikijs/core@4.3.1':
resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==}
engines: {node: '>=20'}
'@shikijs/engine-javascript@4.1.0':
resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==}
'@shikijs/engine-javascript@4.3.1':
resolution: {integrity: sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==}
engines: {node: '>=20'}
'@shikijs/engine-oniguruma@4.1.0':
resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==}
'@shikijs/engine-oniguruma@4.3.1':
resolution: {integrity: sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==}
engines: {node: '>=20'}
'@shikijs/langs@4.1.0':
resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==}
'@shikijs/langs@4.3.1':
resolution: {integrity: sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==}
engines: {node: '>=20'}
'@shikijs/primitive@4.1.0':
resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==}
'@shikijs/primitive@4.3.1':
resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==}
engines: {node: '>=20'}
'@shikijs/themes@4.1.0':
resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==}
'@shikijs/themes@4.3.1':
resolution: {integrity: sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==}
engines: {node: '>=20'}
'@shikijs/types@4.1.0':
resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==}
'@shikijs/types@4.3.1':
resolution: {integrity: sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==}
engines: {node: '>=20'}
'@shikijs/vscode-textmate@10.0.2':
@@ -7918,8 +7918,8 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shiki@4.1.0:
resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==}
shiki@4.3.1:
resolution: {integrity: sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==}
engines: {node: '>=20'}
side-channel-list@1.0.0:
@@ -8884,8 +8884,8 @@ packages:
vue-component-type-helpers@3.3.2:
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
vue-component-type-helpers@3.3.5:
resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==}
vue-component-type-helpers@3.3.7:
resolution: {integrity: sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -9331,7 +9331,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.2.6
lru-cache: 11.5.1
'@asamuzakjp/dom-selector@6.7.6':
dependencies:
@@ -9365,7 +9365,7 @@ snapshots:
js-yaml: 4.1.1
picomatch: 4.0.4
retext-smartypants: 6.2.0
shiki: 4.1.0
shiki: 4.3.1
smol-toml: 1.6.1
unified: 11.0.5
@@ -11528,40 +11528,40 @@ snapshots:
optionalDependencies:
pinia: 3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))
'@shikijs/core@4.1.0':
'@shikijs/core@4.3.1':
dependencies:
'@shikijs/primitive': 4.1.0
'@shikijs/types': 4.1.0
'@shikijs/primitive': 4.3.1
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@4.1.0':
'@shikijs/engine-javascript@4.3.1':
dependencies:
'@shikijs/types': 4.1.0
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.6
'@shikijs/engine-oniguruma@4.1.0':
'@shikijs/engine-oniguruma@4.3.1':
dependencies:
'@shikijs/types': 4.1.0
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@4.1.0':
'@shikijs/langs@4.3.1':
dependencies:
'@shikijs/types': 4.1.0
'@shikijs/types': 4.3.1
'@shikijs/primitive@4.1.0':
'@shikijs/primitive@4.3.1':
dependencies:
'@shikijs/types': 4.1.0
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/themes@4.1.0':
'@shikijs/themes@4.3.1':
dependencies:
'@shikijs/types': 4.1.0
'@shikijs/types': 4.3.1
'@shikijs/types@4.1.0':
'@shikijs/types@4.3.1':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
@@ -11668,7 +11668,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.5
vue-component-type-helpers: 3.3.7
'@swc/helpers@0.5.21':
dependencies:
@@ -12979,7 +12979,7 @@ snapshots:
picomatch: 4.0.4
rehype: 13.0.2
semver: 7.7.4
shiki: 4.1.0
shiki: 4.3.1
smol-toml: 1.6.1
svgo: 4.0.1
tinyclip: 0.1.13
@@ -16996,14 +16996,14 @@ snapshots:
shebang-regex@3.0.0: {}
shiki@4.1.0:
shiki@4.3.1:
dependencies:
'@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/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/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
@@ -18138,7 +18138,7 @@ snapshots:
vue-component-type-helpers@3.3.2: {}
vue-component-type-helpers@3.3.5: {}
vue-component-type-helpers@3.3.7: {}
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
dependencies:

View File

@@ -1,4 +1,7 @@
@import '@comfyorg/design-system/css/style.css';
/* In-App Agent panel (FE-1187) theme: aliases agent-* tokens onto host semantic
variables. Imported after the design-system so those host variables exist. */
@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,133 +1,159 @@
<template>
<div
class="pointer-events-none absolute top-0 left-0 z-999 flex size-full flex-col"
class="pointer-events-none absolute top-0 left-0 z-999 flex size-full flex-row"
>
<slot name="workflow-tabs" />
<!-- Left column: workflow tabs + canvas/panels. The agent dock is a sibling so it
spans the full viewport height beside the tab bar. -->
<div
class="pointer-events-none flex flex-1 overflow-hidden"
:class="{
'flex-row': sidebarLocation === 'left',
'flex-row-reverse': sidebarLocation === 'right'
}"
class="pointer-events-none flex min-w-0 flex-1 flex-col overflow-hidden"
>
<div class="side-toolbar-container">
<slot name="side-toolbar" />
</div>
<slot name="workflow-tabs" />
<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"
<div
class="pointer-events-none flex flex-1 overflow-hidden"
:class="{
'flex-row': sidebarLocation === 'left',
'flex-row-reverse': sidebarLocation === 'right'
}"
>
<!-- 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
<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"
>
<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'
)
<!-- 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
"
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>
<slot
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
name="side-bar-panel"
/>
<slot
v-else-if="sidebarLocation === 'right'"
name="right-side-panel"
/>
</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'
<!-- 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'
)
: '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>
"
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>
<!-- Right column: the agent dock, full viewport height, pixel width with a drag
handle on its left edge (420-960px; the panel header icon jumps between the
extremes). -->
<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" />
</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 } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppMode } from '@/composables/useAppMode'
@@ -142,11 +168,13 @@ 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')
@@ -162,6 +190,37 @@ 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)
// The agent dock renders only while its flag gate is on and the user opened it.
const agentPanelDocked = computed(
() => agentPanelEnabled.value && agentPanelOpen.value
)
// Drag-resize for the agent dock: capture the pointer on the handle and track the delta;
// the store clamps into its [min, max] range.
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
)
@@ -304,4 +363,10 @@ 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,6 +38,15 @@
<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"
@@ -111,6 +120,7 @@
import { until, useEventListener } from '@vueuse/core'
import {
computed,
defineAsyncComponent,
nextTick,
onMounted,
onUnmounted,
@@ -197,6 +207,12 @@ import { forEachNode } from '@/utils/graphTraversalUtil'
import SelectionRectangle from './SelectionRectangle.vue'
import { useUrlActionLoaders } from '@/composables/useUrlActionLoaders'
// Async so the flag-gated agent subtree stays out of the GraphCanvas chunk;
// it loads on first dock (the slot renders only while the panel is open).
const AgentPanelRoot = defineAsyncComponent(
() => import('@/workbench/extensions/agent/AgentPanelRoot.vue')
)
const { t } = useI18n()
const emit = defineEmits<{
ready: []

View File

@@ -150,6 +150,24 @@ 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'
}
}
})
// Agent failures have no catalog remediation copy; the raw details are the
// only actionable signal, so they surface on the card.
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

@@ -62,6 +62,10 @@ 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.',
@@ -621,6 +625,25 @@ 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

@@ -45,6 +45,13 @@ import {
const PROMPT_CARD_ID = '__prompt__'
// Agent prompt errors carry the actionable failure text in `details`; host
// prompt errors keep details off the card (their catalog copy is the message).
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__'
@@ -358,6 +365,9 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
errors: [
{
message: error.message,
...(AGENT_PROMPT_ERROR_TYPES.has(error.type)
? { details: error.details }
: {}),
...resolvedDisplay
}
]

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, reactive } from 'vue'
import { defineComponent, h, reactive, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
@@ -16,6 +16,8 @@ const distribution = vi.hoisted(() => ({
const tabBarLayout = vi.hoisted(() => ({ value: 'Default' }))
const agentPanel = vi.hoisted(() => ({ enabled: false, toggle: vi.fn() }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distribution.isCloud
@@ -79,6 +81,16 @@ vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => ({ shiftDown: false })
}))
// Ref-backed so the component's storeToRefs picks the fields up like a real store.
vi.mock('@/workbench/extensions/agent/stores/agent/agentPanelStore', () => ({
useAgentPanelStore: () =>
reactive({
isOpen: ref(false),
enabled: ref(agentPanel.enabled),
toggle: agentPanel.toggle
})
}))
vi.mock('@/utils/mouseDownUtil', () => ({
whileMouseDown: vi.fn()
}))
@@ -188,3 +200,26 @@ describe('WorkflowTabs feedback button', () => {
).not.toBeInTheDocument()
})
})
describe('WorkflowTabs agent entry button', () => {
beforeEach(() => {
distribution.isCloud = false
distribution.isDesktop = false
distribution.isNightly = false
tabBarLayout.value = 'Default'
agentPanel.enabled = true
agentPanel.toggle.mockClear()
})
afterEach(() => {
agentPanel.enabled = false
})
it('renders the Ask Comfy Agent CTA and toggles the panel on click', async () => {
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: 'Ask Comfy Agent' }))
expect(agentPanel.toggle).toHaveBeenCalledTimes(1)
})
})

View File

@@ -84,6 +84,24 @@
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 }"
@@ -106,7 +124,9 @@
</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'
@@ -126,6 +146,8 @@ 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'
@@ -145,6 +167,16 @@ 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

@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyExtension } from '@/types/comfy'
// Per-test mock state, hoisted so the vi.mock factories below may close over it.
// The extension's only job is the fail-closed flag gate: it flips the panel store's
// `enabled` and closes an open panel when the flag turns off (the tab-bar button and the
// dock both key off the store). The posthog mock exposes isFeatureEnabled (whose return
// the gate reads) and onFeatureFlags (which captures the reload listener so the test can
// drive flag transitions).
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])
// setup fires setupFlagGate, which awaits the (mocked) posthog-js dynamic import before
// it captures the onFeatureFlags listener and runs the initial sync(). Flush macrotasks
// until that listener is registered so tests can drive flag transitions through it.
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).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,39 @@
import { createPostHogFlagSource } from '@/workbench/extensions/agent/composables/agent/useAgentFeatureGate'
import { useAgentPanelStore } from '@/workbench/extensions/agent/stores/agent/agentPanelStore'
import { useExtensionService } from '@/services/extensionService'
/**
* In-App Agent panel (FE-1187): the panel docks as a full-viewport-height right column
* (the splitter overlay's agent-panel slot) and opens from the workflow-tab-bar "Ask
* Comfy Agent" button. Gated by the PostHog flag `agent-in-app-experience`, fail-closed:
* the button is absent and the panel unreachable until the flag evaluates true, and both
* hide again (closing an open panel) if the flag turns off.
*/
useExtensionService().registerExtension({
name: 'Comfy.AgentPanel',
setup() {
const agentPanelStore = useAgentPanelStore()
async function setupFlagGate(): Promise<void> {
// posthog-js is initialized (or not) by the telemetry provider; an
// uninitialized client answers isFeatureEnabled with undefined, which the
// flag source maps to false, so the gate stays closed by default.
const posthog = (await import('posthog-js')).default
const source = createPostHogFlagSource(posthog)
const sync = (): void => {
// The dev server shows the panel without the PostHog flag (which is scoped to a cloud
// project the dev build may not read); test and production builds still gate on it.
const forceInDev = import.meta.env.MODE === 'development'
const enabled = forceInDev || source.isEnabled()
agentPanelStore.enabled = enabled
// Fail-closed: a flag flip-off also closes an open panel.
if (!enabled) agentPanelStore.close('flag_disabled')
}
source.onChange?.(sync)
sync()
}
void setupFlagGate()
}
})

View File

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

View File

@@ -4316,6 +4316,14 @@
"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."
}
}
},
@@ -4496,5 +4504,70 @@
"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."
}
}

View File

@@ -555,6 +555,23 @@ 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,7 +13,8 @@ const KNOWN_PROMPT_ERROR_TYPES = new Set([
'no_prompt',
'server_error',
'missing_node_type',
'prompt_outputs_failed_validation'
'prompt_outputs_failed_validation',
'agent_draft_apply_failed'
])
function getPromptExceptionMessage(

View File

@@ -2,6 +2,13 @@ import type { AuditLog } from '@/services/customerEventsService'
import type {
AddCreditsClickMetadata,
AgentEntryButtonClickedMetadata,
AgentMessageFeedbackMetadata,
AgentMessageSentMetadata,
AgentNodeTaggedMetadata,
AgentPanelClosedMetadata,
AgentPanelOpenedMetadata,
AgentWorkflowAppliedMetadata,
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
@@ -283,6 +290,46 @@ 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

@@ -11,6 +11,13 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
AddCreditsClickMetadata,
AgentEntryButtonClickedMetadata,
AgentMessageFeedbackMetadata,
AgentMessageSentMetadata,
AgentNodeTaggedMetadata,
AgentPanelClosedMetadata,
AgentPanelOpenedMetadata,
AgentWorkflowAppliedMetadata,
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
@@ -564,6 +571,44 @@ 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

@@ -375,6 +375,51 @@ 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'
}
/**
* Help center opened metadata
*/
@@ -615,6 +660,19 @@ 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
}
@@ -717,6 +775,17 @@ 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
@@ -766,6 +835,13 @@ export type TelemetryEventProperties =
| HelpCenterOpenedMetadata
| HelpResourceClickedMetadata
| HelpCenterClosedMetadata
| AgentMessageFeedbackMetadata
| AgentPanelOpenedMetadata
| AgentPanelClosedMetadata
| AgentEntryButtonClickedMetadata
| AgentMessageSentMetadata
| AgentNodeTaggedMetadata
| AgentWorkflowAppliedMetadata
| WorkflowCreatedMetadata
| EnterLinearMetadata
| ShareFlowMetadata

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,545 @@
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useTelemetry } from '@/platform/telemetry'
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'
// 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/safetyTypes'
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 { AgentThreadSummary } from './schemas/agentApiSchema'
import type { ChatSession } from './stores/agent/agentChatHistoryStore'
import type { WorkflowTurnContext } from './composables/agent/useAgentSession'
import { useAgentSession } from './composables/agent/useAgentSession'
import { useDraftCanvasApply } from './composables/agent/useDraftCanvasApply'
import { useAgentDraftStore } from './stores/agent/agentDraftStore'
import { useAgentWorkflowTabBindingStore } from './stores/agent/agentWorkflowTabBindingStore'
import { buildTranscriptMarkdown } from './services/agent/agentTranscript'
import { createAgentRestClient } from './services/agent/agentRestClient'
import type { WorkflowUpload } 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()
// Rides the api's own typed /ws dispatch (which survives socket reconnects), so the
// panel is not left deaf after a reconnect and each frame is JSON-parsed only once.
const events = createAgentEventSource(api)
const workflowStore = useWorkflowStore()
const bindingStore = useAgentWorkflowTabBindingStore()
const draftStore = useAgentDraftStore()
const agentPanelStore = useAgentPanelStore()
// B7 @-tagging: selected nodes stage removable chips; consume() on send clears
// them and the same selection does not re-tag until it changes.
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
})
// The graph the canvas is showing (root or an open subgraph); the deprecated
// app.graph getter only ever returns root, which misses subgraph-inner nodes.
function viewedGraphNodes() {
return app.canvas?.graph?.nodes ?? app.graph?.nodes ?? []
}
// The @ picker lists the viewed graph's nodes, computed on open (not watched).
function mentionableNodes(): SelectedNode[] {
return viewedGraphNodes().map((node) => ({
id: String(node.id),
title: node.title || node.type
}))
}
// A bound tab resolves to its id; a cloud-saved tab offers its persisted uuid
// speculatively (the server 403s foreign ids and the send retries without).
function activeWorkflowTurnContext(): WorkflowTurnContext | undefined {
const active = workflowStore.activeWorkflow
if (!active) return undefined
const bound = bindingStore.workflowIdFor(active.path)
if (bound !== undefined)
return { id: bound, speculative: false, tabPath: active.path }
const savedId = active.activeState?.id
return typeof savedId === 'string'
? { id: savedId, speculative: true, tabPath: active.path }
: undefined
}
// B17/Jo QA: the panel names the tab the agent acts on.
const activeTab = computed<ActiveTab | null>(() => {
const active = workflowStore.activeWorkflow
return active ? { name: active.filename } : null
})
// The turn runs against the canvas as of the send: upload the serialized
// graph when it changed since the last upload so the server can seed the
// thread's draft from it (cloud postMessage.workflow contract; servers
// without the field ignore it). Oversized graphs are skipped, matching the
// server's 413 limit.
const MAX_UPLOAD_CHARS = 2_000_000
let lastSentGraph: string | null = null
let snapshotTabPath: string | null = null
function takeWorkflowSnapshot(): WorkflowUpload | undefined {
const graph = app.graph?.serialize()
if (!graph) return undefined
const serialized = JSON.stringify(graph)
if (serialized === lastSentGraph || serialized.length > MAX_UPLOAD_CHARS)
return undefined
lastSentGraph = serialized
snapshotTabPath = workflowStore.activeWorkflow?.path ?? null
return { graph, last_seen_version: draftStore.version }
}
// A fresh thread gets a fresh draft on the server: re-upload on next send.
function resetSnapshotGuard(): void {
lastSentGraph = null
snapshotTabPath = null
}
// Bind when the server confirmed the id we sent for that tab, or when the
// send uploaded this tab's canvas: the draft then mirrors the tab, so even a
// freshly minted id applies in place instead of opening a new tab.
function onWorkflowAdopted(
workflowId: string,
sent: WorkflowTurnContext | undefined,
uploaded: boolean
): void {
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,
snapshot: takeWorkflowSnapshot
}
})
// Every agent error goes through the ONE existing host error modal — no
// bespoke toasts.
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
}
)
// The draft rides the wire untyped; validate through the host schema. A failed
// apply is a workflow error, surfaced once per failure streak.
let draftRejectionNotified = false
function surfaceDraftApplyFailure(details: string): void {
console.warn(details)
if (draftRejectionNotified) return
draftRejectionNotified = true
surfaceAgentError('agent_draft_apply_failed', details)
}
// B16 graph write: patches route to their workflow_id's bound tab — in place when
// active, lazily on refocus, via the conflict dialog when the user edited the tab.
const conflictOpen = ref(false)
// 'Keep mine' parks the draft until the user's next turn re-arms applies.
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)
return path === undefined ? null : workflowStore.getWorkflowByPath(path)
}
// Agent writes autosave: re-baseline the tracker to the canvas as loaded (a
// minted tab's stored baseline carries an id the canvas never adopts, so the
// next capture would flip isModified and every following patch would raise
// the conflict dialog). A manual edit after this re-arms the dialog as before.
// The serialized canvas re-enters through the schema to type it honestly.
async function autosaveAppliedDraft(tab: ComfyWorkflow): Promise<void> {
const canvasState = app.graph?.serialize()
const baseline = canvasState ? await validateComfyWorkflow(canvasState) : null
if (baseline) tab.changeTracker?.reset(baseline)
tab.isModified = false
}
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
// A user tab-switch mid-load must not misbind: identify the minted tab by
// diffing the open set rather than trusting post-await focus.
const openBefore = new Set(workflowStore.openWorkflows.map((w) => w.path))
try {
await app.loadGraphData(workflow, true, true, tab)
draftRejectionNotified = false
lastApplied = { workflowId, version }
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(opened)
}
return
}
await autosaveAppliedDraft(tab)
} catch (error) {
surfaceDraftApplyFailure(
error instanceof Error ? error.message : String(error)
)
}
}
async function applyDraft(): Promise<void> {
// Patches stream faster than loadGraphData settles; serialize applies and
// coalesce whatever arrived meanwhile into one trailing re-run.
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
// An agent draft with no nodes is noise (the server's freshly minted
// draft starts empty): applying it would blank a bound tab or conjure an
// empty one. Park until a patch carries actual nodes.
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()
}
}
}
useDraftCanvasApply(() => void applyDraft())
// A patch parked while its tab was backgrounded applies when the tab refocuses.
watch(
() => workflowStore.activeWorkflow?.path,
() => void applyDraft()
)
// A rebind opens a new epoch: guards scoped to the old workflow must not leak
// into it (a parked 'mine', a stale lastApplied, an open dialog).
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') {
// Decided: this draft version stays off the canvas; only a NEWER agent
// edit asks again.
lastApplied = { workflowId, version }
return
}
// 'agent' overwrites the bound tab; 'newtab' leaves it with the user's state
// and rebinds the workflow to a fresh tab, where future patches follow.
void loadDraft(
workflowId,
version,
content,
choice === 'agent' ? boundTabFor(workflowId) : null
)
}
start()
onBeforeUnmount(() => {
// Cancel any in-flight turn so it does not keep generating and billing while the panel
// is closed; unsubscribe only after the cancel settles so its ack is not torn down.
void stopTurn().finally(stop)
})
const history = useAgentChatHistoryStore()
const { copy } = useClipboard({ legacy: true })
// A null vote is a retraction of a prior thumb and is forwarded so the eval pipeline
// records it rather than dropping it.
function onFeedback(turnId: string, vote: 'up' | 'down' | null): void {
useTelemetry()?.trackAgentMessageFeedback({
message_id: turnId,
vote,
workflow_id: draftStore.workflowId
})
}
// title is "" until the server names the thread, so the row falls back to the preview
// (the first prompt).
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()
}
// Only the active in-memory conversation has a transcript; a non-active id has nothing
// to serialize and gets an info toast instead.
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 {
// A new turn re-arms applies AND replays the draft a 'Keep mine' parked —
// its version may never advance, so the version watch alone cannot re-drive.
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) => {
// A failed send consumed the snapshot guard without reaching the
// server; drop it so the next send re-uploads.
if (!ok) resetSnapshotGuard()
})
}
function onStop(): void {
void stopTurn()
}
function onNewChat(): void {
resetSnapshotGuard()
newChat()
}
// uploadImage answers {name, subfolder, type}; the send path forwards `name` as the
// LoadImage-style ref.
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 {
useTelemetry()?.trackAgentNodeTagged({ source: 'mention_picker' })
addSelectionTag(node)
}
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))
// Clear so re-picking the same file fires change again.
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

@@ -0,0 +1,7 @@
# 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

@@ -0,0 +1,134 @@
/* In-App Agent panel (FE-1187) theme, owned by the feature (not the design-system
master). The agent components render on agent-* utility classes (bg-agent-surface,
text-agent-fg, border-agent-border, ...); this file routes those tokens onto host
semantic variables so the panel tracks host theme flips (light mode included)
automatically. @theme inline substitutes the var reference into the generated
utilities, which is what makes the flip work without touching any component.
The panel's Figma frames are dark-only; light-mode rendering follows the host theme
here, an intentional divergence deferred to the FE-1187 design-alignment pass. */
@theme inline {
/* agent token -> host semantic token
Aliases tuned to the design prototype's token choices (visual reference). */
/* surface -> base background (#171718 dark) */
--color-agent-surface: var(--color-base-background);
/* surface-raised -> secondary background (composer, chips, bubbles) */
--color-agent-surface-raised: var(--color-secondary-background);
/* surface-hover -> secondary background hover */
--color-agent-surface-hover: var(--color-secondary-background-hover);
/* fg -> base foreground */
--color-agent-fg: var(--color-base-foreground);
/* fg-muted -> muted foreground */
--color-agent-fg-muted: var(--color-muted-foreground);
/* fg-subtle: the reference uses muted-foreground for every secondary text run */
--color-agent-fg-subtle: var(--color-muted-foreground);
/* border -> node component border (header/session dividers) */
--color-agent-border: var(--color-component-node-border);
/* border-strong -> default border (composer resting border) */
--color-agent-border-strong: var(--color-border-default);
/* accent -> primary background */
--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;
/* danger -> destructive background */
--color-agent-danger: var(--color-destructive-background);
/* success -> success background */
--color-agent-success: var(--color-success-background);
/* pill: subtle chip surface with no defensible host pairing distinct from
surface-raised; kept literal pending the FE-1187 design pass. */
--color-agent-pill: #303036;
/* Radius is not theme-flipped and the host exposes no matching --radius-* step, so
the agent radius stays a literal here. */
--radius-agent: 0.75rem;
}
/* Structural (theme-independent) collapsible animation for the agent tool
disclosures, driven by the --reka-collapsible-content-height var Reka sets on
the content element. Namespaced agent-* so this globally-imported sheet cannot
retarget the host's animate-collapsible-* utilities (undefined on main) in the
sharing dialogs. */
@theme {
--animate-agent-collapsible-down: agent-collapsible-down 200ms ease-out;
--animate-agent-collapsible-up: agent-collapsible-up 200ms ease-out;
}
@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;
}
}
/* Shimmering "Thinking…" text (design reference): a light band sweeps across the muted
label while the agent reasons. Falls back to the muted color under reduced-motion. */
@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

@@ -0,0 +1,16 @@
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

@@ -0,0 +1,18 @@
<script setup lang="ts">
export interface ActiveTab {
name: string
}
// Shows which workflow the agent is acting on. Hidden when there is no active tab.
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

@@ -0,0 +1,181 @@
<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/safetyTypes'
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 SessionBar from './SessionBar.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]
}>()
// In-panel Chat History screen (B12): the session bar opens it, the back arrow / picking a
// chat / starting a new chat closes it. Rendered inline instead of a teleported drawer.
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()
// Session bar title (B4): the first user prompt titles the session; a fresh session reads
// "Untitled" (SessionBar's fallback), per the DES-455 chat-history flow.
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
})
// The root wires the file picker + upload lifecycle and drives the composer's
// staged chips through these, without the panel owning the upload path.
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>
<SessionBar :title="sessionTitle" @open-history="onOpenHistory" />
<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

@@ -0,0 +1,107 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
ChatSession,
HistoryGroups
} from '../../stores/agent/agentChatHistoryStore'
// In-panel Chat History screen (Figma B12): replaces the conversation within the panel,
// with a "<- Chat History" back affordance. Sessions are grouped by recency; a row loads
// its chat, and a hover reveals copy / delete. Rendered inline (not a teleported drawer)
// so it never fights the docked panel's stacking context.
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

@@ -0,0 +1,59 @@
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

@@ -0,0 +1,195 @@
<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'
import SelectionTagChip from './composer/SelectionTagChip.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]
}>()
// Snapshotted when the @ menu opens; the graph is not watched.
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')
})
// Enter sends; Shift+Enter inserts a newline (default textarea behavior). An Enter that
// commits an IME composition (CJK input) must NOT send the half-composed text.
function onEnter(event: KeyboardEvent): void {
if (event.isComposing || event.shiftKey) return
event.preventDefault()
composer.submit()
}
// Parent (asset tray / file picker) stages attachments through this.
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">
<SelectionTagChip
v-for="tag in selectionTags"
:key="tag.id"
:title="tag.title"
@remove="emit('removeTag', tag.id)"
/>
</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

@@ -0,0 +1,130 @@
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'
// Capture the IntersectionObserver callback useIntersectionObserver registers so the test
// can drive the "at bottom" signal directly — happy-dom has no real observer, and the pill
// only shows once the sentinel scrolls out of view.
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'))
// The thinking chip shows before any text streams.
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()
// The markdown bold survives sanitization as a real <strong> element.
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] }
})
// At bottom initially: no pill.
expect(screen.queryByText('Latest')).not.toBeInTheDocument()
// Drive the sentinel out of view -> the pill appears.
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

@@ -0,0 +1,87 @@
<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()
// Keep the newest turn in view as content streams in, but ONLY when the user is already at
// the bottom — if they scrolled up to re-read, a streaming delta must not yank them back.
// The bottom sentinel's visibility is the "at bottom" signal.
const bottom = ref<HTMLElement>()
const atBottom = ref(true)
useIntersectionObserver(bottom, ([entry]) => {
atBottom.value = entry?.isIntersecting ?? true
})
function scrollToLatest(): void {
bottom.value?.scrollIntoView({ block: 'end' })
}
// Only the LAST entry can grow while a turn streams (earlier ones are settled), so this
// signal replaces a deep watch that re-walked every entry per token.
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

@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import SuggestedPromptChip from './composer/SuggestedPromptChip.vue'
const { userName } = defineProps<{ userName?: string }>()
const emit = defineEmits<{ insert: [text: string] }>()
const { t, tm } = useI18n()
const prompts = computed(() => tm('agent.suggestedPrompts') as string[])
// Leading icon per suggested prompt (Figma B5), in the same order as agent.suggestedPrompts.
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">
<SuggestedPromptChip
v-for="(prompt, index) in prompts"
:key="index"
:text="prompt"
:icon="promptIcons[index] ?? 'icon-[lucide--sparkles]'"
@insert="emit('insert', $event)"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import { 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'
// Host-DOM anchored: the step points at a host selector (e.g. '#graph-canvas'). Where the
// anchor is absent (the standalone harness) the coach simply shows nothing — it must NOT
// finish, which would burn the persist-once flag before the user ever saw the step.
const { step, storageKey } = defineProps<{
step: CoachStep
storageKey?: string
}>()
const { t } = useI18n()
const { active, finish } = useOnboarding(storageKey)
const { width, height } = useWindowSize()
const CARD_W = 256
const CARD_H = 160
const MARGIN = 8
const cardStyle = ref<Record<string, string> | null>(null)
// flush:'post' so the effect runs AFTER render — a synchronous pre-mount pass would miss
// every app-rendered anchor. A missing anchor leaves the card hidden without consuming the
// step. NOTE(follow-up): re-check via MutationObserver to skip a step whose anchor never
// appears rather than blocking on it.
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" 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

@@ -0,0 +1,83 @@
<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'
// Header per the design reference: title + neutral ALPHA badge (no logo mark) + the
// active tab name, then new chat, width toggle (420 <-> 960 dock), close. History is
// reached from the session bar (B4), not the header.
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

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
// Session / title bar (Figma B4): the row under the header showing the current chat title
// (or "Untitled" for a fresh session). The compact pill is a button that opens Chat
// History; its tooltip reads "Show chat history" to name the action.
const { title } = defineProps<{ title?: string }>()
const emit = defineEmits<{ openHistory: [] }>()
const { t } = useI18n()
</script>
<template>
<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="emit('openHistory')"
>
<span class="icon-[lucide--align-justify] size-3.5 shrink-0" />
<span class="max-w-56 truncate">{{
title || t('agent.newChatTitle')
}}</span>
</button>
</div>
</template>

View File

@@ -0,0 +1,44 @@
<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

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { title } = defineProps<{
title: string
}>()
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.5 text-xs"
>
<span class="text-agent-fg-subtle icon-[lucide--at-sign] size-3.5" />
<span class="max-w-40 truncate">{{ 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('remove')"
>
<span class="icon-[lucide--x] size-3.5" />
</button>
</span>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
// Click INSERTS the prompt into the composer (never sends) per the locked Figma decision.
// Per the design reference: a rounded-full pill (leading icon + label) that stacks
// full-width in narrow panels and wraps centered from 460px container width up.
const { text, icon } = defineProps<{ text: string; icon: string }>()
const emit = defineEmits<{ insert: [text: string] }>()
</script>
<template>
<button
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', text)"
>
<span
:class="cn('text-agent-fg-muted size-3 shrink-0', icon)"
aria-hidden="true"
/>
<span class="truncate">{{ text }}</span>
</button>
</template>

View File

@@ -0,0 +1,107 @@
<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 ThinkingStatus from './ThinkingStatus.vue'
import ToolCallGroup from './ToolCallGroup.vue'
const { message } = defineProps<{ message: AssistantMessage }>()
const emit = defineEmits<{ feedback: [vote: 'up' | 'down' | null] }>()
const { t } = useI18n()
// Adjacent tool parts render as one grouped card; text and notices stay as their
// own rows, preserving the transport's interleaved order.
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">
<ThinkingStatus
v-if="message.thinking || (message.streaming && !message.parts.length)"
/>
<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

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
// Spec (B10): framed block with a header (type icon + language label + copy
// button) over a monospace body. Syntax highlighting is intentionally absent.
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

@@ -0,0 +1,106 @@
// @vitest-environment jsdom
// DOMPurify's sanitize walks a real DOM tree; happy-dom drops the sanitized inner
// tree when re-serializing DOMPurify's <template> round-trip, so this XSS-guard suite
// runs against jsdom, the reference DOM DOMPurify is tested against upstream.
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { i18n } from '@/i18n'
import MarkdownStream from './MarkdownStream.vue'
// Pins the agent-reply rendering contract (markdown in, safe HTML out) as it flows
// through the shared platform renderer; the renderer itself has its own tests.
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] }
})
// The whole inner fence is ONE literal code body, not a nested block.
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' }
})
// Rendered as an inline <code> span inside prose, not a framed block.
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

@@ -0,0 +1,88 @@
<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
// Fenced code blocks are pulled out via marked's lexer (which handles 4+
// backtick fences and inline backtick spans correctly) so they render as
// framed CodeBlocks with copy chrome; the prose between them goes through the
// sanitizing markdown renderer. Indented code stays prose so it keeps its
// inline rendering rather than being promoted to a framed block.
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
})
// Prose styles read from the deployed prototype's .agent-markdown rules.
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

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
// Thumbs are mutually exclusive; clicking the active one clears it. Feedback is emitted
// so the container can forward it to the real submit_feedback tool.
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

@@ -0,0 +1,14 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
// Plain "Thinking..." per the locked Figma decision (the monolith's rotating status
// words are dropped).
const { t } = useI18n()
</script>
<template>
<div 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>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { PartState } from '../../../services/agent/agentMessageParts'
import { cn } from '@comfyorg/tailwind-utils'
// One tool row (or a run of identical calls collapsed with a count). `ok` is undefined
// while the call is still running.
const {
name,
state,
ok,
count = 1
} = defineProps<{
name: string
state: PartState
ok?: boolean
count?: number
}>()
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="truncate font-mono text-xs">{{ name }}</span>
<span v-if="count > 1" class="text-agent-fg-subtle text-xs"
>×{{ count }}</span
>
</div>
</template>

View File

@@ -0,0 +1,96 @@
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('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] }
})
// Every call settled clean: the rows collapse behind the header.
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

@@ -0,0 +1,101 @@
<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()
// Consecutive identical tool names collapse into one row with a count (same-tool xN).
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)
)
// Live while running or if anything failed; collapse once every call settled clean. Drive
// open BOTH ways off the source: a later tool re-entering the group (running again) or a
// tool failing must re-open it, not just the first clean settle. Manual re-open of a
// settled clean group still sticks because the source does not change then.
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

@@ -0,0 +1,31 @@
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

@@ -0,0 +1,62 @@
<script setup lang="ts">
import type { UserAttachment } from '../../../stores/agent/agentConversationStore'
// User message per the design reference: a right-aligned pill, no avatar, with
// sent attachments (B11 thumbnail grid) and @-tags kept visible above the text.
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

@@ -0,0 +1,113 @@
<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'
import type { ConflictChoice } from './safetyTypes'
// DES-502 card: Cancel/✕ defer the decision, "Keep my changes" keeps this draft
// off the canvas, "Accept agent changes" applies it (dropdown: into a new tab).
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

@@ -0,0 +1,3 @@
// 'cancel' defers the decision (the next turn re-asks); the other three act on
// the pending draft, per DES-502.
export type ConflictChoice = 'agent' | 'mine' | 'newtab' | 'cancel'

View File

@@ -0,0 +1,83 @@
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

@@ -0,0 +1,26 @@
export const AGENT_PANEL_FLAG = 'agent-in-app-experience'
export interface AgentFlagSource {
// Must return false while flags are unknown/not yet loaded (fail closed).
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 {
// undefined = flags not loaded -> fail closed.
isEnabled: () => posthog.isFeatureEnabled(flag) === true,
onChange: (listener) => {
const unsubscribe = posthog.onFeatureFlags(listener)
return typeof unsubscribe === 'function' ? unsubscribe : () => {}
}
}
}

View File

@@ -0,0 +1,757 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
AgentCancelAccepted,
AgentDraftSnapshot,
AgentMessages,
AgentThreadSummary,
AgentTurnAccepted,
UploadImageResult
} from '../../schemas/agentApiSchema'
import { zAgentWsEvent } from '../../schemas/agentApiSchema'
import { AgentApiError } from '../../services/agent/agentRestClient'
import type {
AgentRestClient,
PostMessageInput
} from '../../services/agent/agentRestClient'
import { useAgentConversationStore } from '../../stores/agent/agentConversationStore'
import { useAgentDraftStore } from '../../stores/agent/agentDraftStore'
import type { AgentEventSource } from './useAgentSession'
import { useAgentSession } from './useAgentSession'
// A fully typed fake REST client: each method is a vi.fn the test arranges per scenario.
function fakeRest(overrides: Partial<AgentRestClient> = {}): AgentRestClient {
const base: AgentRestClient = {
postMessage: vi.fn(
async (): Promise<AgentTurnAccepted> => ({
thread_id: 'th-1',
message_id: 'msg-1',
workflow_id: 'wf-1'
})
),
getMessages: vi.fn(async (): Promise<AgentMessages> => []),
listThreads: vi.fn(async (): Promise<AgentThreadSummary[]> => []),
cancelMessage: vi.fn(
async (): Promise<AgentCancelAccepted> => ({ status: 'cancelling' })
),
getDraft: vi.fn(
async (): Promise<AgentDraftSnapshot> => ({ content: {}, version: 1 })
),
uploadImage: vi.fn(
async (): Promise<UploadImageResult> => ({
name: 'n',
subfolder: '',
type: 'input'
})
)
}
return { ...base, ...overrides }
}
// A fake event source that captures the session's listeners so the test can push frames
// and toggle liveness by hand.
function fakeEvents() {
let listener: ((raw: unknown) => void) | undefined
let statusListener: ((live: boolean) => void) | undefined
const source: AgentEventSource = {
subscribe(fn) {
listener = fn
return () => {
listener = undefined
}
},
onStatus(fn) {
statusListener = fn
return () => {
statusListener = undefined
}
}
}
return {
source,
emit: (raw: unknown) => listener?.(raw),
status: (live: boolean) => statusListener?.(live)
}
}
const wire = (raw: unknown): unknown => zAgentWsEvent.parse(raw)
const thinking = (id: string, delta: string) =>
wire({
type: 'agent_thinking',
data: { delta, message_id: id, thread_id: 'th-1' }
})
const delta = (id: string, text: string) =>
wire({
type: 'agent_message_delta',
data: { delta: text, message_id: id, thread_id: 'th-1' }
})
const done = (id: string) =>
wire({
type: 'agent_message_done',
data: { message_id: id, thread_id: 'th-1', usage: null }
})
const draftPatch = (workflowId: string, version: number) =>
wire({
type: 'draft_patch',
data: {
base_version: version - 1,
version,
content: { n: 1 },
workflow_id: workflowId
}
})
const draftVersion = (workflowId: string, version: number) =>
wire({ type: 'draft_version', data: { version, workflow_id: workflowId } })
describe('useAgentSession (v1 composition root)', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
it('(a) posts to new, adopts ids, records the user turn, and renders a settled reply', async () => {
const rest = fakeRest()
const { source, emit } = fakeEvents()
const session = useAgentSession({ rest, events: source })
session.start()
await session.sendMessage('make me a cat')
expect(rest.postMessage).toHaveBeenCalledWith('new', {
content: 'make me a cat',
selection: undefined,
attachments: undefined
})
expect(session.threadId.value).toBe('th-1')
emit(thinking('msg-1', 'planning'))
emit(delta('msg-1', 'A cat.'))
emit(done('msg-1'))
const roles = session.entries.value.map((e) => e.role)
expect(roles).toEqual(['user', 'assistant'])
const assistant = session.entries.value[1]
expect(assistant).toMatchObject({
role: 'assistant',
streaming: false
})
expect(session.isStreaming.value).toBe(false)
})
it('(b) a second send posts to the adopted threadId, not new', async () => {
const postMessage = vi
.fn<
(threadId: string, req: PostMessageInput) => Promise<AgentTurnAccepted>
>()
.mockResolvedValueOnce({ thread_id: 'th-9', message_id: 'msg-1' })
.mockResolvedValueOnce({ thread_id: 'th-9', message_id: 'msg-2' })
const rest = fakeRest({ postMessage })
const { source } = fakeEvents()
const session = useAgentSession({ rest, events: source })
session.start()
await session.sendMessage('first')
await session.sendMessage('second')
expect(postMessage.mock.calls[0][0]).toBe('new')
expect(postMessage.mock.calls[1][0]).toBe('th-9')
})
it('(b2) a remounted session continues the persisted thread, not a new one', async () => {
const postMessage = vi
.fn<
(threadId: string, req: PostMessageInput) => Promise<AgentTurnAccepted>
>()
.mockResolvedValueOnce({ thread_id: 'th-9', message_id: 'msg-1' })
.mockResolvedValueOnce({ thread_id: 'th-9', message_id: 'msg-2' })
const rest = fakeRest({ postMessage })
// First mount adopts the thread, then unmounts (the panel closes).
const first = useAgentSession({ rest, events: fakeEvents().source })
first.start()
await first.sendMessage('first')
first.stop()
// A remount is a fresh composable over the same pinia: it must read the persisted thread
// and post there, not split the transcript across a second 'new' thread.
const second = useAgentSession({ rest, events: fakeEvents().source })
second.start()
await second.sendMessage('second')
expect(postMessage.mock.calls[0][0]).toBe('new')
expect(postMessage.mock.calls[1][0]).toBe('th-9')
})
it('(c) a postMessage AgentApiError surfaces inline only (no toast) and opens no live turn', async () => {
const postMessage = vi
.fn<
(threadId: string, req: PostMessageInput) => Promise<AgentTurnAccepted>
>()
.mockRejectedValue(new AgentApiError('server exploded', 500, undefined))
const rest = fakeRest({ postMessage })
const { source } = fakeEvents()
const session = useAgentSession({ rest, events: source })
session.start()
const ok = await session.sendMessage('boom')
expect(ok).toBe(false)
// A send failure already has an inline conversation row, so it must NOT also raise a
// session notice (host toast) — that double-surfaces and, top-right, collides with the
// docked panel. The failed send renders as a settled exchange (user + error notice),
// not a live turn: no spinner is left running.
expect(session.notices.value).toHaveLength(0)
expect(session.entries.value.map((e) => e.role)).toEqual([
'user',
'assistant'
])
expect(session.isStreaming.value).toBe(false)
})
it('(d) stopTurn cancels the active turn; a 409 is swallowed and the socket settles it', async () => {
const cancelMessage = vi
.fn<
(threadId: string, messageId: string) => Promise<AgentCancelAccepted>
>()
.mockRejectedValue(new AgentApiError('already done', 409, undefined))
const rest = fakeRest({ cancelMessage })
const { source, emit } = fakeEvents()
const session = useAgentSession({ rest, events: source })
session.start()
await session.sendMessage('go')
emit(delta('msg-1', 'working'))
expect(session.isStreaming.value).toBe(true)
await session.stopTurn()
expect(cancelMessage).toHaveBeenCalledWith('th-1', 'msg-1')
// A 409 is benign — no notice, and no local abort (the socket settles the turn).
expect(session.notices.value).toHaveLength(0)
expect(session.isStreaming.value).toBe(true)
emit(delta('msg-1', ' Stopped at your request.'))
emit(done('msg-1'))
expect(session.isStreaming.value).toBe(false)
})
it('(d2) stopTurn rejecting with a network TypeError surfaces a notice, not an unhandled rejection', async () => {
// Mirrors resyncDraft (n): newChat/onStop void stopTurn(), so a non-AgentApiError
// rethrow would escape as an unhandled rejection. The catch must pushError instead;
// the notice is the proof the error was surfaced rather than swallowed or escaped.
const cancelMessage = vi
.fn<
(threadId: string, messageId: string) => Promise<AgentCancelAccepted>
>()
.mockRejectedValue(new TypeError('fetch failed'))
const rest = fakeRest({ cancelMessage })
const { source, emit } = fakeEvents()
const session = useAgentSession({ rest, events: source })
session.start()
await session.sendMessage('go')
emit(delta('msg-1', 'working'))
await session.stopTurn()
expect(cancelMessage).toHaveBeenCalledWith('th-1', 'msg-1')
expect(session.notices.value).toEqual([
{ level: 'error', text: 'fetch failed' }
])
})
it('(e) foreign chat events are ignored, but a mid-turn draft_patch still adopts', async () => {
const rest = fakeRest()
const { source, emit } = fakeEvents()
const session = useAgentSession({ rest, events: source })
session.start()
// The send's ack binds the draft store to the server's workflow (wf-1).
await session.sendMessage('hi')
emit(delta('msg-1', 'kept'))
// A chat event for a different turn is dropped.
emit(delta('msg-OTHER', 'DROP'))
// A draft_patch arriving during this turn still adopts (draft is not turn-filtered).
emit(draftPatch('wf-1', 5))
const assistant = session.entries.value.at(-1)
const textPart =
assistant?.role === 'assistant'
? assistant.parts.find((p) => p.type === 'text')
: undefined
expect(textPart).toMatchObject({ text: 'kept' })
const draft = useAgentDraftStore()
expect(draft.version).toBe(5)
expect(draft.content).toEqual({ n: 1 })
})
it('(f) draft_version ahead triggers exactly one single-flight resync', async () => {
let resolveDraft: ((snapshot: AgentDraftSnapshot) => void) | undefined
const getDraft = vi.fn<(workflowId: string) => Promise<AgentDraftSnapshot>>(
() =>
new Promise<AgentDraftSnapshot>((resolve) => {
resolveDraft = resolve
})
)
const rest = fakeRest({ getDraft })
const { source, emit } = fakeEvents()
const session = useAgentSession({ rest, events: source })
session.start()
// The server-owned workflow id is adopted from a message ack; simulate that binding.
const draft = useAgentDraftStore()
draft.bind('wf-1')
// Two 'behind' heartbeats before the fetch resolves collapse to ONE getDraft.
emit(draftVersion('wf-1', 9))
emit(draftVersion('wf-1', 10))
expect(getDraft).toHaveBeenCalledTimes(1)
resolveDraft?.({ content: { adopted: true }, version: 10 })
await Promise.resolve()
expect(draft.version).toBe(10)
expect(draft.content).toEqual({ adopted: true })
})
it('(g) onStatus(false) aborts the active turn; onStatus(true) resyncs the draft', async () => {
const rest = fakeRest()
const { source, emit, status } = fakeEvents()
const session = useAgentSession({ rest, events: source })
session.start()
// The send's ack binds the draft store; no resync fires until a reconnect.
await session.sendMessage('go')
emit(delta('msg-1', 'partial'))
expect(session.isStreaming.value).toBe(true)
expect(rest.getDraft).not.toHaveBeenCalled()
status(false)
expect(session.isStreaming.value).toBe(false)
status(true)
expect(rest.getDraft).toHaveBeenCalledTimes(1)
})
it('(h) attachments pass through to the postMessage wire body', async () => {
const rest = fakeRest()
const { source } = fakeEvents()
const session = useAgentSession({ rest, events: source })
session.start()
await session.sendMessage('with files', [
{ ref: 'upload_a.png', name: 'a.png', previewUrl: 'blob:a' },
{ ref: 'upload_b.png', name: 'b.png' }
])
// Only the server refs ride the wire; name/preview stay on the transcript.
expect(rest.postMessage).toHaveBeenCalledWith('new', {
content: 'with files',
selection: undefined,
attachments: ['upload_a.png', 'upload_b.png']
})
})
it('(h2) tags ride as node_ids on the POST selection', async () => {
const rest = fakeRest()
const session = useAgentSession({ rest, events: fakeEvents().source })
session.start()
await session.sendMessage('explain', undefined, [
{ id: '5', title: 'K' },
{ id: '6', title: 'Decode' }
])
const body = vi.mocked(rest.postMessage).mock.calls[0][1]
expect(body.selection).toEqual({ node_ids: ['5', '6'] })
})
it('(h4) attaches the workflow snapshot and reports the upload to adopted', async () => {
const rest = fakeRest()
const adopted = vi.fn()
const session = useAgentSession({
rest,
events: fakeEvents().source,
workflow: {
current: () => undefined,
adopted,
snapshot: () => ({
graph: { nodes: [{ id: 1 }] },
last_seen_version: null
})
}
})
session.start()
await session.sendMessage('hi')
expect(vi.mocked(rest.postMessage).mock.calls[0][1]).toMatchObject({
workflow: { graph: { nodes: [{ id: 1 }] }, last_seen_version: null }
})
expect(adopted).toHaveBeenCalledWith('wf-1', undefined, true)
})
it("(i2) loadThread drops the previous thread's draft binding", async () => {
const rest = fakeRest()
const { source } = fakeEvents()
const session = useAgentSession({ rest, events: source })
session.start()
await session.sendMessage('bind me')
expect(useAgentDraftStore().workflowId).toBe('wf-1')
// A late patch for the abandoned thread must find nothing to land on.
await session.loadThread('th-2')
expect(useAgentDraftStore().workflowId).toBeNull()
})
it('(j) a rebind during resyncDraft does not adopt the stale workflow draft', async () => {
let resolveDraft: ((snapshot: AgentDraftSnapshot) => void) | undefined
const getDraft = vi.fn<(workflowId: string) => Promise<AgentDraftSnapshot>>(
() =>
new Promise<AgentDraftSnapshot>((resolve) => {
resolveDraft = resolve
})
)
const rest = fakeRest({ getDraft })
const { source, status } = fakeEvents()
const session = useAgentSession({ rest, events: source })
session.start()
// Bind wf-1 (as a message ack would) and let a reconnect start its resync.
const draft = useAgentDraftStore()
draft.bind('wf-1')
status(true)
expect(getDraft).toHaveBeenCalledWith('wf-1')
// The bound workflow changes before the in-flight fetch resolves.
draft.bind('wf-2')
// The stale snapshot for wf-1 arrives; it must NOT be adopted onto wf-2.
resolveDraft?.({ content: { stale: true }, version: 99 })
await Promise.resolve()
expect(draft.workflowId).toBe('wf-2')
expect(draft.content).toBeNull()
expect(draft.version).toBeNull()
})
it('(k) a failed POST records the user text plus a settled error reply and returns false', async () => {
const postMessage = vi
.fn<
(threadId: string, req: PostMessageInput) => Promise<AgentTurnAccepted>
>()
.mockRejectedValue(new AgentApiError('server exploded', 500, undefined))
const rest = fakeRest({ postMessage })
const session = useAgentSession({ rest, events: fakeEvents().source })
session.start()
const ok = await session.sendMessage('boom')
expect(ok).toBe(false)
const entries = session.entries.value
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: 'Message failed to send: server exploded'
}
])
}
expect(session.isStreaming.value).toBe(false)
})
it('(l) newChat cancels the active turn exactly once with the right ids', async () => {
const cancelMessage = vi.fn<
(threadId: string, messageId: string) => Promise<AgentCancelAccepted>
>(async () => ({ status: 'cancelling' }))
const rest = fakeRest({ cancelMessage })
const { source, emit } = fakeEvents()
const session = useAgentSession({ rest, events: source })
session.start()
await session.sendMessage('go')
emit(delta('msg-1', 'working'))
expect(session.isStreaming.value).toBe(true)
session.newChat()
expect(cancelMessage).toHaveBeenCalledTimes(1)
expect(cancelMessage).toHaveBeenCalledWith('th-1', 'msg-1')
expect(session.entries.value).toHaveLength(0)
expect(session.threadId.value).toBeNull()
})
it('(m) a second send while the first POST is pending posts once and records a busy notice', async () => {
let resolvePost: ((ack: AgentTurnAccepted) => void) | undefined
const postMessage = vi
.fn<
(threadId: string, req: PostMessageInput) => Promise<AgentTurnAccepted>
>()
.mockImplementationOnce(
() =>
new Promise<AgentTurnAccepted>((resolve) => {
resolvePost = resolve
})
)
const rest = fakeRest({ postMessage })
const session = useAgentSession({ rest, events: fakeEvents().source })
session.start()
const first = session.sendMessage('first')
const second = await session.sendMessage('second')
expect(second).toBe(false)
// Only the first send reached the wire.
expect(postMessage).toHaveBeenCalledTimes(1)
// The dropped second send is recorded as a failed exchange, not swallowed.
const busyNotice = session.entries.value.find(
(e) =>
e.role === 'assistant' &&
e.parts.some(
(p) =>
p.type === 'notice' && p.text === 'A message is already being sent'
)
)
expect(busyNotice).toBeDefined()
resolvePost?.({ thread_id: 'th-1', message_id: 'msg-1' })
await first
})
it('(n) a getDraft rejecting with a network TypeError surfaces a notice, not an unhandled rejection', async () => {
// onStatus(true) voids resyncDraft(), so a non-AgentApiError rethrow would escape as an
// unhandled rejection. The catch must instead pushError. The notice is the proof:
// the old rethrow path pushed NO notice (the error escaped), so its presence pins
// both the surfacing and the absence of an escaping rejection.
const getDraft = vi
.fn<(workflowId: string) => Promise<AgentDraftSnapshot>>()
.mockRejectedValue(new TypeError('fetch failed'))
const { source, status } = fakeEvents()
const session = useAgentSession({
rest: fakeRest({ getDraft }),
events: source
})
session.start()
useAgentDraftStore().bind('wf-1')
status(true)
await Promise.resolve()
await Promise.resolve()
expect(session.notices.value).toEqual([
{ level: 'error', text: 'fetch failed' }
])
})
it('(o) a malformed done for the active turn settles it; a foreign malformed done does not', async () => {
const rest = fakeRest()
const { source, emit } = fakeEvents()
const session = useAgentSession({ rest, events: source })
session.start()
await session.sendMessage('go')
emit(delta('msg-1', 'partial'))
expect(session.isStreaming.value).toBe(true)
// A malformed done for a FOREIGN, readable message_id must not abort our turn.
emit({ type: 'agent_message_done', data: { message_id: 'msg-OTHER' } })
expect(session.isStreaming.value).toBe(true)
// A malformed done for the ACTIVE turn settles it (no hung spinner).
emit({ type: 'agent_message_done', data: { message_id: 'msg-1' } })
expect(session.isStreaming.value).toBe(false)
})
it('(p) non-object and foreign host frames are dropped silently mid-turn', async () => {
const rest = fakeRest()
const { source, emit } = fakeEvents()
const session = useAgentSession({ rest, events: source })
session.start()
await session.sendMessage('go')
emit(delta('msg-1', 'working'))
expect(session.isStreaming.value).toBe(true)
// Both ride the shared /ws: a garbage frame and a real host status frame.
emit('not an object')
emit({ type: 'status', data: { sid: 1 } })
expect(session.isStreaming.value).toBe(true)
expect(session.entries.value.map((e) => e.role)).toEqual([
'user',
'assistant'
])
expect(session.notices.value).toHaveLength(0)
})
it('(i) a 404 draft resync is benign; a 403 pushes an error notice', async () => {
const draft = useAgentDraftStore()
const getDraft404 = vi
.fn<(workflowId: string) => Promise<AgentDraftSnapshot>>()
.mockRejectedValue(new AgentApiError('not found', 404, undefined))
const events404 = fakeEvents()
const session404 = useAgentSession({
rest: fakeRest({ getDraft: getDraft404 }),
events: events404.source
})
session404.start()
draft.bind('wf-1')
events404.status(true)
await Promise.resolve()
expect(session404.notices.value).toHaveLength(0)
const getDraft403 = vi
.fn<(workflowId: string) => Promise<AgentDraftSnapshot>>()
.mockRejectedValue(new AgentApiError('forbidden', 403, undefined))
const events403 = fakeEvents()
const session403 = useAgentSession({
rest: fakeRest({ getDraft: getDraft403 }),
events: events403.source
})
session403.start()
events403.status(true)
await Promise.resolve()
expect(session403.notices.value).toEqual([
{ level: 'error', text: 'forbidden' }
])
})
})
describe('thread resume (B17)', () => {
const HISTORY: AgentMessages = [
{
id: 'row-1',
thread_id: 'th-9',
seq: 0,
role: 'user',
status: 'complete',
turn_id: 'turn-1',
content: { text: 'build a duck' }
},
{
id: 'row-2',
thread_id: 'th-9',
seq: 1,
role: 'assistant',
status: 'complete',
turn_id: 'turn-1',
content: { text: 'Duck workflow ready.' }
}
]
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
it('restores the persisted thread and hydrates its transcript on start', async () => {
localStorage.setItem('Comfy.Agent.ThreadId', 'th-9')
const getMessages = vi.fn(async (): Promise<AgentMessages> => HISTORY)
const session = useAgentSession({
rest: fakeRest({ getMessages }),
events: fakeEvents().source
})
session.start()
await vi.waitFor(() => expect(getMessages).toHaveBeenCalledWith('th-9'))
await vi.waitFor(() => expect(session.entries.value).toHaveLength(2))
const [user, assistant] = session.entries.value
expect(user).toMatchObject({ role: 'user', text: 'build a duck' })
expect(assistant).toMatchObject({ role: 'assistant', streaming: false })
expect(session.threadId.value).toBe('th-9')
// The resumed transcript is settled: nothing streams.
expect(session.isStreaming.value).toBe(false)
})
it('forgets a stale persisted thread on 404 without surfacing an error', async () => {
localStorage.setItem('Comfy.Agent.ThreadId', 'th-gone')
const getMessages = vi.fn(async (): Promise<AgentMessages> => {
throw new AgentApiError('not found', 404, null)
})
const session = useAgentSession({
rest: fakeRest({ getMessages }),
events: fakeEvents().source
})
session.start()
await vi.waitFor(() =>
expect(localStorage.getItem('Comfy.Agent.ThreadId')).toBeNull()
)
expect(session.threadId.value).toBeNull()
expect(session.entries.value).toHaveLength(0)
expect(session.notices.value).toHaveLength(0)
})
it('persists the thread on send and clears it on newChat', async () => {
const session = useAgentSession({
rest: fakeRest(),
events: fakeEvents().source
})
session.start()
await session.sendMessage('hello')
expect(localStorage.getItem('Comfy.Agent.ThreadId')).toBe('th-1')
session.newChat()
expect(localStorage.getItem('Comfy.Agent.ThreadId')).toBeNull()
expect(useAgentConversationStore().threadId).toBeNull()
})
it('does not clobber an in-memory conversation on panel reopen', async () => {
const getMessages = vi.fn(async (): Promise<AgentMessages> => HISTORY)
const rest = fakeRest({ getMessages })
const first = useAgentSession({ rest, events: fakeEvents().source })
first.start()
await first.sendMessage('live message')
first.stop()
// Reopen within the same page session: the store still holds the live conversation.
const second = useAgentSession({ rest, events: fakeEvents().source })
second.start()
expect(getMessages).not.toHaveBeenCalled()
expect(
second.entries.value.some(
(entry) => entry.role === 'user' && entry.text === 'live message'
)
).toBe(true)
})
it('loadThread adopts, persists and hydrates a chat picked from history', async () => {
const getMessages = vi.fn(async (): Promise<AgentMessages> => HISTORY)
const session = useAgentSession({
rest: fakeRest({ getMessages }),
events: fakeEvents().source
})
session.start()
await session.loadThread('th-9')
expect(getMessages).toHaveBeenCalledWith('th-9')
expect(session.threadId.value).toBe('th-9')
expect(localStorage.getItem('Comfy.Agent.ThreadId')).toBe('th-9')
await vi.waitFor(() => expect(session.entries.value).toHaveLength(2))
expect(session.entries.value[0]).toMatchObject({
role: 'user',
text: 'build a duck'
})
})
it('listThreads returns the REST client thread list', async () => {
const listThreads = vi.fn(
async (): Promise<AgentThreadSummary[]> => [
{
id: 'th-9',
title: 'build a duck',
updated_at: '2026-07-07T00:00:00Z'
}
]
)
const session = useAgentSession({
rest: fakeRest({ listThreads }),
events: fakeEvents().source
})
const threads = await session.listThreads()
expect(threads).toHaveLength(1)
expect(threads[0]).toMatchObject({ id: 'th-9', title: 'build a duck' })
})
})

View File

@@ -0,0 +1,337 @@
import { computed, ref } from 'vue'
import { i18n } from '@/i18n'
import type { TurnId } from '../../schemas/agentApiSchema'
import { isAgentEvent, parseAgentWsEvent } from '../../schemas/agentApiSchema'
import { AgentApiError } from '../../services/agent/agentRestClient'
import type {
AgentRestClient,
WorkflowUpload
} from '../../services/agent/agentRestClient'
import { useAgentConversationStore } from '../../stores/agent/agentConversationStore'
import { useAgentDraftStore } from '../../stores/agent/agentDraftStore'
// Headless composition root. Turns are addressed by the server-minted assistant
// message_id from the send ack; the panel never mints turn ids.
export interface AgentEventSource {
subscribe(listener: (raw: unknown) => void): () => void
onStatus?(listener: (live: boolean) => void): () => void
}
// Only errors surface out of the session today; widen the union when the wire
// grows a non-error notice.
export interface SessionNotice {
level: 'error'
text: string
}
// A settled upload: the server ref rides the POST, name + preview stay behind
// on the transcript's user entry.
interface SentAttachment {
ref: string
name: string
previewUrl?: string
}
// A consumed @-tag: the node id rides the POST selection, the title stays
// behind on the transcript's user entry. The id resolves against the graph
// uploaded with the same POST (workflow.graph).
interface SentTag {
id: string
title: string
}
// The active tab resolved for a turn. `speculative` marks an id the server has
// not confirmed owning; a 403 on it retries the send once without the id.
export interface WorkflowTurnContext {
id: string
speculative: boolean
tabPath: string
}
export interface AgentSessionDeps {
rest: AgentRestClient
events: AgentEventSource
workflow?: {
current(): WorkflowTurnContext | undefined
adopted(
workflowId: string,
sent: WorkflowTurnContext | undefined,
uploaded: boolean
): void
// The canvas snapshot to seed the server draft with, or undefined when
// unchanged since the last upload.
snapshot?(): WorkflowUpload | undefined
}
}
const THREAD_STORAGE_KEY = 'Comfy.Agent.ThreadId'
export function useAgentSession(deps: AgentSessionDeps) {
const { rest, events, workflow } = deps
const conversationStore = useAgentConversationStore()
const draftStore = useAgentDraftStore()
const notices = ref<SessionNotice[]>([])
let resyncing = false
// The 202 ack races the socket frames; a second send in that window must not open a
// second wire turn.
const sending = ref(false)
// Local-only turn ids for failed sends, which have no server-minted message_id.
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)
// A rebind during the fetch must not adopt the stale workflow's draft.
if (draftStore.workflowId === id) draftStore.adoptSnapshot(snapshot)
} catch (error) {
if (error instanceof AgentApiError) {
if (error.status === 404) return
pushError(error.message)
return
}
// Callers void this promise; surface instead of an unhandled rejection.
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)
// A same-page reopen already has the conversation in the store; only a fresh page
// restores the persisted thread.
if (
conversationStore.threadId === null &&
conversationStore.messages.length === 0
) {
const stored = localStorage.getItem(THREAD_STORAGE_KEY)
if (stored !== null) {
conversationStore.setThreadId(stored)
void hydrateFromServer(stored)
}
}
}
// A 404 means the thread expired or was deleted: forget the stale id, not an error.
async function hydrateFromServer(threadId: string): Promise<void> {
try {
const history = await rest.getMessages(threadId)
// newChat may have replaced the thread while the fetch was in flight.
if (conversationStore.threadId === threadId)
conversationStore.hydrate(history)
} catch (error) {
if (error instanceof AgentApiError && error.status === 404) {
if (conversationStore.threadId === threadId)
conversationStore.setThreadId(null)
localStorage.removeItem(THREAD_STORAGE_KEY)
return
}
pushError(error instanceof Error ? error.message : String(error))
}
}
function stop(): void {
unsubscribe?.()
unsubscribeStatus?.()
unsubscribe = null
unsubscribeStatus = null
}
// False on any failure; hosts may restore the composer draft from it.
async function sendMessage(
text: string,
attachments?: SentAttachment[],
tags?: SentTag[]
): Promise<boolean> {
if (sending.value) {
// The composer already cleared its draft; a silently dropped send loses the text.
conversationStore.recordFailedSend(
nextLocalErrorId(),
text,
i18n.global.t('agent.sendBusy')
)
return false
}
sending.value = true
const wfContext = workflow?.current()
const upload = workflow?.snapshot?.()
const input = {
content: text,
selection:
tags !== undefined && tags.length > 0
? { node_ids: tags.map((tag) => tag.id) }
: undefined,
attachments: attachments?.map((attachment) => attachment.ref),
workflow: upload
}
async function postTurn(threadId: string) {
try {
return await rest.postMessage(
threadId,
wfContext ? { ...input, workflowId: wfContext.id } : input
)
} catch (error) {
const speculativeDenied =
wfContext?.speculative === true &&
error instanceof AgentApiError &&
error.status === 403
if (!speculativeDenied) throw error
return await rest.postMessage(threadId, input)
}
}
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, upload !== undefined)
}
// The ONE branding seam: the server-minted message_id becomes the TurnId.
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)
// Inline row only: a toast would double-surface this and overlap the docked panel.
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) {
// 409 = already settled server-side; the socket still delivers the terminal
// delta + done, so no local abort.
if (error.status === 409) return
pushError(error.message)
return
}
pushError(error instanceof Error ? error.message : String(error))
}
}
function newChat(): void {
// Cancel first: an abandoned turn keeps generating and billing. stopTurn reads its
// ids synchronously, so the reset below cannot race it.
void stopTurn()
conversationStore.reset()
// A late patch or resync for the abandoned thread's workflow must not land here.
draftStore.reset()
localStorage.removeItem(THREAD_STORAGE_KEY)
}
function listThreads() {
return rest.listThreads()
}
async function loadThread(threadId: string): Promise<void> {
void stopTurn()
// The switched-to thread's workflow binds on its next ack; drop the old one.
draftStore.reset()
conversationStore.setThreadId(threadId)
localStorage.setItem(THREAD_STORAGE_KEY, threadId)
await hydrateFromServer(threadId)
}
function onRaw(raw: unknown): void {
if (typeof raw !== 'object' || raw === null) return
const type = (raw as { type?: unknown }).type
// The event source subscribes by agent type, but gate again before paying for a
// zod parse so a mis-wired source cannot feed foreign frames into the turn logic.
if (typeof type !== 'string' || !isAgentEvent(type)) return
const parsed = parseAgentWsEvent(raw)
if (!parsed.success) {
// A malformed done for OUR turn must still settle it (or the spinner hangs);
// a readable FOREIGN message_id must not abort our turn.
const messageId = (raw as { data?: { message_id?: unknown } }).data
?.message_id
if (
type === 'agent_message_done' &&
(typeof messageId !== 'string' ||
messageId === conversationStore.activeTurnId)
) {
conversationStore.abortActiveTurn()
pushError(i18n.global.t('agent.malformedEvent'))
}
console.warn('[agent] dropping malformed agent event', parsed.error)
return
}
const event = parsed.data
switch (event.type) {
case 'draft_patch':
// Drafts are shared workflow state, never turn-filtered.
draftStore.applyPatch(event.data)
return
case 'draft_version':
if (draftStore.checkHeartbeat(event.data) === 'behind')
void resyncDraft()
return
default:
conversationStore.ingest(event)
}
}
function onStatus(live: boolean): void {
// Drop: settle the turn so no spinner hangs. Recover: resync missed draft patches.
if (live) void resyncDraft()
else conversationStore.abortActiveTurn()
}
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

@@ -0,0 +1,123 @@
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
}
// A live chip registry mirroring what the composer does with the callbacks, so
// each test asserts the states a user would actually see.
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()
// The full message pins the {name}/{limit} i18n interpolation; the count
// pins one error per rejected file.
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)])
// Visible before the upload finishes: name + preview, marked uploading.
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

@@ -0,0 +1,66 @@
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) {
// Each file stages an uploading chip immediately (local object-URL preview),
// then settles in place: the upload's ref on success, removal + error on failure.
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> {
// preventDefault before any await — a default drop navigates the tab away.
event.preventDefault()
const files = event.dataTransfer?.files
return files && files.length > 0
? addFiles(Array.from(files))
: Promise.resolve()
}
return { addFiles, onDrop }
}

View File

@@ -0,0 +1,59 @@
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
// watch re-evaluates on the reactive isLive change
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([])
// Same selection stays empty (already consumed) even after a reactive tick.
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

@@ -0,0 +1,66 @@
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[]>([])
// consumedSig: the same selection must not re-tag every message. stagedSig: a watcher
// re-fire with no real change must not resurrect tags the user dismissed via remove().
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)
}
// Manual staging from the @ picker; a later canvas-selection change still
// replaces the whole set (last gesture wins).
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

@@ -0,0 +1,135 @@
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')
// A submitted preview lives on in the transcript; submit must not touch it.
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()
// The draft is preserved on stop (only a real send clears it).
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

@@ -0,0 +1,76 @@
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[]>([])
// In-flight uploads block the send: their refs do not exist server-side yet,
// so sending now would silently drop what the user can see attached.
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)
// A dismissed chip's local preview has no other consumer (sent ones live on
// in the transcript); release the object URL's image buffer.
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

@@ -0,0 +1,82 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import type { DraftPatchData } from '../../schemas/agentApiSchema'
import { useAgentDraftStore } from '../../stores/agent/agentDraftStore'
import { useDraftCanvasApply } from './useDraftCanvasApply'
const WF = 'wf-1'
function patch(version: number): DraftPatchData {
return {
workflow_id: WF,
base_version: version - 1,
version,
content: { nodes: [{ id: version }] }
}
}
describe('useDraftCanvasApply', () => {
beforeEach(() => setActivePinia(createPinia()))
it('applies each newly adopted draft version with its content', async () => {
const store = useAgentDraftStore()
store.bind(WF)
const apply = vi.fn()
useDraftCanvasApply(apply)
store.adoptSnapshot({ content: { nodes: [] }, version: 5 })
await nextTick()
store.applyPatch(patch(6))
await nextTick()
expect(apply.mock.calls).toEqual([
[{ nodes: [] }],
[{ nodes: [{ id: 6 }] }]
])
})
it('does not fire for rejected (stale or foreign) patches', async () => {
const store = useAgentDraftStore()
store.bind(WF)
store.adoptSnapshot({ content: {}, version: 9 })
const apply = vi.fn()
useDraftCanvasApply(apply)
store.applyPatch(patch(9))
store.applyPatch({ ...patch(10), workflow_id: 'other' })
await nextTick()
expect(apply).not.toHaveBeenCalled()
})
it('does not fire with null content when the store resets or rebinds', async () => {
const store = useAgentDraftStore()
store.bind(WF)
store.adoptSnapshot({ content: {}, version: 3 })
const apply = vi.fn()
useDraftCanvasApply(apply)
store.reset()
await nextTick()
store.bind('wf-2')
await nextTick()
expect(apply).not.toHaveBeenCalled()
})
it('stops applying after the returned stop handle runs', async () => {
const store = useAgentDraftStore()
store.bind(WF)
const apply = vi.fn()
const stop = useDraftCanvasApply(apply)
stop()
store.applyPatch(patch(1))
await nextTick()
expect(apply).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,23 @@
import { watch } from 'vue'
import { useAgentDraftStore } from '../../stores/agent/agentDraftStore'
/**
* useDraftCanvasApply: the canvas half of the draft pipeline. Whenever a newer draft
* version lands in the store (live draft_patch or reconnect resync), the full graph is
* handed to the injected apply callback. The HOST injects the actual canvas load
* (e.g. app.loadGraphData); the panel itself has no canvas. Returns the watch stop
* handle so the host can tear down with its mount.
*/
export function useDraftCanvasApply(
apply: (content: Record<string, unknown>) => void
): () => void {
const draftStore = useAgentDraftStore()
return watch(
() => draftStore.version,
(version) => {
if (version === null || draftStore.content === null) return
apply(draftStore.content)
}
)
}

View File

@@ -0,0 +1,24 @@
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)
// useStorage flushes the write asynchronously; let it land before a fresh instance
// reads the persisted flag and confirms it does not re-run.
await nextTick()
const second = useOnboarding(KEY)
expect(second.active.value).toBe(false)
})
})

View File

@@ -0,0 +1,27 @@
import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
export interface CoachStep {
// A host DOM selector the spotlight anchors to (e.g. '#graph-canvas'). When the
// target is missing the coachmark stays hidden rather than pointing at nothing.
target: string
title: string
body: string
}
/**
* Onboarding coachmark state: persist-once (shown a single time per browser). Host DOM
* anchoring lives in the OnboardingCoach component; this only owns whether the single
* coachmark should show at all.
*/
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

@@ -0,0 +1,13 @@
{"capturedAt":1783366986602,"frame":{"data":{"delta":"I'll set the positive prompt to your red fox in the snow, then run it.","message_id":"0a0b096e-8169-4655-83ab-784558030c17","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_thinking"}}
{"capturedAt":1783366988747,"frame":{"data":{"args":["workflow","slots","workflow.json"],"message_id":"0a0b096e-8169-4655-83ab-784558030c17","status":"ok","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","tool_name":"list_slots"},"type":"agent_tool_call"}}
{"capturedAt":1783366991756,"frame":{"data":{"args":["workflow","ls-nodes","workflow.json"],"message_id":"0a0b096e-8169-4655-83ab-784558030c17","status":"ok","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","tool_name":"ls_nodes"},"type":"agent_tool_call"}}
{"capturedAt":1783366994714,"frame":{"data":{"delta":"There are two prompt nodes — let me confirm which is the positive one by checking the sampler's wiring.","message_id":"0a0b096e-8169-4655-83ab-784558030c17","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_thinking"}}
{"capturedAt":1783366997022,"frame":{"data":{"args":["nodes","show","KSampler"],"message_id":"0a0b096e-8169-4655-83ab-784558030c17","status":"ok","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","tool_name":"show_node"},"type":"agent_tool_call"}}
{"capturedAt":1783367000894,"frame":{"data":{"delta":"The positive prompt is node 3610737701189037 (the one I set earlier as the positive). Let me update it to the red fox and set a batch size of 1, then run.","message_id":"0a0b096e-8169-4655-83ab-784558030c17","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_thinking"}}
{"capturedAt":1783367002946,"frame":{"data":{"base_version":26,"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 red fox sitting in fresh snow, winter forest, highly detailed fur, 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":"0a0b096e-8169-4655-83ab-784558030c17","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","version":27,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_patch"}}
{"capturedAt":1783367002948,"frame":{"data":{"args":["workflow","set-widget","workflow.json","--actor","MjVkuhzz6vSuA19LWfBa9lMQdKv2","--base-version","26","--where","cloud","--","3610737701189037.text","a red fox sitting in fresh snow, winter forest, highly detailed fur, sharp focus, vibrant colors"],"message_id":"0a0b096e-8169-4655-83ab-784558030c17","status":"ok","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","tool_name":"set_widget"},"type":"agent_tool_call"}}
{"capturedAt":1783367004628,"frame":{"data":{"args":["workflow","set-widget","workflow.json","--actor","MjVkuhzz6vSuA19LWfBa9lMQdKv2","--base-version","27","--where","cloud","--","3095725229108875.batch_size","1"],"message_id":"0a0b096e-8169-4655-83ab-784558030c17","status":"ok","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","tool_name":"set_widget"},"type":"agent_tool_call"}}
{"capturedAt":1783367007855,"frame":{"data":{"delta":"Prompt set to the red fox in the snow. Generating your image now — this usually takes a moment.","message_id":"0a0b096e-8169-4655-83ab-784558030c17","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_thinking"}}
{"capturedAt":1783367011395,"frame":{"data":{"version":27,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
{"capturedAt":1783367012307,"frame":{"data":{"delta":"The graph is fully ready to go — prompt set to *\"a red fox sitting in fresh snow, winter forest, highly detailed fur, sharp focus, vibrant colors,\"* one image, 12 steps, CFG 6.5, on SDXL. But the run needs your approval before it can start (running spends credits), and I don't have a way to confirm that on my end.\n\nCould you approve/confirm the run so I can kick it off? Once you do, I'll generate the image and send you the result.","message_id":"0a0b096e-8169-4655-83ab-784558030c17","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_message_delta"}}
{"capturedAt":1783367012316,"frame":{"data":{"message_id":"0a0b096e-8169-4655-83ab-784558030c17","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":53394,"input_tokens":11662,"output_tokens":731,"total_tokens":12393}},"type":"agent_message_done"}}

View File

@@ -0,0 +1,3 @@
{"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

@@ -0,0 +1,7 @@
{"capturedAt":1783367023398,"frame":{"data":{"version":27,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
{"capturedAt":1783367035407,"frame":{"data":{"version":27,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
{"capturedAt":1783367047474,"frame":{"data":{"version":27,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
{"capturedAt":1783367059396,"frame":{"data":{"version":27,"workflow_id":"a81718a4-02ae-41e6-ae85-c33b7bb880f6"},"type":"draft_version"}}
{"capturedAt":1783367073388,"frame":{"data":{"delta":"Generating your red fox image now — this usually takes a short moment.","message_id":"162f34e8-a64c-40ff-bb8f-382c5ff9bf1b","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_thinking"}}
{"capturedAt":1783367080091,"frame":{"data":{"delta":"I hit a wall on my side: even with your go-ahead, I don't have an approval channel to actually start the run from here — the system is blocking it and asking that the run be triggered directly rather than by me.\n\nEverything is built and ready — your red fox prompt, 12 steps, CFG 6.5, one image, SDXL. If there's a **Run** control on your end for this workflow, go ahead and trigger it and it'll generate right away. If you're expecting me to run it and it keeps refusing, this is likely a permissions setting on the account that needs to be enabled before I can launch runs on your behalf.\n\nSorry for the snag — the workflow itself is complete and correct, so the moment the run can be triggered you'll get your image.","message_id":"162f34e8-a64c-40ff-bb8f-382c5ff9bf1b","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718"},"type":"agent_message_delta"}}
{"capturedAt":1783367080100,"frame":{"data":{"message_id":"162f34e8-a64c-40ff-bb8f-382c5ff9bf1b","thread_id":"d4c016c4-3b8c-44cf-97de-1ae27e43e718","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":17798,"input_tokens":1846,"output_tokens":318,"total_tokens":2164}},"type":"agent_message_done"}}

View File

@@ -0,0 +1,20 @@
{"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

@@ -0,0 +1,173 @@
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'
// Fixtures are read at test time via Vite's raw glob (the bundler-native equivalent
// of fs.readFileSync here, since @types/node is not wired into this tsconfig). Each
// .jsonl file lands as its raw text, split into lines below.
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 six agent event types', () => {
expect([...AGENT_WS_EVENT_TYPES].sort()).toEqual(
[
'agent_message_delta',
'agent_message_done',
'agent_thinking',
'agent_tool_call',
'draft_patch',
'draft_version'
].sort()
)
})
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

@@ -0,0 +1,198 @@
import { z } from 'zod'
// Wire contract for the In-App Agent REST + WebSocket backend, validated
// field-by-field against live frames in __fixtures__/agent/.
// Wire fields stay plain z.string(); the session layer brands ids as it adopts them,
// so a raw frame parses without a branding cast.
const zTurnId = z.string().brand<'TurnId'>()
export type TurnId = z.infer<typeof zTurnId>
// workflow_id is optional only because the openapi marks it so; every observed ack
// carries it.
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>
// content is omitted while a message is still streaming, so it stays optional.
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>
// title is "" until the server names the thread; preview carries the first prompt.
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 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>
// Plain-string error shape only; ingest-raised errors use a richer {error:{message,type}}
// shape that callers tolerate but do not model.
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>
// Each event rides the standard ComfyUI envelope {type, data}.
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()
// content is the full UI-format graph object (not a diff); base_version anchors it.
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(),
// The wire carries token usage (null on a cancelled turn) but the panel does not
// render it, so its inner shape stays unvalidated.
usage: z.unknown().nullish()
})
.passthrough()
// Heartbeat; carries no message_id/thread_id.
const zDraftVersionData = z
.object({
version: z.number().int(),
workflow_id: z.string()
})
.passthrough()
export type DraftVersionData = z.infer<typeof zDraftVersionData>
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
})
export const zAgentWsEvent = z.discriminatedUnion('type', [
zAgentThinkingEvent,
zAgentToolCallEvent,
zDraftPatchEvent,
zAgentMessageDeltaEvent,
zAgentMessageDoneEvent,
zDraftVersionEvent
])
export type AgentWsEvent = z.infer<typeof zAgentWsEvent>
// Host frames (e.g. {type:"status"}) ride the same /ws; isAgentEvent sorts agent events
// from foreign frames on the type string without zod-parsing them.
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'
])
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

@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest'
import { AGENT_WS_EVENT_TYPES } from '../../schemas/agentApiSchema'
import type { AgentEventHost } from './agentEventSource'
import { createAgentEventSource } from './agentEventSource'
// A minimal stand-in for the api singleton: registered custom types receive the
// frame's data as a CustomEvent detail (exactly what api.ts dispatches after its
// single JSON.parse), and reconnect liveness rides 'reconnecting'/'reconnected'.
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)
// Registration is what keeps api.ts from throwing 'Unknown message type'
// once per agent type; the schema's list is the authoritative set.
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

@@ -0,0 +1,58 @@
import type { AgentEventSource } from '../../composables/agent/useAgentSession'
import { AGENT_WS_EVENT_TYPES } from '../../schemas/agentApiSchema'
// The surface of the host api singleton this adapter binds to. The api JSON-parses
// every /ws frame ONCE and dispatches frames whose type was registered (via
// addCustomEventListener) as CustomEvents on itself — listeners survive socket
// reconnects because they bind to the api EventTarget, not the socket. Registering
// the agent types also stops the api's once-per-type "Unknown message type" throw.
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 // WebSocket.OPEN
export function createAgentEventSource(host: AgentEventHost): AgentEventSource {
return {
subscribe(listener) {
// The api dispatches only the frame's data as the event detail; rebuild the
// {type, data} envelope the zod event schema validates.
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)
// A socket already open before (re)binding must report live once, else the
// session's open-triggered draft resync never fires for it.
if (host.socket?.readyState === OPEN) listener(true)
return () => {
host.removeEventListener('reconnecting', onDown)
host.removeEventListener('reconnected', onUp)
}
}
}
}

View File

@@ -0,0 +1,218 @@
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'
// Fixtures are read at test time via Vite's raw glob (node:fs types are not wired
// into this tsconfig), mirroring agentApiSchema.test.ts.
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
}
// Parse every line, keep only chat events for the given message_id, in file order.
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
// The transport emits a fresh snapshot per applied event; the last snapshot is the
// settled message under test.
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
}
// Typed builders keep the unit cases free of `as` casts on inline literals.
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'
)
// The captured reply is the single message_delta, asserted against the fixture
// rather than hardcoded so a re-capture keeps the test honest.
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' })
// Tools come before the text: the last part is the reply.
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)
})
})

View File

@@ -0,0 +1,90 @@
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'
}
>
// settle closes the turn for both endings: the server's done event and a local
// abort (socket drop / superseded turn) — the settled message looks the same.
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
// v1 tool events carry no id; callId is synthesized by arrival order within the turn.
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 {
switch (event.type) {
case 'agent_thinking':
// v1 thinking is never persisted server-side; storing it locally would diverge
// from GET /messages on reload, so raise the transient chip only, no stored part.
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 emits its own snapshot; return so this event does not emit again below.
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

@@ -0,0 +1,52 @@
import type { TurnId } from '../../schemas/agentApiSchema'
// Part field shapes mirror ai's TextUIPart so a later swap to ai's own message
// model is mechanical.
export type PartState = 'streaming' | 'done'
export interface TextPart {
type: 'text'
text: string
state: PartState
}
// callId is synthesized in arrival order; the wire carries no tool id.
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
}
}
// Fresh reference per emit: Vue's set trap skips same-reference (Object.is) and deep
// in-place mutations, so the shallow clone is what makes streaming deltas propagate.
export function snapshotMessage(message: AssistantMessage): AssistantMessage {
return { ...message, parts: [...message.parts] }
}

View File

@@ -0,0 +1,219 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// The client rides the host transport (auth headers, 401 remint, Comfy-User all
// live in api.fetchApi); tests assert the route + init handed to that seam and
// drive responses back through it.
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('{}')
})
})
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')
// passthrough keeps the additive workflow_id key
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 () => {
// content + version are required by zAgentDraftSnapshot; a wrong shape must throw.
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

@@ -0,0 +1,184 @@
import type { z } from 'zod'
import { api } from '@/scripts/api'
import {
zAgentCancelAccepted,
zAgentDraftSnapshot,
zAgentError,
zAgentMessages,
zAgentThreads,
zAgentTurnAccepted,
zUploadImageResult
} from '../../schemas/agentApiSchema'
import type {
AgentCancelAccepted,
AgentDraftSnapshot,
AgentMessages,
AgentThreadSummary,
AgentTurnAccepted,
UploadImageResult
} from '../../schemas/agentApiSchema'
// body is the parsed error payload, or undefined when empty or not JSON.
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
}
}
// The client's canvas, uploaded with a turn so the server seeds the thread's
// draft from it before the agent runs. last_seen_version null force-seeds; a
// non-null value is the draft version the client last received, so the server
// can reject an upload built on a draft the agent has since moved past.
export interface WorkflowUpload {
graph: unknown
last_seen_version: number | null
}
export interface PostMessageInput {
content: string
workflowId?: string
selection?: Record<string, unknown>
attachments?: string[]
workflow?: WorkflowUpload
}
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)
}
// Rides api.fetchApi so agent calls share the host transport: auth headers
// (Firebase/workspace), the 401 remint retry, and the Comfy-User header.
// A schema violation throws zod's error uncaught here, by design (anti-drift seam).
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)
}
}
// threadId 'new' opens a thread as part of posting the first message.
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.selection !== undefined) body.selection = req.selection
if (req.attachments !== undefined) body.attachments = req.attachments
if (req.workflow !== undefined) body.workflow = req.workflow
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 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
)
}
// No Content-Type header is set so the browser writes the multipart boundary itself.
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,
cancelMessage,
getDraft,
uploadImage
}
}
export type AgentRestClient = ReturnType<typeof createAgentRestClient>

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import type { TurnId } from '../../schemas/agentApiSchema'
import type { AssistantMessage } from './agentMessageParts'
import type { ConversationEntry } from '../../stores/agent/agentConversationStore'
import { buildTranscriptMarkdown } from './agentTranscript'
describe('buildTranscriptMarkdown', () => {
it('renders a user line and an assistant line from a turn', () => {
const assistant: AssistantMessage = {
id: 'm-1' as TurnId,
role: 'assistant',
parts: [
{ type: 'text', text: 'Here is ', state: 'done' },
{ type: 'tool', callId: 'tool_0', name: 'add_node', state: 'done' },
{ type: 'text', text: 'a cat.', state: 'done' }
],
streaming: false,
thinking: false
}
const entries: ConversationEntry[] = [
{ id: 'm-1' as TurnId, role: 'user', text: 'make a cat' },
assistant
]
const md = buildTranscriptMarkdown(entries)
expect(md).toContain('**You:** make a cat')
// Text parts concatenate; the tool part contributes no plain-markdown body.
expect(md).toContain('**Agent:** Here is a cat.')
})
})

View File

@@ -0,0 +1,14 @@
import type { ConversationEntry } from '../../stores/agent/agentConversationStore'
export 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')
}

View File

@@ -0,0 +1,88 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { ChatSession } from './agentChatHistoryStore'
import {
groupSessionsByRecency,
useAgentChatHistoryStore
} from './agentChatHistoryStore'
// A fixed reference moment: 2026-03-15 12:00 local.
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', () => {
// NOW is 02:30 just after the US spring-forward (2026-03-08 02:00 -> 03:00), so today
// is a 23h day. A session at 23:30 the evening before must land in Yesterday. This pins
// the startYesterday = startOfLocalDay(startToday - 1) intent: a naive startToday - 24h
// drifts the yesterday boundary off local midnight around a DST transition. Constructed
// with a local Date; in a non-DST test TZ it degrades to a plain day-boundary check,
// which is still the correct bucket.
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

@@ -0,0 +1,77 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export interface ChatSession {
id: string
title: string
// epoch ms of the last activity in the session
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)
// Local-day start, not startToday - 24h, so a DST 23h/25h day doesn't misbucket yesterday.
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 grouped = computed(() =>
groupSessionsByRecency(sessions.value, activeId.value, Date.now())
)
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

@@ -0,0 +1,48 @@
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

@@ -0,0 +1,219 @@
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'
// Schema-honest event builders (the v1 analog of the old frame() helper): a typo in a
// test event surfaces as a parse error here, not as a silently-dropped event. Only chat
// events are built, so each parse result is narrowed to AgentChatEvent.
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()
// The fresh-reference-per-emit is what trips the watcher; an in-place mutation of
// the same object reference would NOT (Object.is on the slot stays equal).
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)
// The settled message itself carries streaming=false (the transport's in-place
// abort), not merely the null-active fallback of isStreaming.
expect(store.messages[0].streaming).toBe(false)
// History is preserved (the partial message stays), unlike reset().
expect(store.messages).toHaveLength(1)
// A second abort is a no-op.
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'))
// An event from a different (stale/foreign) turn must not bleed in. Every v1 chat
// event carries data.message_id, so there is no absent-id case to test.
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)
// The prior turn is settled (no lingering spinner), the new one is live.
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' }
])
}
// No transport opened, no active turn touched.
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')
// The live turn is untouched: still active and streaming.
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

@@ -0,0 +1,226 @@
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'
// What a sent attachment leaves behind in the transcript: the upload's display
// name plus a local preview (object URL); the server ref is not re-fetchable yet.
export interface UserAttachment {
name: string
previewUrl?: string
}
// Recorded at send time because the event stream is assistant-only; paired to its
// assistant turn by the shared TurnId (the server-minted message_id).
interface UserEntry {
id: TurnId
role: 'user'
text: string
attachments?: UserAttachment[]
tags?: string[]
}
export type ConversationEntry = UserEntry | AssistantMessage
export const useAgentConversationStore = defineStore(
'agentConversation',
() => {
const messages = ref<AssistantMessage[]>([])
const activeTurnId = ref<TurnId | null>(null)
// Held here (not the session composable) so it survives a panel remount: a reopened
// panel with a null thread would post to 'new' and split the transcript across two
// server threads.
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
// Reactive so status/isStreaming re-derive the instant a turn opens or settles; a plain
// closure int leaves those computeds stale until the next messages mutation.
const activeIndex = ref(-1)
function replaceActive(message: AssistantMessage): void {
if (activeIndex.value >= 0) messages.value[activeIndex.value] = 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
}
// Opens no transport and leaves activeTurnId/activeIndex untouched, so any in-flight
// turn is unaffected.
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)
}
// Abort any prior in-flight turn first so a dropped reply can't leave two turns live.
function startTurn(turnId: TurnId): void {
if (transport) abortActiveTurn()
const message = createAssistantMessage(turnId)
activeIndex.value = messages.value.push(message) - 1
activeTurnId.value = turnId
transport = createAgentEventTransport(message, replaceActive)
}
// The store owns turn filtering: events for a foreign turn are dropped here. Every v1
// chat event carries data.message_id, so there is no absent-id case to guard.
function ingest(event: AgentChatEvent): void {
if (!transport) return
if (event.data.message_id !== activeTurnId.value) return
if (event.type === 'agent_message_done') {
transport.settle()
clearActive()
return
}
transport.ingest(event)
}
// Settle the in-flight turn WITHOUT a done (socket drop mid-turn): close open blocks and
// clear transport/activeTurnId so no spinner is stuck forever.
function abortActiveTurn(): void {
if (!transport) return
transport.settle()
clearActive()
}
function clearActive(): void {
transport = null
activeIndex.value = -1
activeTurnId.value = null
}
// Dropped transcript previews are object URLs pinning image buffers;
// release them or every sent image outlives its conversation.
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()
}
// Rows pair by turn_id and order by seq. A turn whose assistant row is missing
// (interrupted before the reply persisted) still gets a settled placeholder so its user
// prompt renders.
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
// The history endpoint does not return attachment refs or tags yet;
// hydrated turns render text-only until the BE persists them.
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,
reset,
hydrate
}
}
)

View File

@@ -0,0 +1,221 @@
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'
// Minimal full-graph content mirroring draft_patch.content in ws-turn-edit.jsonl:
// a single-node snapshot whose lone widget value carries the version's edit.
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)
// base_version 26 would gap the 25 we hold; content is a full snapshot, so the
// monotonic jump is still adopted without a refetch.
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

@@ -0,0 +1,64 @@
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)
// Switching workflows clears content/version so a prior workflow's draft can't bleed in.
function bind(id: string): void {
if (id === workflowId.value) return
workflowId.value = id
content.value = null
version.value = null
}
// draft_patch.content is the FULL graph snapshot at that version, NOT a diff, so a
// base_version gap does not force a mid-stream refetch; monotonic adoption suffices.
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'
}
// Equal-version re-adopt is a harmless idempotent refresh; an older snapshot is ignored.
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

@@ -0,0 +1,62 @@
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('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

@@ -0,0 +1,63 @@
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', () => {
// Mirrors the PostHog flag gate (fail-closed): host renders the panel only while on.
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' })
}
// Idempotent: the flag gate re-syncs close() on every flag evaluation, and a
// no-op close must not emit a phantom closed event or corrupt the duration.
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

@@ -0,0 +1,35 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useAgentWorkflowTabBindingStore } from './agentWorkflowTabBindingStore'
describe('agentWorkflowTabBindingStore', () => {
beforeEach(() => {
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')
})
})

View File

@@ -0,0 +1,31 @@
import { defineStore } from 'pinia'
import { reactive } from 'vue'
// 1:1 workflow_id ↔ tab-path binding: sends resolve the active tab to its id,
// patches resolve back to their tab, so sequential agent edits land in ONE tab.
export const useAgentWorkflowTabBindingStore = defineStore(
'agentWorkflowTabBinding',
() => {
const tabByWorkflow = reactive(new Map<string, string>())
function bind(workflowId: string, tabPath: string): void {
for (const [boundId, boundPath] of tabByWorkflow) {
if (boundPath === tabPath) tabByWorkflow.delete(boundId)
}
tabByWorkflow.set(workflowId, tabPath)
}
function tabPathFor(workflowId: string): string | undefined {
return tabByWorkflow.get(workflowId)
}
function workflowIdFor(tabPath: string): string | undefined {
for (const [workflowId, boundPath] of tabByWorkflow) {
if (boundPath === tabPath) return workflowId
}
return undefined
}
return { bind, tabPathFor, workflowIdFor }
}
)