Compare commits

...

89 Commits

Author SHA1 Message Date
Nathaniel Parson Koroso
3d82f92d4c fix(agent): keep panel mounted under dialogs 2026-07-14 09:34:28 -07:00
Nathaniel Parson Koroso
4cae69c445 feat(agent): resolve open tabs to cloud workflow ids
The agent could only ever see the last-messaged tab because saved tabs
had no id the agent service recognizes. The Cloud Ingest workflows API
turns out to be that id space (live-verified: its ids are accepted by
the messages and draft endpoints), so every message now refreshes a
name-to-id index from GET /workflows (paginated, capped, warn on
truncation) and resolves each open saved tab through it, with the
ack-binding store as fallback for unsaved tabs. Reverse resolution
activates the existing tab on switch_tab instead of minting a copy.

Ambiguity never guesses: duplicate or nameless cloud records and
same-named open tabs are excluded from resolution and fall back to
path-exact bindings. The per-send refresh is raced against a timeout
so a slow index can never block sending.

Draft-bearing sends that 5xx now retry once without the draft so a
drafts outage degrades to plain chat, re-arming the upload for the
next message.
2026-07-14 01:45:34 -07:00
Nathaniel Parson Koroso
2c37614589 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	pnpm-lock.yaml
#	src/platform/telemetry/TelemetryRegistry.ts
#	src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.ts
2026-07-14 00:29:04 -07:00
Nathaniel Parson Koroso
17334a1811 fix(agent): stop guessing workflow ids and follow bindings across renames
The frontend sent the graph JSON's internal id as a speculative
workflow_id, which the backend never issued, so every unbound send
403'd before the retry stripped it. Remove the guess entirely: bound
tabs send their bound id, unbound tabs send none and adopt through the
uploaded draft.

Dedup draft uploads by content and tab path so a Save As still uploads,
and reclaim the binding when an unbound active tab's canvas exactly
matches the last graph known for a workflow, so renamed tabs keep their
workflow identity instead of hijacking the focused one. The reclaim
fingerprint resets with the other guards and is consumed on use.
2026-07-14 00:01:14 -07:00
Nathaniel Parson Koroso
52f6ae1db5 fix(agent): stop shipping agent panel css on flag-off pages
Move the cascading rules (keyframes, .agent-shimmer-text, scoped reset)
from globally imported agentTheme.css into agentPanel.css, loaded only
by the lazy AgentPanelRoot chunk; agentTheme.css keeps the @theme blocks.
Dist grep: cascading rules land in AgentPanelRoot-*.css only, not entry css.
2026-07-14 00:01:14 -07:00
Nathaniel Parson Koroso
321a74bdd0 fix(agent): close docked panel for subscription dialogs 2026-07-13 23:45:36 -07:00
Nathaniel Parson Koroso
c75621a17f Revert "fix(agent): publish docked panel workspace inset"
This reverts commit 5a634a150a.
2026-07-13 23:28:51 -07:00
Nathaniel Parson Koroso
dbf1abca86 Revert "fix(agent): use Vue effect for workspace inset"
This reverts commit 11a65fd49b.
2026-07-13 23:28:51 -07:00
Nathaniel Parson Koroso
11a65fd49b fix(agent): use Vue effect for workspace inset 2026-07-13 23:16:25 -07:00
Nathaniel Parson Koroso
d28583f9f1 feat(agent): multi-tab awareness for the agent
Implement the tabs and activity spec end to end:
- Handle agent_active_tab: activate the bound tab for the workflow, or
  mint, open, and autosave a named temporary tab for an unknown one.
  Draft bases adopt without re-rendering versions already on canvas,
  and concurrent focus events serialize latest-wins.
- Send open_tabs and current_tab with every message so the agent can
  address the user's open workflows by name and switch between them.
- Persist the workflow-tab binding map in localStorage so bindings
  survive reloads and backgrounded tabs stay addressable.
- Render friendly labels for new_tab, switch_tab, remember, and forget
  tool calls; unknown tools keep their raw name via a prototype-safe
  lookup.
2026-07-13 23:02:38 -07:00
Nathaniel Parson Koroso
5a634a150a fix(agent): publish docked panel workspace inset 2026-07-13 22:27:33 -07:00
Nathaniel Parson Koroso
122ce0f073 fix(agent): keep turns streaming across chat switches and panel close
- loadThread and newChat fired a server-side cancel for the in-flight
  turn; the backend recorded it and the reply was lost ("Stopped
  streaming..."). Panel unmount did the same. All implicit cancels are
  removed; the explicit Stop button is the only cancel left
- switching away parks the live turn (message + event transport) in a
  per-thread registry; events keep accumulating silently and cannot
  bleed into the displayed chat; switching back re-attaches the turn
  live with no lost text, or renders the finished reply from history
- a done landing during the return fetch keeps the parked entry so the
  full reply still renders; overlapping or double-clicked history loads
  are generation-gated; socket death and panel close settle parked
  turns; remount refreshes a surviving thread from history
- draft patches from a backgrounded thread no longer drive the
  displayed canvas (thread guard; version heartbeat unchanged)
- transport ignores events after settle; chat history date buckets
  follow a reactive clock across midnight; onboarding coach gains
  dialog semantics and Escape to dismiss; dead approval-flow fixtures
  removed
2026-07-10 19:05:01 -07:00
Nathaniel Parson Koroso
22043354c0 feat(telemetry): allow a local dev PostHog token via VITE_POSTHOG_PROJECT_TOKEN
Deployed builds keep reading the token from the backend-injected __CONFIG__
(precedence unchanged); the env fallback only enables event verification from
local dev servers, which otherwise have no key and silently queue forever.
2026-07-10 17:19:02 -07:00
Nathaniel Parson Koroso
39cc78727d refactor(agent): fold seven single-consumer files into their owners
Per the per-file consolidation audit (temp/plans/pr13472-file-consolidation-audit.md):
safetyTypes -> ConflictDialog (SFC type export, ActiveTabStrip pattern),
agentTranscript + useDraftCanvasApply -> AgentPanelRoot, ThinkingStatus ->
AgentMessage, SuggestedPromptChip -> EmptyState, SelectionTagChip -> Composer,
SessionBar -> AgentPanel. All moves byte-identical; net -9 files.

agentTranscript's test is replaced by a stronger end-to-end copy test
asserting the exact clipboard markdown. useDraftCanvasApply's tests were
retired as redundant rather than merged: patch-to-canvas firing is covered
end-to-end by the draft binding suite, rejection semantics are owned by
agentDraftStore.test.ts, and the stop handle was unused API removed by the
fold.
2026-07-10 17:17:40 -07:00
Nathaniel Parson Koroso
7a05020f55 fix(agent): persist applied drafts so tabs stop showing unsaved
- autosaveAppliedDraft only reset the change tracker and isModified,
  but the tab dot renders whenever a workflow is not persisted, so
  agent-applied drafts always showed unsaved and lived only in memory
- save through workflowService instead: temporary tabs use a silent
  saveWorkflowAs with a collision-safe filename (no prompt or
  overwrite dialog), persisted tabs re-save silently
- rebind workflow_id to the tab current path in finally so service
  renames (collision bump, app extension mismatch) and failed saves
  never strand the binding on a dead path
- known limit: host saveWorkflow drift handling can rarely toast or
  confirm on persisted tabs; reusing the canonical save path there
  is intentional
- tests: failure isolation keeps the applied draft and surfaces no
  error, an app-mode collision bumps the filename and rebinds, a
  save that fails after rename still rebinds; binding tests now
  stub a valid empty threads page
2026-07-10 17:15:27 -07:00
Nathaniel Parson Koroso
dc1174e0e1 chore: remove redundant comments 2026-07-10 15:48:26 -07:00
Nathaniel Parson Koroso
ac0dead740 feat(agent): match the live draft-seeding contract
The backend now seeds the thread's draft from the graph the client
sends each turn. Align to its shape and rules:
- The turn POST carries draft: { content: <serialize() save format>,
  version: <last seen draft version | null> } (was the inert
  workflow/graph/last_seen_version shape).
- Snapshots gate on canvas-non-empty, not workflow_id, so templates,
  unsaved tabs, and new tabs seed correctly; skipping an unchanged
  canvas stays as the server-blessed optimization.
- A 409 {error, version} means the agent's draft advanced: adopt the
  returned version and retry the send once.
2026-07-10 15:06:22 -07:00
Nathaniel Parson Koroso
a7fffdcb4b fix(agent): drop the client-side canvas upload size cap
The 2MB cap guarded a server limit that does not exist yet and silently
skipped uploads for large graphs, which are normal on a video/image
platform. The server owns its own limits.
2026-07-10 15:00:48 -07:00
Nathaniel Parson Koroso
bca7995b4c fix(telemetry): drop the no-op posthog-js import retry
The module map caches a failed module resolution, so re-importing the
same specifier rejects from cache without refetching; the retry could
never succeed. Stale-chunk failures are already handled app-wide by the
vite:preloadError listener in App.vue.
2026-07-10 12:38:03 -07:00
Nathaniel Parson Koroso
41bd6a8406 fix(telemetry): server config owns person_profiles; retry PostHog load
The client hardcoded person_profiles: 'identified_only' AFTER the
posthog_config spread, silently overriding the server's explicit
'always' (live on /api/features). Under identified_only every event
captured before identify() resolves is a person-less anonymous event
that never attaches to the user, so early-session events (entry click,
panel opened, first message) vanish from person-scoped PostHog views.
The hardcode is now a default the server can override; before_send
stays locked (PII stripping). Reverses the person_profiles half of the
pin added in #12479, test updated accordingly.

Also retries the posthog-js dynamic import once: a stale deploy can
404 the chunk, which previously disabled telemetry for the entire
session after a single console.error.
2026-07-10 12:36:03 -07:00
Nathaniel Parson Koroso
ccf4642882 Merge remote-tracking branch 'origin/nathaniel/fe-1187-agent-side-panel-fe-implementation' into nathaniel/fe-1187-agent-side-panel-fe-implementation 2026-07-10 12:20:24 -07:00
Nathaniel Parson Koroso
9d30613860 Merge branch 'nathaniel/fe-1187-agent-panel-trim': audited size trim
Merges the reviewed trim (-1,189 net lines, 19 fewer files, shiki
dropped, four host regressions fixed, zero functionality loss;
validated by a full review pipeline) into the PR branch as a single
revertible merge. Conflict resolutions favor the amended stable side
where it superseded the trim: raw-serialization autosave baseline
(schema-normalized baselines re-flip isModified via graphEqual),
dedupe-gated mention telemetry, the richer WorkflowTabs CTA tests, and
the validate-once assertion; the trim's ws event fake wins in the Root
tests.
2026-07-10 12:19:00 -07:00
Nathaniel Parson Koroso
5727717ef3 test(agent): pin the raw autosave baseline and harden metric coverage
autosaveAppliedDraft re-baselines with the RAW canvas serialization: a
schema-validated baseline diffs against raw captures under strict
graphEqual and re-raises the conflict dialog on an unedited canvas. A
test pins validateComfyWorkflow to its single loadDraft call so the
baseline can never route through the schema.

Coverage hardening from review: entry-click resulting_state both ways,
close-button click attribution through the real store, message_sent
payloads (attachments and node tags), mention-pick positive plus
stages-nothing negative, null open_duration_ms, flag_disabled pin.
2026-07-10 11:01:22 -07:00
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
f62d7af7c5 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).
The entry/close click events are kept alongside the state events
deliberately: the V0 metrics checklist names them as PM-queryable
trackers, though the state events' sources make them derivable.

Also hardens autosaveAppliedDraft: the tracker re-baselines with the RAW
canvas serialization. A schema-validated baseline would diff against raw
captures under strict graphEqual and re-raise the conflict dialog on an
unedited canvas; a test pins validateComfyWorkflow to its single
loadDraft call so the baseline can never route through the schema.
2026-07-09 21:09:49 -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
85 changed files with 12242 additions and 329 deletions

View File

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

View File

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

View File

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

276
pnpm-lock.yaml generated
View File

@@ -167,7 +167,7 @@ catalogs:
version: 2.0.1
'@vitejs/plugin-vue':
specifier: ^6.0.0
version: 6.0.3
version: 6.0.7
'@vitest/coverage-v8':
specifier: ^4.0.16
version: 4.0.16
@@ -380,7 +380,7 @@ catalogs:
version: 3.2.2
vite-plugin-vue-devtools:
specifier: ^8.0.0
version: 8.0.5
version: 8.1.2
vitest:
specifier: ^4.1.0
version: 4.1.8
@@ -389,7 +389,7 @@ catalogs:
version: 3.5.34
vue-component-type-helpers:
specifier: ^3.2.1
version: 3.3.2
version: 3.3.7
vue-eslint-parser:
specifier: ^10.4.0
version: 10.4.0
@@ -717,7 +717,7 @@ importers:
version: 0.184.1
'@vitejs/plugin-vue':
specifier: 'catalog:'
version: 6.0.3(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
version: 6.0.7(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
'@vitest/coverage-v8':
specifier: 'catalog:'
version: 4.0.16(vitest@4.1.8)
@@ -861,13 +861,13 @@ importers:
version: 3.2.2(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-plugin-vue-devtools:
specifier: 'catalog:'
version: 8.0.5(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
version: 8.1.2(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
vitest:
specifier: 'catalog:'
version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/coverage-v8@4.0.16)(@vitest/ui@4.0.16)(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vue-component-type-helpers:
specifier: 'catalog:'
version: 3.3.2
version: 3.3.7
vue-eslint-parser:
specifier: 'catalog:'
version: 10.4.0(eslint@10.4.0(jiti@2.6.1))
@@ -925,7 +925,7 @@ importers:
version: 4.3.0(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
'@vitejs/plugin-vue':
specifier: 'catalog:'
version: 6.0.3(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
version: 6.0.7(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
dotenv:
specifier: 'catalog:'
version: 16.6.1
@@ -943,7 +943,7 @@ importers:
version: 3.2.2(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-plugin-vue-devtools:
specifier: 'catalog:'
version: 8.0.5(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
version: 8.1.2(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
vue-tsc:
specifier: 'catalog:'
version: 3.2.5(typescript@5.9.3)
@@ -1037,7 +1037,7 @@ importers:
version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/coverage-v8@4.0.16(vitest@4.1.8))(@vitest/ui@4.0.16(vitest@4.1.8))(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vue-component-type-helpers:
specifier: 'catalog:'
version: 3.3.2
version: 3.3.7
packages/comfyui-desktop-bridge-types: {}
@@ -3305,9 +3305,6 @@ packages:
cpu: [x64]
os: [win32]
'@rolldown/pluginutils@1.0.0-beta.53':
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
@@ -3467,32 +3464,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':
@@ -4206,13 +4203,6 @@ packages:
vite: ^8.0.13
vue: ^3.0.0
'@vitejs/plugin-vue@6.0.3':
resolution: {integrity: sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
vite: ^8.0.13
vue: ^3.2.25
'@vitejs/plugin-vue@6.0.7':
resolution: {integrity: sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -4369,11 +4359,6 @@ packages:
'@vue/devtools-api@7.7.9':
resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==}
'@vue/devtools-core@8.0.5':
resolution: {integrity: sha512-dpCw8nl0GDBuiL9SaY0mtDxoGIEmU38w+TQiYEPOLhW03VDC0lfNMYXS/qhl4I0YlysGp04NLY4UNn6xgD0VIQ==}
peerDependencies:
vue: ^3.0.0
'@vue/devtools-core@8.1.2':
resolution: {integrity: sha512-ZGGyaSBP4/+bN2Nd9ZHNYAVDRIzMw1rv2RyXWtyZlo6mQal+IDmTvKY4V+DjAEBhaXt30mHmsgYp1yXJ/2tIWg==}
peerDependencies:
@@ -4382,18 +4367,12 @@ packages:
'@vue/devtools-kit@7.7.9':
resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==}
'@vue/devtools-kit@8.0.5':
resolution: {integrity: sha512-q2VV6x1U3KJMTQPUlRMyWEKVbcHuxhqJdSr6Jtjz5uAThAIrfJ6WVZdGZm5cuO63ZnSUz0RCsVwiUUb0mDV0Yg==}
'@vue/devtools-kit@8.1.2':
resolution: {integrity: sha512-f75/upc+GCyjXErpgPGz4582ujS0L/adAltGy+tqXMGUJpgAcfGr6CxnnhpZY8BHuMYt6KpbF8uaFrrQG66rGQ==}
'@vue/devtools-shared@7.7.9':
resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==}
'@vue/devtools-shared@8.0.5':
resolution: {integrity: sha512-bRLn6/spxpmgLk+iwOrR29KrYnJjG9DGpHGkDFG82UM21ZpJ39ztUT9OXX3g+usW7/b2z+h46I9ZiYyB07XMXg==}
'@vue/devtools-shared@8.1.2':
resolution: {integrity: sha512-X9RyVFYAdkBe4IUf5v48TxBF/6QPmF8CmWrDAjXzfUHrgQ/HGfTC1A6TqgXqZ03ye66l3AD51BAGD69IvKM9sw==}
@@ -6709,10 +6688,6 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@11.2.6:
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
engines: {node: 20 || >=22}
lru-cache@11.5.1:
resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==}
engines: {node: 20 || >=22}
@@ -6746,9 +6721,6 @@ packages:
resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==}
engines: {node: '>=12'}
magicast@0.5.1:
resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==}
magicast@0.5.3:
resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==}
@@ -7072,11 +7044,6 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.5:
resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==}
engines: {node: ^18 || >=20}
hasBin: true
napi-postinstall@0.3.3:
resolution: {integrity: sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -7927,8 +7894,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:
@@ -8253,10 +8220,6 @@ packages:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'}
tinyrainbow@3.0.3:
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
engines: {node: '>=14.0.0'}
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
@@ -8669,23 +8632,12 @@ packages:
'@nuxt/kit':
optional: true
vite-plugin-vue-devtools@8.0.5:
resolution: {integrity: sha512-p619BlKFOqQXJ6uDWS1vUPQzuJOD6xJTfftj57JXBGoBD/yeQCowR7pnWcr/FEX4/HVkFbreI6w2uuGBmQOh6A==}
engines: {node: '>=v14.21.3'}
peerDependencies:
vite: ^8.0.13
vite-plugin-vue-devtools@8.1.2:
resolution: {integrity: sha512-gt5h1CNryR9Hy0tvhSbqY3j0F7aj0pGxBxWLa1lXSiZVkhdWDf0vbCOZyjh8ivFGE6FDHTGy3zkcZGlMZdVHig==}
engines: {node: '>=v14.21.3'}
peerDependencies:
vite: ^8.0.13
vite-plugin-vue-inspector@5.3.2:
resolution: {integrity: sha512-YvEKooQcSiBTAs0DoYLfefNja9bLgkFM7NI2b07bE2SruuvX0MEa9cMaxjKVMkeCp5Nz9FRIdcN1rOdFVBeL6Q==}
peerDependencies:
vite: ^8.0.13
vite-plugin-vue-inspector@6.0.0:
resolution: {integrity: sha512-OpyITJLgZNibxlrik1EmRtvXHDjLRxNPsWkGFTERZs2LgMEdG4W0WoFt5GIgp3a3jRou+eJR8U1zOBk/XQgEbw==}
peerDependencies:
@@ -8890,11 +8842,8 @@ packages:
vue-component-type-helpers@2.2.12:
resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==}
vue-component-type-helpers@3.3.2:
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
vue-component-type-helpers@3.3.6:
resolution: {integrity: sha512-FkljacAwJ9BUoSUdpFe3VDy0sGigNlTH9+2zcXUWmZOjN8swiCkl3t48wOJun0OsUd2cEIda1l04tsxMiKIIrQ==}
vue-component-type-helpers@3.3.7:
resolution: {integrity: sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -9340,7 +9289,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:
@@ -9348,7 +9297,7 @@ snapshots:
bidi-js: 1.0.3
css-tree: 3.1.0
is-potential-custom-element-name: 1.0.1
lru-cache: 11.2.6
lru-cache: 11.5.1
'@asamuzakjp/nwsapi@2.3.9': {}
@@ -9374,7 +9323,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
@@ -11362,8 +11311,6 @@ snapshots:
'@rolldown/binding-win32-x64-msvc@1.0.1':
optional: true
'@rolldown/pluginutils@1.0.0-beta.53': {}
'@rolldown/pluginutils@1.0.1': {}
'@rollup/pluginutils@4.2.1':
@@ -11537,40 +11484,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
@@ -11677,7 +11624,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.6
vue-component-type-helpers: 3.3.7
'@swc/helpers@0.5.21':
dependencies:
@@ -12303,18 +12250,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-vue@6.0.3(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
'@vitejs/plugin-vue@6.0.7(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.53
'@rolldown/pluginutils': 1.0.1
vite: 8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vue: 3.5.34(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.3(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.53
vite: 8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vue: 3.5.34(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.7(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.1
@@ -12330,10 +12271,10 @@ snapshots:
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 5.0.6
istanbul-reports: 3.2.0
magicast: 0.5.1
magicast: 0.5.3
obug: 2.1.1
std-env: 3.10.0
tinyrainbow: 3.0.3
tinyrainbow: 3.1.0
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/coverage-v8@4.0.16)(@vitest/ui@4.0.16)(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
transitivePeerDependencies:
- supports-color
@@ -12377,7 +12318,7 @@ snapshots:
'@vitest/pretty-format@4.0.16':
dependencies:
tinyrainbow: 3.0.3
tinyrainbow: 3.1.0
'@vitest/pretty-format@4.1.8':
dependencies:
@@ -12409,7 +12350,7 @@ snapshots:
pathe: 2.0.3
sirv: 3.0.2
tinyglobby: 0.2.16
tinyrainbow: 3.0.3
tinyrainbow: 3.1.0
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/coverage-v8@4.0.16)(@vitest/ui@4.0.16)(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
'@vitest/utils@3.2.4':
@@ -12421,7 +12362,7 @@ snapshots:
'@vitest/utils@4.0.16':
dependencies:
'@vitest/pretty-format': 4.0.16
tinyrainbow: 3.0.3
tinyrainbow: 3.1.0
'@vitest/utils@4.1.8':
dependencies:
@@ -12590,30 +12531,6 @@ snapshots:
dependencies:
'@vue/devtools-kit': 7.7.9
'@vue/devtools-core@8.0.5(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
dependencies:
'@vue/devtools-kit': 8.0.5
'@vue/devtools-shared': 8.0.5
mitt: 3.0.1
nanoid: 5.1.5
pathe: 2.0.3
vite-hot-client: 2.1.0(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vue: 3.5.34(typescript@5.9.3)
transitivePeerDependencies:
- vite
'@vue/devtools-core@8.0.5(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
dependencies:
'@vue/devtools-kit': 8.0.5
'@vue/devtools-shared': 8.0.5
mitt: 3.0.1
nanoid: 5.1.5
pathe: 2.0.3
vite-hot-client: 2.1.0(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vue: 3.5.34(typescript@5.9.3)
transitivePeerDependencies:
- vite
'@vue/devtools-core@8.1.2(vue@3.5.34(typescript@5.9.3))':
dependencies:
'@vue/devtools-kit': 8.1.2
@@ -12630,16 +12547,6 @@ snapshots:
speakingurl: 14.0.1
superjson: 2.2.2
'@vue/devtools-kit@8.0.5':
dependencies:
'@vue/devtools-shared': 8.0.5
birpc: 2.9.0
hookable: 5.5.3
mitt: 3.0.1
perfect-debounce: 2.0.0
speakingurl: 14.0.1
superjson: 2.2.2
'@vue/devtools-kit@8.1.2':
dependencies:
'@vue/devtools-shared': 8.1.2
@@ -12651,10 +12558,6 @@ snapshots:
dependencies:
rfdc: 1.4.1
'@vue/devtools-shared@8.0.5':
dependencies:
rfdc: 1.4.1
'@vue/devtools-shared@8.1.2': {}
'@vue/language-core@2.2.0(typescript@5.9.3)':
@@ -12988,7 +12891,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
@@ -15229,8 +15132,6 @@ snapshots:
lru-cache@10.4.3: {}
lru-cache@11.2.6: {}
lru-cache@11.5.1: {}
lru-cache@5.1.1:
@@ -15259,12 +15160,6 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magicast@0.5.1:
dependencies:
'@babel/parser': 7.29.3
'@babel/types': 7.29.0
source-map-js: 1.2.1
magicast@0.5.3:
dependencies:
'@babel/parser': 7.29.3
@@ -15873,8 +15768,6 @@ snapshots:
nanoid@3.3.12: {}
nanoid@5.1.5: {}
napi-postinstall@0.3.3: {}
natural-compare@1.4.0: {}
@@ -16227,7 +16120,7 @@ snapshots:
path-scurry@2.0.2:
dependencies:
lru-cache: 11.2.6
lru-cache: 11.5.1
minipass: 7.1.3
path-type@4.0.0: {}
@@ -17007,14 +16900,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
@@ -17380,8 +17273,6 @@ snapshots:
tinyrainbow@2.0.0: {}
tinyrainbow@3.0.3: {}
tinyrainbow@3.1.0: {}
tinyspy@4.0.4: {}
@@ -17850,29 +17741,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
vite-plugin-vue-devtools@8.0.5(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)):
vite-plugin-vue-devtools@8.1.2(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)):
dependencies:
'@vue/devtools-core': 8.0.5(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
'@vue/devtools-kit': 8.0.5
'@vue/devtools-shared': 8.0.5
'@vue/devtools-core': 8.1.2(vue@3.5.34(typescript@5.9.3))
'@vue/devtools-kit': 8.1.2
'@vue/devtools-shared': 8.1.2
sirv: 3.0.2
vite: 8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-plugin-inspect: 11.3.3(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-plugin-vue-inspector: 5.3.2(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
transitivePeerDependencies:
- '@nuxt/kit'
- supports-color
- vue
vite-plugin-vue-devtools@8.0.5(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)):
dependencies:
'@vue/devtools-core': 8.0.5(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
'@vue/devtools-kit': 8.0.5
'@vue/devtools-shared': 8.0.5
sirv: 3.0.2
vite: 8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-plugin-inspect: 11.3.3(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-plugin-vue-inspector: 5.3.2(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-plugin-vue-inspector: 6.0.0(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
transitivePeerDependencies:
- '@nuxt/kit'
- supports-color
@@ -17892,7 +17769,7 @@ snapshots:
- supports-color
- vue
vite-plugin-vue-inspector@5.3.2(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
vite-plugin-vue-inspector@6.0.0(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
@@ -17907,21 +17784,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
vite-plugin-vue-inspector@5.3.2(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
'@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0)
'@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0)
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0)
'@vue/babel-plugin-jsx': 1.4.0(@babel/core@7.29.0)
'@vue/compiler-dom': 3.5.34
kolorist: 1.8.0
magic-string: 0.30.21
vite: 8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
transitivePeerDependencies:
- supports-color
vite-plugin-vue-inspector@6.0.0(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
dependencies:
'@babel/core': 7.29.0
@@ -18147,9 +18009,7 @@ snapshots:
vue-component-type-helpers@2.2.12: {}
vue-component-type-helpers@3.3.2: {}
vue-component-type-helpers@3.3.6: {}
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,5 @@
@import '@comfyorg/design-system/css/style.css';
@import '../../workbench/extensions/agent/agentTheme.css';
/* Use 0.001ms instead of 0s so transitionend/animationend events still fire
and JS listeners aren't broken. */

View File

@@ -1,133 +1,154 @@
<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" />
<div
class="pointer-events-none flex min-w-0 flex-1 flex-col overflow-hidden"
>
<slot name="workflow-tabs" />
<div
class="pointer-events-none flex flex-1 overflow-hidden"
:class="{
'flex-row': sidebarLocation === 'left',
'flex-row-reverse': sidebarLocation === 'right'
}"
>
<div class="side-toolbar-container">
<slot name="side-toolbar" />
</div>
<Splitter
:key="splitterRefreshKey"
class="pointer-events-none flex-1 overflow-hidden border-none bg-transparent"
:state-key="
isSelectMode
? sidebarLocation === 'left'
? 'builder-splitter'
: 'builder-splitter-right'
: sidebarStateKey
"
state-storage="local"
@resizestart="onResizestart"
@resizeend="normalizeSavedSizes"
>
<!-- First panel: sidebar when left, properties when right -->
<SplitterPanel
v-if="firstPanelVisible"
:class="
sidebarLocation === 'left'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
"
:min-size="
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:style="firstPanelStyle"
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
:aria-label="
sidebarLocation === 'left' ? t('sideToolbar.sidebar') : undefined
"
>
<slot
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
name="side-bar-panel"
/>
<slot
v-else-if="sidebarLocation === 'right'"
name="right-side-panel"
/>
</SplitterPanel>
<!-- Main panel (always present) -->
<SplitterPanel :size="centerPanelDefaultSize" class="flex flex-col">
<slot name="topmenu" :sidebar-panel-visible />
<Splitter
class="splitter-overlay-bottom pointer-events-none mx-1 mb-1 flex-1 border-none bg-transparent"
layout="vertical"
:pt:gutter="
cn(
'rounded-t-lg',
!(bottomPanelVisible && !focusMode) && 'hidden'
)
"
state-key="bottom-panel-splitter"
state-storage="local"
@resizestart="onResizestart"
>
<SplitterPanel
class="graph-canvas-panel relative overflow-visible"
>
<slot name="graph-canvas-panel" />
</SplitterPanel>
<SplitterPanel
v-show="bottomPanelVisible && !focusMode"
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg focus-visible:outline-hidden"
>
<slot name="bottom-panel" />
</SplitterPanel>
</Splitter>
</SplitterPanel>
<!-- Last panel: properties when left, sidebar when right -->
<SplitterPanel
v-if="lastPanelVisible"
:class="
sidebarLocation === 'right'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
"
:min-size="
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:style="lastPanelStyle"
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
:aria-label="
sidebarLocation === 'right' ? t('sideToolbar.sidebar') : undefined
"
>
<slot v-if="sidebarLocation === 'left'" name="right-side-panel" />
<slot
v-else-if="sidebarLocation === 'right' && sidebarPanelVisible"
name="side-bar-panel"
/>
</SplitterPanel>
</Splitter>
</div>
</div>
<div
class="pointer-events-none flex flex-1 overflow-hidden"
:class="{
'flex-row': sidebarLocation === 'left',
'flex-row-reverse': sidebarLocation === 'right'
}"
v-if="agentPanelDocked"
class="pointer-events-auto relative h-full shrink-0 overflow-hidden"
:style="{ width: `${agentPanelWidth}px` }"
>
<div class="side-toolbar-container">
<slot name="side-toolbar" />
</div>
<Splitter
:key="splitterRefreshKey"
class="pointer-events-none flex-1 overflow-hidden border-none bg-transparent"
:state-key="
isSelectMode
? sidebarLocation === 'left'
? 'builder-splitter'
: 'builder-splitter-right'
: sidebarStateKey
"
state-storage="local"
@resizestart="onResizestart"
@resizeend="normalizeSavedSizes"
>
<!-- First panel: sidebar when left, properties when right -->
<SplitterPanel
v-if="firstPanelVisible"
:class="
sidebarLocation === 'left'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
"
:min-size="
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:style="firstPanelStyle"
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
:aria-label="
sidebarLocation === 'left' ? t('sideToolbar.sidebar') : undefined
"
>
<slot
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
name="side-bar-panel"
/>
<slot
v-else-if="sidebarLocation === 'right'"
name="right-side-panel"
/>
</SplitterPanel>
<!-- Main panel (always present) -->
<SplitterPanel :size="centerPanelDefaultSize" class="flex flex-col">
<slot name="topmenu" :sidebar-panel-visible />
<Splitter
class="splitter-overlay-bottom pointer-events-none mx-1 mb-1 flex-1 border-none bg-transparent"
layout="vertical"
:pt:gutter="
cn(
'rounded-t-lg',
!(bottomPanelVisible && !focusMode) && 'hidden'
)
"
state-key="bottom-panel-splitter"
state-storage="local"
@resizestart="onResizestart"
>
<SplitterPanel class="graph-canvas-panel relative overflow-visible">
<slot name="graph-canvas-panel" />
</SplitterPanel>
<SplitterPanel
v-show="bottomPanelVisible && !focusMode"
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg focus-visible:outline-hidden"
>
<slot name="bottom-panel" />
</SplitterPanel>
</Splitter>
</SplitterPanel>
<!-- Last panel: properties when left, sidebar when right -->
<SplitterPanel
v-if="lastPanelVisible"
:class="
sidebarLocation === 'right'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
"
:min-size="
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:style="lastPanelStyle"
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
:aria-label="
sidebarLocation === 'right' ? t('sideToolbar.sidebar') : undefined
"
>
<slot v-if="sidebarLocation === 'left'" name="right-side-panel" />
<slot
v-else-if="sidebarLocation === 'right' && sidebarPanelVisible"
name="side-bar-panel"
/>
</SplitterPanel>
</Splitter>
<div
class="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 +163,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 +185,33 @@ const { isSelectMode, isBuilderMode } = useAppMode()
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
const {
isOpen: agentPanelOpen,
enabled: agentPanelEnabled,
width: agentPanelWidth
} = storeToRefs(agentPanelStore)
const agentPanelDocked = computed(
() => agentPanelEnabled.value && agentPanelOpen.value
)
const isAgentResizing = ref(false)
let agentResizeStartX = 0
let agentResizeStartWidth = 0
function onAgentResizeStart(e: PointerEvent): void {
isAgentResizing.value = true
agentResizeStartX = e.clientX
agentResizeStartWidth = agentPanelStore.width
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
e.preventDefault()
}
useEventListener(document, 'pointermove', (e: PointerEvent) => {
if (!isAgentResizing.value) return
agentPanelStore.setWidth(
agentResizeStartWidth + (agentResizeStartX - e.clientX)
)
})
const showOffsideSplitter = computed(
() => rightSidePanelVisible.value || isSelectMode.value
)
@@ -304,4 +354,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,10 @@ import { forEachNode } from '@/utils/graphTraversalUtil'
import SelectionRectangle from './SelectionRectangle.vue'
import { useUrlActionLoaders } from '@/composables/useUrlActionLoaders'
const AgentPanelRoot = defineAsyncComponent(
() => import('@/workbench/extensions/agent/AgentPanelRoot.vue')
)
const { t } = useI18n()
const emit = defineEmits<{
ready: []

View File

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

View File

@@ -63,6 +63,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.',
@@ -629,6 +633,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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
AuthErrorMetadata,
AuthMetadata,
BeginCheckoutMetadata,
@@ -288,6 +295,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

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

View File

@@ -11,6 +11,13 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
AddCreditsClickMetadata,
AgentEntryButtonClickedMetadata,
AgentMessageFeedbackMetadata,
AgentMessageSentMetadata,
AgentNodeTaggedMetadata,
AgentPanelClosedMetadata,
AgentPanelOpenedMetadata,
AgentWorkflowAppliedMetadata,
AuthErrorMetadata,
AuthMetadata,
BeginCheckoutMetadata,
@@ -119,7 +126,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
{ immediate: true }
)
const apiKey = window.__CONFIG__?.posthog_project_token
const apiKey =
window.__CONFIG__?.posthog_project_token ??
import.meta.env.VITE_POSTHOG_PROJECT_TOKEN
if (apiKey) {
try {
void import('posthog-js')
@@ -135,8 +144,8 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
capture_pageleave: false,
persistence: 'localStorage+cookie',
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',
...serverConfig,
person_profiles: 'identified_only',
...serverConfig,
// cookie_domain omitted: posthog-js sets a first-party cross-subdomain cookie
// automatically when persistence includes 'cookie' (the default).
// Explicit override interacts badly with posthog-js#3578 where reset() fails
@@ -569,6 +578,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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
const STORAGE_KEY = 'Comfy.Agent.WorkflowTabBindings'
export const useAgentWorkflowTabBindingStore = defineStore(
'agentWorkflowTabBinding',
() => {
const tabByWorkflow = useLocalStorage<Record<string, string>>(
STORAGE_KEY,
{}
)
function bind(workflowId: string, tabPath: string): void {
const next = Object.fromEntries(
Object.entries(tabByWorkflow.value).filter(
([, boundPath]) => boundPath !== tabPath
)
)
next[workflowId] = tabPath
tabByWorkflow.value = next
}
function tabPathFor(workflowId: string): string | undefined {
return Object.hasOwn(tabByWorkflow.value, workflowId)
? tabByWorkflow.value[workflowId]
: undefined
}
function workflowIdFor(tabPath: string): string | undefined {
for (const [workflowId, boundPath] of Object.entries(
tabByWorkflow.value
)) {
if (boundPath === tabPath) return workflowId
}
return undefined
}
return { bind, tabPathFor, workflowIdFor }
}
)