Compare commits

...

44 Commits

Author SHA1 Message Date
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
imick-io
010389903d feat(website): sitewide announcement banner (#13481)
## Summary

Adds a **sitewide announcement banner** to the website, rendered above
the navbar on every page. It has a two-layer visibility model:

- **Build-time gate** — a pure, unit-tested `evaluateBannerVisibility()`
decides whether the banner mounts at all (active flag + optional date
window + locale/section targeting), driven by a typed config
(`src/config/banner.ts`). No CMS; copy resolves through i18n.
- **Client-side dismissal** — persisted in `localStorage`, keyed by a
**content hash** so editing the copy re-shows the banner (per-locale, so
an en edit doesn't re-show it for zh-CN).

Current content points the CTA at **Comfy MCP** (`/mcp`).

## Highlights

- **Full-width branded bar** above a now-`sticky` navbar; reuses the
design system (`Button`, gradient tokens, new reusable `IconButton`).
- **Flash-free** on load: an inline pre-hydration script hides an
already-dismissed banner before paint (no pop-in, no layout shift);
`close()` sets the same signal after the leave animation to stay
flash-free across ClientRouter navigations.
- **Open/close transition**: grid-rows height collapse + fade,
respecting `prefers-reduced-motion`.
- **i18n**: copy in `en` + `zh-CN`.

## Where to edit later

- **Copy**: `apps/website/src/i18n/translations.ts` →
`launches.banner.text` / `launches.banner.cta`
- **Link / on-off / dates / targeting**:
`apps/website/src/config/banner.ts` (`bannerConfig`)

## Notes

- On a static site the `startsAt`/`endsAt` window is evaluated at
**build time** (documented in `banner.ts`).
- Changing the copy or link changes the content hash, so
previously-dismissed visitors will see the banner again — by design.

## Test plan

- `pnpm test:unit` — evaluator + version-hash unit tests pass.
- `pnpm typecheck` + lint clean.
- On the preview: banner shows on `/`, `/launches`, and a `zh-CN` page;
CTA -> `/mcp`; dismiss animates and stays dismissed on reload (no
flash); reduced-motion disables the animation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 01:56:48 +00: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
Benjamin Lu
684b0b08b0 feat(telemetry): register client + deployment platform axes on PostHog (#13469)
## Problem

Filtering PostHog for the three product surfaces — **desktop local**,
**desktop cloud**, **web cloud** — currently requires a different hack
per pipe. For this repo's cloud build, the desktop-embedded frontend and
a plain browser are indistinguishable except by sniffing `Electron` in
`$raw_user_agent` (~124K desktop-cloud vs ~844K web-cloud execution
events/week get separated that way today).

## Change

Register the two standardized platform axes as PostHog super properties
at SDK init in `PostHogTelemetryProvider`:

- **`client`** — which surface emitted the event: `'desktop'` when the
desktop preload bridge (`window.__comfyDesktop2`) is present, else
`'web'`. The bridge is injected by Electron before any page script runs,
so detection is deterministic — unlike the existing utm-based
`source_app` attribution, which only covers sessions that *entered* via
a desktop link.
- **`deployment`** — which backend runs the work: pinned to `'cloud'`.

The register happens before the pre-init event queue flushes, so events
captured during the posthog-js dynamic-import window carry the axes too.

## Why pinning `deployment: 'cloud'` is safe (including
embedded-in-desktop)

The cloud bundle also runs **embedded in Comfy Desktop** — a cloud
install loads this same bundle in Electron, where `isCloud` and the host
bridge are both true. Two things happen there:

1. `main.ts` runs `initHostTelemetry()` *after* `initTelemetry()`, and
(when remote config `enable_telemetry` is on) it **replaces** the
registry with `HostTelemetrySink` — so tracked events
(`execution_start`, …) route through the desktop main process, bypassing
this provider. Those are tagged the same `client`/`deployment` values
main-side from the install's source category
([Comfy-Desktop#1229](https://github.com/Comfy-Org/Comfy-Desktop/pull/1229)).
2. posthog-js keeps capturing independently of the registry (pageviews,
web vitals, identify) — those are what these super properties cover in
the embedded case, and `deployment: 'cloud'` is correct for them because
the cloud bundle always talks to the cloud backend regardless of
embedding; the embedding itself is what `client: 'desktop'` captures.

The only way a cloud build runs against a non-cloud backend is a dev
setup, where `window.__CONFIG__.posthog_project_token` is absent
(injected by the cloud server) and the provider disables itself before
registering anything.

The locally-served frontend (desktop/localhost builds) never runs this
provider: `__DISTRIBUTION__` is a compile-time define, so the
`initTelemetry()` call folds away, with a runtime `IS_CLOUD_BUILD` guard
as backstop.

With both PRs, the three platforms become clean property filters:

| Surface | Filter |
|---|---|
| Desktop local | `client=desktop, deployment=local` |
| Desktop cloud | `client=desktop, deployment=cloud` |
| Web cloud | `client=web, deployment=cloud` |

## Testing

- `vitest run` on `PostHogTelemetryProvider.test.ts` — 45 passing,
including new coverage: web default, bridge-present → `client=desktop`,
and register-before-queue-flush ordering. The two desktop-entry tests
that asserted `register` is never called were narrowed to assert no
`source_app` register call.
- `pnpm typecheck` + eslint/oxlint on touched files — clean.

Ref
[MAR-51](https://linear.app/comfyorg/issue/MAR-51/foundation-desktop-sdk-dual-send-to-posthog-alongside-mixpanel)

---------

Co-authored-by: AustinMroz <austin@comfy.org>
2026-07-08 01:11:01 +00: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
Benjamin Lu
95b121bed9 fix(website): unbreak website-e2e on main (card count, CTA overflow, stale goldens) (#13496)
## Summary

Fix the website-e2e job, red on main since 2026-07-07 15:19 UTC, by
updating the cloud model-card count test and regenerating stale visual
screenshot goldens.

## Context

website-e2e runs Playwright tests against the marketing site
(`apps/website`). It broke on main in two independent ways, so every PR
since — however unrelated — has shown a red website-e2e check:

1. **Model-card count.** #13431 added a sixth model card (GPT Image 2)
to the /cloud "AI models" section but didn't update the test that pins
the card count at 5. CI: `locator resolved to 6 elements`.
2. **Stale screenshot goldens.** #13431 also swapped the ProductCard CTA
to the shared `Button` (`whitespace-nowrap`, so e.g. "SEE ENTERPRISE
FEATURES" renders on one line instead of wrapping) and committed
matching `home-product-cards-*` goldens. Minutes later #13445 — a branch
cut from main *before* #13431 — ran the screenshot-regen workflow, which
checks out the raw PR branch, not the branch merged with main. Its
regenerated lg/xl goldens therefore depict the **old** pre-#13431 card
(wrapped label; pixel-identical layout to the pre-#13431 golden), and
overwrote #13431's correct ones at merge. #13445's own website-e2e was
red at merge time for exactly this reason. The sm/md goldens (last
captured by #13431, without #13445's `ppformula-text-center`
0.19em→0.1em nudge) went stale by ~900 px the moment #13445 landed.

## Changes

- **What**: `cloud.spec.ts` — model-card count assertion and test title
5 → 6 (verified against the 6 entries in `AIModelsSection.vue`; passes
locally).
- **What**: `ProductCard.vue` — `h-auto whitespace-normal` on the CTA
`Button`. The shared Button's `whitespace-nowrap` made long labels ("SEE
ENTERPRISE FEATURES") overflow past the card edge at lg/xl (live on prod
since #13431); labels now wrap inside the card as they did before
#13431.
- **What**: regenerated `home-product-cards-*` goldens via the `Update
Website Screenshots` workflow, run on this branch, so they capture the
fixed rendering rather than enshrining the overflow.

## Review Focus

- At lg, "SEE DESKTOP/CLOUD FEATURES" now also wrap to two lines: #13431
raised the CTA font from `text-xs` to `md:text-sm`, so those labels no
longer fit one line in the 200px content box either (pre-fix they
silently consumed the card padding). If design prefers one-liners,
shrinking the CTA font is a follow-up.
- Process gaps this incident exposed (follow-ups, not in this PR): the
regen workflow captures against the raw branch instead of the
main-merged result, and website-e2e was red on #13445 at merge without
blocking it.

---

*Six cards where five once stood,*
*a button's text sat where it should —*
*but pixels pinned in amber lied,*
*so Linux looked, and rectified.*

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-07-07 17:59:21 -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
Wei Hai
baeb6df662 Gate signup submit on Turnstile being enabled, not enforced (#13463)
## Summary
- Shadow-mode Turnstile never blocked the signup Submit button on the
async Cloudflare challenge resolving, so most real submits raced ahead
of the widget and reached the backend with an empty token. This defeated
the point of shadow mode, which needs real tokens to measure the
false-positive rate before flipping to enforce.
- Submit is now blocked while the widget is enabled (shadow or enforce)
and has no token yet, in both modes.
- To keep a broken or slow Cloudflare load (network issue, ad-blocker,
CDN outage) from permanently blocking a legitimate signup,
`TurnstileWidget` now reports itself "unavailable" on a script-load
failure, a challenge error, or a 9s load timeout, and the form treats
that the same as shadow previously did: proceed without a token.

## Test plan
- [x] `vitest run` on `TurnstileWidget.test.ts` + `SignUpForm.test.ts`
(unit tests updated/added, all passing)
- [x] `pnpm typecheck` / eslint / oxlint / stylelint / oxfmt via
pre-commit hooks
- [ ] Manual click-through on staging to confirm no perceptible UX
regression during normal-latency challenge solves
- [ ] Confirm the 9s fallback timeout against real p95 Turnstile
challenge-solve latency

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-08 00:39:58 +00:00
CodeJuggernaut
e58b231664 fix: suppress the stray focus ring on the sidebar splitter panel (#13482)
Clicking empty space in a workspace panel focuses the whole PrimeVue
SplitterPanel (tabindex=-1 makes it click-focusable), and any following
non-chord keypress (e.g. Shift) trips the browser focus-visible
heuristic, painting the default blue ring around the entire panel.

The wrappers are not Tab-reachable (Tab lands on the controls inside,
never the panel box) and nothing focuses them programmatically, so the
ring conveys nothing. `focus-visible:outline-hidden` suppresses it while
keeping a forced-colors (High Contrast) indicator. Confirmed as noise
with design (Alex Tov).

Covers every click-focusable panel in
`LiteGraphCanvasSplitterOverlay.vue`: the sidebar panel (both
locations), the properties-side panel (both branches), and the bottom
panel. The center and graph-canvas panels are left untouched - they
inherit `pointer-events-none`, so a click can never focus them.

## Repro / QA

Ring trigger: click an empty, non-interactive spot inside the panel,
then press solo Shift. On main the browser paints a blue ring around the
whole panel; on this PR nothing appears. (Ctrl+Shift only triggers when
Shift lands first, hence the original "sometimes".)

| Panel | How to open | Fixed |
| --- | --- | --- |
| Sidebar (left, default) | Any rail icon, e.g. Assets | yes |
| Sidebar (right) | Settings > Sidebar Location > right | yes |
| Properties-side panel | Toggle properties panel / builder mode | yes |
| Bottom panel | Toggle Logs/Terminal | yes |
| Canvas / center | n/a | untouched - pointer-events-none, cannot be
click-focused |

- [ ] Each fixed panel: click empty spot, press Shift, no ring
- [ ] Same steps on main/prod show the ring (before-state)
- [ ] Tab still reaches controls inside each panel and their own focus
rings still show
- [ ] Media Assets shortcuts unchanged (Ctrl/Cmd+A, marquee modifiers) -
PR is CSS-only

- Surfaced during design review of #13323
2026-07-08 00:33:41 +00:00
Comfy Org PR Bot
545b48ee5b [chore] Update Ingest API types from cloud@421de6d (#12777)
## Automated Ingest API Type Update

This PR updates the Ingest API TypeScript types and Zod schemas from the
latest cloud OpenAPI specification.

- Cloud commit: 421de6d
- Generated using @hey-api/openapi-ts with Zod plugin

These types cover cloud-only endpoints (workspaces, billing, secrets,
assets, tasks, etc.).
Overlapping endpoints shared with the local ComfyUI Python backend are
excluded.

---------

Co-authored-by: mattmillerai <7741082+mattmillerai@users.noreply.github.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
2026-07-08 00:29:59 +00: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
Benjamin Lu
22ea53fb56 fix(ci): remove out-of-rootDir config files from package tsconfig includes (#13485)
## Summary

Type-aware oxlint rejects `packages/ingest-types/tsconfig.json` and
`packages/object-info-parser/tsconfig.json` as invalid (TS6059): their
`include` lists a root-level config file (`openapi-ts.config.ts` /
`vitest.config.ts`) that sits outside `rootDir: "src"`. This fails the
lint-and-format job with "Invalid tsconfig" on any PR that touches those
packages' src files — currently blocking every auto-generated
ingest-types sync (e.g. #12777).

## Changes

- **What**: Drop the out-of-`rootDir` config-file entries from the two
package tsconfig `include` arrays, matching the other workspace
packages. Repro: `pnpm exec oxlint --type-aware
packages/ingest-types/src/index.ts` fails before, passes after; the
config files themselves still lint clean.

## Review Focus

Neither package emits a build, so `rootDir`/`outDir` are
editor/lint-only; excluding the config files from the project has no
runtime effect (vitest/openapi-ts load their configs directly).
2026-07-07 17:03:12 -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
cloud-code-bot[bot]
9fe5dd51b8 ci: bump cursor-review to github-workflows@df507e6 (#13493)
Automatic SHA bump — `cursor-review.yml` was updated in
`Comfy-Org/github-workflows` at
[`df507e6`](df507e6bae).
_Opened by the `bump-cursor-review-callers` workflow._

Co-authored-by: cloud-code-bot[bot] <234529496+cloud-code-bot[bot]@users.noreply.github.com>
2026-07-07 23:06:59 +00: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
141 changed files with 11252 additions and 321 deletions

View File

@@ -29,7 +29,7 @@ jobs:
# SHA-pinned per zizmor `unpinned-uses: hash-pin`. Bump this SHA to pick up
# upstream changes; keep `workflows_ref` matching so prompts/scripts load
# from the same commit as the workflow definition.
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@047ca48febe3a6647608ed2e0c4331b491cb9d6a # github-workflows#9
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@df507e6bae179c567ad3849370f99dae588985dc # github-workflows main (df507e6)
with:
# Overriding diff_excludes replaces the reusable default wholesale, so
# this restates the generated/vendored defaults and adds this repo's heavy
@@ -48,7 +48,7 @@ jobs:
:!**/*-snapshots/**
:!src/workbench/extensions/manager/types/generatedManagerTypes.ts
# Load the prompts/scripts from the same ref as `uses:`.
workflows_ref: 047ca48febe3a6647608ed2e0c4331b491cb9d6a
workflows_ref: df507e6bae179c567ad3849370f99dae588985dc
secrets:
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
# Optional — enables start/complete Slack DMs to the triggerer.

View File

@@ -40,7 +40,7 @@ test.describe('Cloud page @smoke', () => {
}
})
test('AIModelsSection heading and 5 model cards are visible', async ({
test('AIModelsSection heading and 6 model cards are visible', async ({
page
}) => {
const heading = page.getByRole('heading', { name: /leading AI models/i })
@@ -49,7 +49,7 @@ test.describe('Cloud page @smoke', () => {
const section = heading.locator('xpath=ancestor::section')
const grid = section.locator('.grid')
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
await expect(modelCards).toHaveCount(5)
await expect(modelCards).toHaveCount(6)
})
test('AIModelsSection CTA links to workflows', async ({ page }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -33,7 +33,7 @@ const ctaButtons = [
<template>
<nav
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
class="sticky top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
aria-label="Main navigation"
>
<a

View File

@@ -30,7 +30,12 @@ const { title, description, cta, href, bg } = defineProps<{
<p class="text-sm text-white/70">
{{ description }}
</p>
<Button as="span" variant="default" size="sm" class="mt-4">
<Button
as="span"
variant="default"
size="sm"
class="mt-4 h-auto whitespace-normal"
>
{{ cta }}
</Button>
</div>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { IconButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
import { iconButtonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: IconButtonVariants['variant']
size?: IconButtonVariants['size']
class?: HTMLAttributes['class']
disabled?: boolean
}
const {
as = 'button',
asChild,
variant,
size,
class: className,
disabled
} = defineProps<Props>()
</script>
<template>
<Primitive
data-slot="icon-button"
:data-variant="variant"
:data-size="size"
:as
:as-child
:disabled
:class="cn(iconButtonVariants({ variant, size }), className)"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,28 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export const iconButtonVariants = cva(
[
'focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 inline-flex shrink-0 cursor-pointer items-center justify-center rounded-2xl transition-all duration-200 outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'
],
{
variants: {
variant: {
ghost:
'text-primary-warm-white hover:text-primary-comfy-yellow bg-transparent',
outline:
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border-primary-comfy-yellow border-2 bg-primary-comfy-ink hover:text-primary-comfy-ink'
},
size: {
sm: 'size-8',
default: 'size-10',
lg: 'size-14'
}
},
defaultVariants: {
variant: 'ghost',
size: 'default'
}
}
)
export type IconButtonVariants = VariantProps<typeof iconButtonVariants>

View File

@@ -0,0 +1,75 @@
import { onMounted, ref } from 'vue'
import { BANNER_DISMISS_ATTR, BANNER_STORAGE_KEY } from '../utils/banner'
type ClosedBanners = Record<string, boolean>
function readClosedBanners(): ClosedBanners {
try {
const raw = localStorage.getItem(BANNER_STORAGE_KEY)
return raw ? (JSON.parse(raw) as ClosedBanners) : {}
} catch {
return {}
}
}
function writeClosedBanners(value: ClosedBanners): void {
try {
localStorage.setItem(BANNER_STORAGE_KEY, JSON.stringify(value))
} catch {
// Storage unavailable (private mode / quota) — dismissal just won't persist.
}
}
/** The stable part of a version key (everything before `_v<hash>`). */
function versionPrefix(version: string): string {
const idx = version.lastIndexOf('_v')
return idx === -1 ? version : version.slice(0, idx)
}
/**
* Client-side dismissal persisted in localStorage, keyed by a content-aware
* `version`. The banner renders visible in the static HTML (so non-dismissers
* see no pop-in); an inline pre-hydration script hides an already-dismissed
* banner before paint, and this composable then removes it from the DOM on mount.
*/
export function useBannerDismissal(version: string) {
const isVisible = ref(true)
onMounted(() => {
const stored = readClosedBanners()
const prefix = versionPrefix(version)
// Prune stale versions of THIS banner+locale; keep other banners/locales
// and the current version.
const cleaned: ClosedBanners = Object.create(null) as ClosedBanners
let pruned = false
for (const key of Object.keys(stored)) {
if (versionPrefix(key) !== prefix || key === version) {
cleaned[key] = stored[key]
} else {
pruned = true
}
}
if (pruned) writeClosedBanners(cleaned)
isVisible.value = !cleaned[version]
})
function close(): void {
isVisible.value = false
const stored = readClosedBanners()
stored[version] = true
writeClosedBanners(stored)
}
// Call once the close transition has finished. Sets the pre-paint hide signal
// so the banner doesn't flash back in on a ClientRouter (view-transition)
// navigation — where the inline <head> script does not re-run but <html>
// persists. Deferred to after the animation so the leave transition can play.
function persistHidden(): void {
document.documentElement.setAttribute(BANNER_DISMISS_ATTR, '')
}
return { isVisible, close, persistHidden }
}

View File

@@ -0,0 +1,84 @@
import type { ButtonVariants } from '../components/ui/button'
import type { Locale, TranslationKey } from '../i18n/translations'
import { t } from '../i18n/translations'
import { resolveRel } from '../utils/cta'
// The banner "CMS": a single typed config resolved through i18n at build time.
// `isActive` is the master on/off switch (supersedes the old SHOW_ANNOUNCEMENT_BANNER).
// NOTE: on this static site, `startsAt`/`endsAt` are evaluated at BUILD time — the
// window gates on the last deploy, not the visitor's exact clock.
interface BannerLinkConfig {
readonly href: string
readonly titleKey: TranslationKey
readonly target?: boolean
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
}
export interface BannerConfig {
readonly id: string
readonly isActive: boolean
readonly startsAt?: string
readonly endsAt?: string
/** Empty/undefined = all locales. */
readonly targetLocales?: readonly Locale[]
/** v1 only supports 'sitewide'. */
readonly targetSections?: readonly string[]
readonly titleKey: TranslationKey
readonly descriptionKey?: TranslationKey
readonly link?: BannerLinkConfig
}
interface BannerLinkData {
readonly href: string
readonly title: string
readonly target?: '_blank'
readonly rel?: string
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
}
export interface BannerData {
readonly id: string
readonly title: string
readonly description?: string
readonly link?: BannerLinkData
}
export const bannerConfig: BannerConfig = {
id: 'announcement',
isActive: true,
targetSections: ['sitewide'],
titleKey: 'launches.banner.text',
link: {
href: '/mcp',
titleKey: 'launches.banner.cta',
buttonVariant: 'underlineLink'
}
}
/** Resolve a config's i18n keys into display strings for the given locale. */
export function getBannerData(
config: BannerConfig,
locale: Locale
): BannerData {
const { link } = config
const target = link?.target ? '_blank' : undefined
return {
id: config.id,
title: t(config.titleKey, locale),
description: config.descriptionKey
? t(config.descriptionKey, locale)
: undefined,
link: link
? {
href: link.href,
title: t(link.titleKey, locale),
target,
rel: resolveRel({ target: target ?? '_self' }),
buttonVariant: link.buttonVariant
}
: undefined
}
}

View File

@@ -3983,12 +3983,12 @@ const translations = {
// Launches page (/launches) — subscribe banner
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.banner.text': {
en: 'Join the live stream. Get answers in real time.',
'zh-CN': '加入直播,实时获得解答。'
en: 'Now turn your agent into a creative technologist.',
'zh-CN': '现在,让你的智能体成为创意技术专家。'
},
'launches.banner.cta': {
en: 'Join livestream',
'zh-CN': '加入直播'
en: 'Start Comfy MCP',
'zh-CN': '启动 Comfy MCP'
},
// Launches page (/launches) — closing CTA

View File

@@ -5,6 +5,14 @@ import '../styles/global.css'
import type { Locale } from '../i18n/translations'
import SiteFooter from '../components/common/SiteFooter.vue'
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
import AnnouncementBanner from '../templates/drops/AnnouncementBanner.vue'
import { bannerConfig, getBannerData } from '../config/banner'
import {
BANNER_DISMISS_ATTR,
BANNER_STORAGE_KEY,
createBannerVersion,
evaluateBannerVisibility
} from '../utils/banner'
import { escapeJsonLd } from '../utils/escapeJsonLd'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
@@ -34,6 +42,15 @@ const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
const githubStars = rawStars ? formatStarCount(rawStars) : ''
// Announcement banner — build-time visibility gate + content-hash version key.
const bannerData = getBannerData(bannerConfig, locale)
const bannerVisible = evaluateBannerVisibility(bannerConfig, {
currentLocale: locale,
currentSection: 'sitewide',
now: new Date(),
})
const bannerVersion = createBannerVersion(bannerData, locale)
const gtmId = 'GTM-NP9JM6K7'
const gtmEnabled = import.meta.env.PROD
@@ -124,6 +141,25 @@ const websiteJsonLd = {
<ClientRouter />
<slot name="head" />
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
{bannerVisible && (
<script
is:inline
define:vars={{
bannerVersion,
storageKey: BANNER_STORAGE_KEY,
dismissAttr: BANNER_DISMISS_ATTR
}}
>
try {
const dismissed = JSON.parse(localStorage.getItem(storageKey) || '{}')
if (dismissed[bannerVersion]) {
document.documentElement.setAttribute(dismissAttr, '')
}
} catch (e) {}
</script>
)}
</head>
<body class="bg-primary-comfy-ink text-white font-formula antialiased overflow-x-clip">
{gtmEnabled && (
@@ -137,8 +173,16 @@ const websiteJsonLd = {
</noscript>
)}
{bannerVisible && (
<AnnouncementBanner
data={bannerData}
version={bannerVersion}
locale={locale}
client:load
/>
)}
<HeaderMain locale={locale} github-stars={githubStars} client:load />
<main class="mt-20 lg:mt-32">
<main>
<slot />
</main>
<SiteFooter locale={locale} client:load />

View File

@@ -3,7 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import CtaSection from '../templates/drops/CtaSection.vue'
import DropsSection from '../templates/drops/DropsSection.vue'
import HeroSection from '../templates/drops/HeroSection.vue'
import SubscribeBanner from '../templates/drops/SubscribeBanner.vue'
import { t } from '../i18n/translations'
const locale = 'en' as const
@@ -13,7 +12,6 @@ const locale = 'en' as const
title={t('launches.page.title', locale)}
description={t('launches.page.description', locale)}
>
<SubscribeBanner locale={locale} client:load />
<HeroSection locale={locale} client:load />
<DropsSection locale={locale} />
<CtaSection locale={locale} />

View File

@@ -3,7 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import CtaSection from '../../templates/drops/CtaSection.vue'
import DropsSection from '../../templates/drops/DropsSection.vue'
import HeroSection from '../../templates/drops/HeroSection.vue'
import SubscribeBanner from '../../templates/drops/SubscribeBanner.vue'
import { t } from '../../i18n/translations'
const locale = 'zh-CN' as const
@@ -13,7 +12,6 @@ const locale = 'zh-CN' as const
title={t('launches.page.title', locale)}
description={t('launches.page.description', locale)}
>
<SubscribeBanner locale={locale} client:load />
<HeroSection locale={locale} client:load />
<DropsSection locale={locale} />
<CtaSection locale={locale} />

View File

@@ -70,6 +70,7 @@
--color-secondary-mauve: #4d3762;
--color-destructive: #f44336;
--color-primary-comfy-plum: #49378b;
--color-secondary-deep-plum: #2b2040;
--color-secondary-cool-gray: #3c3c3c;
--color-illustration-forest: #20464c;
--color-transparency-white-t4: rgb(255 255 255 / 0.04);
@@ -93,6 +94,14 @@
initial-value: 0deg;
}
/* Pre-hydration hide for a dismissed announcement banner (set by an inline
script in BaseLayout head) — prevents any flash before Vue hydrates.
The [data-banner-dismissed] literal is BANNER_DISMISS_ATTR in utils/banner.ts;
keep them in sync. */
[data-banner-dismissed] [data-slot='announcement-banner'] {
display: none;
}
@keyframes border-angle-spin {
to {
--border-angle: 360deg;

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import { ArrowRight, X } from '@lucide/vue'
import type { BannerData } from '../../config/banner'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import Button from '@/components/ui/button/Button.vue'
import IconButton from '@/components/ui/icon-button/IconButton.vue'
import { useBannerDismissal } from '../../composables/useBannerDismissal'
const {
data,
version,
locale = 'en'
} = defineProps<{
data: BannerData
version: string
locale?: Locale
}>()
const { isVisible, close, persistHidden } = useBannerDismissal(version)
</script>
<template>
<Transition name="banner-collapse" @after-leave="persistHidden">
<div v-if="isVisible" class="banner-collapse grid">
<div class="min-h-0 overflow-hidden">
<div
data-slot="announcement-banner"
class="after:bg-transparency-white-t4 relative flex items-center gap-x-6 px-6 py-4 after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px sm:px-3.5 sm:before:flex-1"
style="
background: linear-gradient(
90deg,
var(--color-primary-comfy-plum) 0%,
var(--color-secondary-deep-plum) 53.85%,
var(--color-secondary-mauve) 100%
);
"
>
<div class="flex flex-wrap items-center gap-x-8 gap-y-2">
<p
class="text-primary-warm-white ppformula-text-center text-sm md:text-base/6"
>
{{ data.title }}
<span v-if="data.description" class="text-primary-warm-white/80">
{{ data.description }}
</span>
</p>
<Button
v-if="data.link"
as="a"
:href="data.link.href"
:target="data.link.target"
:rel="data.link.rel"
:variant="data.link.buttonVariant ?? 'underlineLink'"
size="sm"
>
{{ data.link.title }}
<template #append>
<ArrowRight class="size-4" />
</template>
</Button>
</div>
<div class="flex flex-1 justify-end">
<IconButton
type="button"
:aria-label="t('nav.close', locale)"
@click="close"
>
<X class="size-5" aria-hidden="true" />
</IconButton>
</div>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
/* Collapse the banner's height (grid 1fr → 0fr) so page content below slides
up smoothly, with a fade. Enter is defined for symmetry; in practice only the
leave (dismiss) runs, since the banner renders present in the static HTML. */
.banner-collapse {
grid-template-rows: 1fr;
}
.banner-collapse-enter-active,
.banner-collapse-leave-active {
transition:
grid-template-rows 300ms ease,
opacity 250ms ease;
}
.banner-collapse-enter-from,
.banner-collapse-leave-to {
grid-template-rows: 0fr;
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.banner-collapse-enter-active,
.banner-collapse-leave-active {
transition: none;
}
}
</style>

View File

@@ -1,61 +0,0 @@
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { onMounted, ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import Button from '@/components/ui/button/Button.vue'
import { resolveRel } from '../../utils/cta'
import { livestream } from './livestream'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const signUpHref = `https://www.youtube.com/watch?v=${livestream.youtubeVideoId}`
const signUpRel = resolveRel({ target: '_blank' })
// Hide once the livestream window closes — both for visitors arriving after
// the event and for visitors whose tab is open when it ends.
const endMs = new Date(livestream.endDateTime).getTime()
const visible = ref(true)
// useTimeoutFn auto-clears on unmount. Arm it client-side only so SSR never
// schedules a long-lived server timer.
const { start } = useTimeoutFn(
() => {
visible.value = false
},
() => Math.max(0, endMs - Date.now()),
{ immediate: false }
)
onMounted(() => {
if (endMs - Date.now() <= 0) {
visible.value = false
} else {
start()
}
})
</script>
<template>
<div v-if="visible" class="px-4">
<div
class="bg-primary-comfy-plum max-w-8xl rounded-5xl text-primary-warm-white mx-auto flex w-full flex-col items-center justify-center gap-2 px-6 py-5 text-center text-sm sm:flex-row sm:gap-4"
>
<p class="ppformula-text-center">
{{ t('launches.banner.text', locale) }}
</p>
<Button
:href="signUpHref"
as="a"
variant="underlineLink"
size="sm"
target="_blank"
:rel="signUpRel"
>
{{ t('launches.banner.cta', locale) }}
</Button>
</div>
</div>
</template>

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import type { EvaluableBanner } from './banner'
import { createBannerVersion, evaluateBannerVisibility } from './banner'
const base: EvaluableBanner = {
isActive: true,
targetSections: ['sitewide']
}
const ctx = {
currentLocale: 'en',
currentSection: 'sitewide',
now: new Date('2026-07-06T00:00:00Z')
}
describe('evaluateBannerVisibility', () => {
it('shows an active, untargeted, sitewide banner', () => {
expect(evaluateBannerVisibility(base, ctx)).toBe(true)
})
it('hides when inactive', () => {
expect(evaluateBannerVisibility({ ...base, isActive: false }, ctx)).toBe(
false
)
})
it('hides before startsAt and shows within the window', () => {
expect(
evaluateBannerVisibility(
{ ...base, startsAt: '2026-07-10T00:00:00Z' },
ctx
)
).toBe(false)
expect(
evaluateBannerVisibility(
{ ...base, startsAt: '2026-07-01T00:00:00Z' },
ctx
)
).toBe(true)
})
it('hides after endsAt', () => {
expect(
evaluateBannerVisibility({ ...base, endsAt: '2026-07-01T00:00:00Z' }, ctx)
).toBe(false)
expect(
evaluateBannerVisibility({ ...base, endsAt: '2026-07-10T00:00:00Z' }, ctx)
).toBe(true)
})
it('treats an empty targetLocales as "all locales"', () => {
expect(evaluateBannerVisibility({ ...base, targetLocales: [] }, ctx)).toBe(
true
)
})
it('hides when targetLocales excludes the current locale', () => {
expect(
evaluateBannerVisibility({ ...base, targetLocales: ['zh-CN'] }, ctx)
).toBe(false)
expect(
evaluateBannerVisibility({ ...base, targetLocales: ['en', 'zh-CN'] }, ctx)
).toBe(true)
})
it('hides when targetSections does not include the current section', () => {
expect(
evaluateBannerVisibility({ ...base, targetSections: ['checkout'] }, ctx)
).toBe(false)
})
it('hides when targetSections is absent (nothing to match)', () => {
expect(evaluateBannerVisibility({ isActive: true }, ctx)).toBe(false)
})
})
describe('createBannerVersion', () => {
const content = {
id: 'announcement',
title: 'Join the live stream',
link: { href: 'https://x', title: 'Join' }
}
it('is deterministic for identical content', () => {
expect(createBannerVersion(content, 'en')).toBe(
createBannerVersion(content, 'en')
)
})
it('encodes the banner id and locale in the key', () => {
expect(createBannerVersion(content, 'en')).toMatch(
/^announcement_en_v-?\d+$/
)
})
it('changes when the copy changes', () => {
expect(createBannerVersion(content, 'en')).not.toBe(
createBannerVersion({ ...content, title: 'New copy' }, 'en')
)
})
it('differs per locale so one locale edit does not re-show another', () => {
expect(createBannerVersion(content, 'en')).not.toBe(
createBannerVersion(content, 'zh-CN')
)
})
})

View File

@@ -0,0 +1,87 @@
// Pure, framework-agnostic banner logic — no Vue/Astro/config imports so it stays
// trivially unit-testable. Locale/section are plain strings on purpose.
// Shared dismissal storage contract. The pre-hydration script in BaseLayout.astro,
// the useBannerDismissal composable, and the CSS selector in global.css must all
// agree on these literals — keep them here as the single source of truth.
export const BANNER_STORAGE_KEY = 'closedBanners'
export const BANNER_DISMISS_ATTR = 'data-banner-dismissed'
export interface BannerVisibilityContext {
currentLocale: string
currentSection: string
now: Date
}
export interface EvaluableBanner {
isActive: boolean
startsAt?: string
endsAt?: string
targetLocales?: readonly string[]
targetSections?: readonly string[]
}
/**
* Server/build-time visibility gate. Returns false on the FIRST failing check,
* in order: active flag → start window → end window → locale targeting →
* section targeting. An empty/absent `targetLocales` means "all locales".
*/
export function evaluateBannerVisibility(
banner: EvaluableBanner,
ctx: BannerVisibilityContext
): boolean {
if (!banner.isActive) return false
if (
banner.startsAt &&
ctx.now.getTime() < new Date(banner.startsAt).getTime()
)
return false
if (banner.endsAt && ctx.now.getTime() > new Date(banner.endsAt).getTime())
return false
const targetLocales = banner.targetLocales ?? []
if (targetLocales.length > 0 && !targetLocales.includes(ctx.currentLocale))
return false
const targetSections = banner.targetSections ?? []
if (!targetSections.includes(ctx.currentSection)) return false
return true
}
interface BannerLinkContent {
href: string
title: string
target?: string
rel?: string
buttonVariant?: string
}
export interface BannerVersionContent {
id: string
title: string
description?: string
link?: BannerLinkContent
}
/**
* Content-aware version key. Editing the copy changes the hash, so a previously
* dismissed banner re-appears. Keyed per-locale so a zh-CN edit doesn't re-show
* the banner for en visitors. Format: `${content.id}_${locale}_v${hash}`.
*/
export function createBannerVersion(
content: BannerVersionContent,
locale: string
): string {
const contentString = JSON.stringify({
locale,
title: content.title,
description: content.description,
link: content.link
})
let hash = 0
for (const char of contentString) {
hash = Math.imul(hash, 31) + char.charCodeAt(0)
}
return `${content.id}_${locale}_v${hash}`
}

View File

@@ -0,0 +1,200 @@
import type { WebSocketRoute } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { webSocketFixture } from '@e2e/fixtures/ws'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { AgentWsEvent } from '@/workbench/extensions/agent/schemas/agentApiSchema'
import {
DRAFT_PATCH,
MESSAGE_DELTA_EVENT,
MESSAGE_DONE_EVENT,
THINKING_EVENT,
TOOL_CALL_EVENT,
mockAgentBoot
} from '@e2e/tests/agent/agentPanelMocks'
/**
* In-App Agent panel e2e coverage (FE-1187).
*
* The panel is a CLOUD-ONLY workbench extension: `src/extensions/core/index.ts`
* imports `agentPanel.ts` only under `if (isCloud)`, and `isCloud` is the
* build-time `__DISTRIBUTION__ === 'cloud'` define. It therefore exists only in
* the cloud build, which the `cloud` Playwright project runs against
* (`frontend-dist-cloud` in CI; `playwright.config.ts` gives that project
* `grep: /@cloud/` while every other project greps it out). These specs are
* tagged `@cloud` so they run there and nowhere else. The `comfyPageFixture`
* itself already installs the cloud Firebase-auth mock for any `@cloud` test and
* boots the app, so tests only arrange agent mocks and act.
*
* The panel docks on the right and opens from the top-bar "Ask Comfy Agent"
* button (not a sidebar tab). It is gated FAIL-CLOSED by the PostHog flag
* `agent-in-app-experience`: `agentPanel.ts` reads `posthog.isFeatureEnabled`
* and only exposes that button once it is `true`. PostHog only initializes
* when `/api/features` supplies a `posthog_project_token`, so the mocks seed both
* the token and the flag (through PostHog `bootstrap.featureFlags`, which resolves
* the read synchronously with no `/decide` network call — see `agentPanelMocks.ts`).
* `agentFlagEnabled` (default true) is a fixture option a test flips off to model
* the fail-closed default.
*
* REST is mocked via `page.route` (POST messages -> 202, GET draft snapshot) and
* server->client WS frames are injected with the repo's `webSocketFixture`
* (`context.routeWebSocket(/\/ws/)`): `ws.send(JSON.stringify({type, data}))`
* pushes a frame onto the app's shared `/ws`, which is exactly where the panel's
* reconnecting event source listens.
*/
type AgentFixtures = {
agentFlagEnabled: boolean
postedMessages: string[]
}
// Install agent boot + REST mocks in the `page` fixture (before navigation) and
// expose the recorded POST bodies. `comfyPageFixture` consumes this same `page`
// and then boots the app, so the mocks are in place before mount.
const agentCloudFixture = comfyPageFixture.extend<AgentFixtures>({
agentFlagEnabled: [true, { option: true }],
postedMessages: async ({ page, agentFlagEnabled }, use) => {
const { postedMessages } = await mockAgentBoot(page, {
agentFlag: agentFlagEnabled
})
await use(postedMessages)
}
})
const test = mergeTests(agentCloudFixture, webSocketFixture)
// The panel opens from the top-bar action button, exposed with aria-label = tooltip.
const OPEN_AGENT_LABEL = enMessages.agent.askComfyAgent
// Push one agent WS event onto the app's /ws in the ComfyUI envelope shape.
function pushEvent(ws: WebSocketRoute, event: AgentWsEvent): void {
ws.send(JSON.stringify(event))
}
test.describe('In-App Agent panel', { tag: '@cloud' }, () => {
test.describe('flag off', () => {
test.use({ agentFlagEnabled: false })
test('does not expose the Ask Comfy Agent button', async ({
comfyPage,
postedMessages
}) => {
// Touch the fixture so its mocks (flag-off /api/features) are installed.
expect(postedMessages).toHaveLength(0)
// Fail-closed: with the PostHog flag false the top-bar button is never
// exposed, so the panel cannot be opened.
await expect(
comfyPage.page.getByRole('button', { name: OPEN_AGENT_LABEL })
).toHaveCount(0)
})
})
test('shows the greeting, inserts a suggested prompt, and completes a chat turn', async ({
comfyPage,
postedMessages,
getWebSocket
}) => {
const page = comfyPage.page
// Flag on: the top-bar button is exposed. Open the panel.
const openButton = page.getByRole('button', { name: OPEN_AGENT_LABEL })
await expect(openButton).toBeVisible()
await openButton.click()
const panel = page.locator('#agent-panel-root')
await expect(panel).toBeVisible()
// Empty state: greeting + question and the five suggested-prompt chips. The greeting
// personalizes to the account's first name, so match the stable prefix, not a fixed name.
await expect(panel.getByText(/^Hello/)).toBeVisible()
await expect(panel.getByText('What do you want to make?')).toBeVisible()
// Sourced from the bundled locale so the spec cannot drift from the rendered prompts.
const firstPrompt = enMessages.agent.suggestedPrompts[0]
const promptChip = panel.getByRole('button', { name: firstPrompt })
await expect(promptChip).toBeVisible()
const composer = panel.getByPlaceholder(enMessages.agent.placeholder)
const sendButton = panel.getByRole('button', { name: 'Send' })
// Clicking a suggested prompt INSERTS it into the composer, it does not send.
await expect(composer).toHaveValue('')
await promptChip.click()
await expect(composer).toHaveValue(firstPrompt)
expect(
postedMessages,
'inserting a prompt must not POST a message'
).toHaveLength(0)
// Submitting sends the composed message: POST /api/agent/threads/new/messages.
const ws = await getWebSocket()
await sendButton.click()
await expect.poll(() => postedMessages.length).toBeGreaterThanOrEqual(1)
expect(postedMessages[0]).toContain(firstPrompt)
// The composer clears once the send is accepted.
await expect(composer).toHaveValue('')
// agent_thinking -> the "Thinking..." status is visible.
pushEvent(ws, THINKING_EVENT)
await expect(panel.getByText('Thinking...')).toBeVisible()
// agent_tool_call (ok) -> a tool-call group card appears ("Ran 1 tool call").
pushEvent(ws, TOOL_CALL_EVENT)
await expect(panel.getByText('Ran 1 tool call')).toBeVisible()
// agent_message_delta with markdown -> rendered agent message; the **bold**
// run renders as <strong>.
pushEvent(ws, MESSAGE_DELTA_EVENT)
await expect(
panel.locator('strong', { hasText: 'fully ready' })
).toBeVisible()
// Text arriving clears the thinking chip.
await expect(panel.getByText('Thinking...')).toBeHidden()
// agent_message_done -> the turn settles: the primary button returns to Send
// (it is Stop while streaming).
pushEvent(ws, MESSAGE_DONE_EVENT)
await expect(panel.getByRole('button', { name: 'Send' })).toBeVisible()
await expect(panel.getByRole('button', { name: 'Stop' })).toHaveCount(0)
})
test('applies a draft_patch graph to the canvas', async ({
comfyPage,
postedMessages,
getWebSocket
}) => {
const page = comfyPage.page
const panel = page.locator('#agent-panel-root')
const openButton = page.getByRole('button', { name: OPEN_AGENT_LABEL })
await expect(openButton).toBeVisible()
await openButton.click()
await expect(panel).toBeVisible()
// The draft store only adopts a draft_patch whose workflow_id matches the
// server's workflow. The server returns that id in the message ack, and the
// panel binds the draft store to it. So send once to establish the bind before
// pushing the patch. A draft_patch is otherwise NEVER turn-filtered.
await panel.getByPlaceholder(enMessages.agent.placeholder).fill('Build it')
await panel.getByRole('button', { name: 'Send' }).click()
await expect.poll(() => postedMessages.length).toBeGreaterThanOrEqual(1)
const ws = await getWebSocket()
pushEvent(ws, { type: 'draft_patch', data: DRAFT_PATCH })
// The two-node draft graph is validated and handed to app.loadGraphData, so
// the canvas graph must reflect it.
await expect
.poll(() => page.evaluate(() => window.app!.graph!.nodes.length))
.toBe(2)
const nodeTypes = await page.evaluate(() =>
window.app!.graph!.nodes.map((n) => n.type)
)
expect(nodeTypes).toEqual(
expect.arrayContaining(['CheckpointLoaderSimple', 'SaveImage'])
)
})
})

View File

@@ -0,0 +1,272 @@
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'
/**
* Typed mocks for the In-App Agent panel e2e spec. Every REST body and WS frame
* is annotated with the shared agent contract types
* (`@/workbench/extensions/agent/schemas/agentApiSchema`) and the workflow schema,
* so a wire-shape drift is a compile error here, not a flaky runtime failure. The
* payload values are copied from the captured fixtures under
* `src/workbench/extensions/agent/schemas/__fixtures__/agent/`.
*/
// The turn everything in scenario 2 & 3 is addressed by. The panel adopts this
// server-minted message_id (from the POST 202 ack) as the TurnId that keys the
// assistant message the WS frames stream into.
const THREAD_ID = 'd4c016c4-3b8c-44cf-97de-1ae27e43e718'
const TURN_ID = '3818ba00-d772-4a3f-98c1-9312725b577d'
const WORKFLOW_ID = 'a81718a4-02ae-41e6-ae85-c33b7bb880f6'
// The 202 body the panel's postMessage('new') expects: {thread_id, message_id}
// plus the captured additive workflow_id (the schema is passthrough).
const TURN_ACCEPTED: AgentTurnAccepted = {
message_id: TURN_ID,
thread_id: THREAD_ID,
workflow_id: WORKFLOW_ID
}
/**
* A minimal schema-valid v0.4 workflow. `validateComfyWorkflow` first parses a
* top-level `version: number` (the captured draft graphs omit it, so they would
* be rejected before ever reaching the canvas); this shape carries `version`,
* `last_node_id`, `last_link_id`, `nodes` and `links` so it survives validation
* and `app.loadGraphData` actually applies it. Two nodes so the assertion on the
* canvas node count is unambiguous.
*/
const DRAFT_GRAPH: ComfyWorkflowJSON = {
version: 0.4,
last_node_id: 2,
last_link_id: 0,
nodes: [
{
id: 1,
type: 'CheckpointLoaderSimple',
pos: [100, 300],
size: [210, 100],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [
{ name: 'MODEL', type: 'MODEL', links: [] },
{ name: 'CLIP', type: 'CLIP', links: [] },
{ name: 'VAE', type: 'VAE', links: [] }
],
properties: {},
widgets_values: ['sd_xl_base_1.0.safetensors']
},
{
id: 2,
type: 'SaveImage',
pos: [1400, 300],
size: [210, 100],
flags: {},
order: 1,
mode: 0,
inputs: [{ name: 'images', type: 'IMAGE', link: null }],
outputs: [],
properties: {},
widgets_values: ['ComfyUI']
}
],
links: []
}
// The GET /api/agent/draft snapshot body: {content, version}. content rides the
// wire as an opaque object; the panel re-validates it before loading.
const DRAFT_SNAPSHOT: AgentDraftSnapshot = {
content: DRAFT_GRAPH as unknown as Record<string, unknown>,
version: 24
}
// A draft_patch WS event whose content is the minimal valid graph above. The panel
// adopts monotonically, so the version must exceed any snapshot already adopted.
export const DRAFT_PATCH: DraftPatchData = {
base_version: 24,
version: 25,
content: DRAFT_GRAPH as unknown as Record<string, unknown>,
workflow_id: WORKFLOW_ID,
message_id: TURN_ID,
thread_id: THREAD_ID
}
// The chat-turn WS frames for scenario 2, in arrival order. Each rides the ComfyUI
// envelope {type, data}; values copied from ws-turn-ask-run.jsonl but retargeted to
// TURN_ID so they land on the turn the POST ack opened.
export const THINKING_EVENT: AgentWsEvent = {
type: 'agent_thinking',
data: {
delta: "I'll set the positive prompt to your red fox scene.",
message_id: TURN_ID,
thread_id: THREAD_ID
}
}
export const TOOL_CALL_EVENT: AgentWsEvent = {
type: 'agent_tool_call',
data: {
tool_name: 'set_widget',
status: 'ok',
args: ['workflow', 'set-widget', 'workflow.json'],
message_id: TURN_ID,
thread_id: THREAD_ID
}
}
// Markdown delta: the **bold** run renders as <strong> through the sanitizing
// markdown pipeline, which the spec asserts on.
const MESSAGE_DELTA_TEXT =
'The graph is **fully ready** to go — prompt set to the red fox in the snow.'
export const MESSAGE_DELTA_EVENT: AgentWsEvent = {
type: 'agent_message_delta',
data: {
delta: MESSAGE_DELTA_TEXT,
message_id: TURN_ID,
thread_id: THREAD_ID
}
}
export const MESSAGE_DONE_EVENT: AgentWsEvent = {
type: 'agent_message_done',
data: {
message_id: TURN_ID,
thread_id: THREAD_ID,
usage: {
input_tokens: 4493,
output_tokens: 425,
total_tokens: 12393,
cache_read_input_tokens: 35596,
cache_creation_input_tokens: 0
}
}
}
/**
* `/api/features` payload that boots the cloud telemetry provider with PostHog AND
* turns the agent gate on. PostHog only initializes when a `posthog_project_token`
* is present, and the panel's fail-closed gate reads `posthog.isFeatureEnabled`;
* seeding the flag through PostHog's `bootstrap.featureFlags` makes that read
* resolve `true` synchronously at init with no `/decide` round-trip, so the gate is
* deterministic. Omit `agentFlag` (or pass false) to model the fail-closed default.
*/
function agentFeatures(agentFlag: boolean): RemoteConfig {
return {
team_workspaces_enabled: true,
posthog_project_token: 'phc_e2e_agent_panel',
posthog_config: {
// Never let posthog-js reach its flags endpoint in the test env; bootstrap
// alone supplies the flag value the gate reads.
advanced_disable_flags: true,
bootstrap: {
featureFlags: { 'agent-in-app-experience': agentFlag }
}
}
}
}
const jsonRoute = (body: unknown) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
/**
* Boots the cloud app against fully mocked boot + agent REST endpoints. Mirrors
* `CloudWorkspaceMockHelper.mockBoot`, adding the agent's own REST surface
* (`postMessage`, `getDraft`). Returns the recorded POST bodies so the spec can
* assert the composed message reached the wire. Auth (Firebase) is mocked by the
* caller via `CloudAuthHelper` before navigation.
*/
export async function mockAgentBoot(
page: Page,
{ agentFlag }: { agentFlag: boolean }
): Promise<{ postedMessages: string[] }> {
const postedMessages: string[] = []
await page.route('**/api/features', (r) =>
r.fulfill(jsonRoute(agentFeatures(agentFlag)))
)
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
// TutorialCompleted marks the user as returning so the new-user Templates dialog
// never auto-opens over the sidebar rail; errors tab off suppresses a 401 toast.
await page.route('**/api/settings', (r) =>
r.fulfill(
jsonRoute({
'Comfy.TutorialCompleted': true,
'Comfy.RightSidePanel.ShowErrorsTab': false
})
)
)
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/api/auth/token', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/workspaces', (r) =>
r.fulfill(
jsonRoute({
workspaces: [
{
id: 'ws-personal',
name: 'Personal',
type: 'personal',
role: 'owner'
}
]
})
)
)
// POST /api/agent/threads/new/messages -> 202 {thread_id, message_id, workflow_id}.
// The server owns the workflow and returns its id in this ack; the panel binds the
// draft store to it and then only adopts draft_patch events whose workflow_id
// matches. Record the body so the spec can assert the composed text was sent, not
// just that a request fired. GET on the same path (history hydration) is empty.
await page.route('**/api/agent/threads/*/messages', (route: Route) => {
const request = route.request()
if (request.method() === 'POST') {
postedMessages.push(request.postData() ?? '')
return route.fulfill({
status: 202,
contentType: 'application/json',
body: JSON.stringify(TURN_ACCEPTED)
})
}
return route.fulfill(jsonRoute([]))
})
await page.route('**/api/agent/draft**', (r) =>
r.fulfill(jsonRoute(DRAFT_SNAPSHOT))
)
return { postedMessages }
}

View File

@@ -120,6 +120,7 @@
"primevue": "catalog:",
"reka-ui": "catalog:",
"semver": "^7.7.2",
"shiki": "catalog:",
"three": "catalog:",
"tiptap-markdown": "^0.8.10",
"typegpu": "catalog:",

View File

@@ -12,6 +12,11 @@ export type {
AddAssetTagsErrors,
AddAssetTagsResponse,
AddAssetTagsResponses,
AdminDeleteHubWorkflowData,
AdminDeleteHubWorkflowError,
AdminDeleteHubWorkflowErrors,
AdminDeleteHubWorkflowResponse,
AdminDeleteHubWorkflowResponses,
Asset,
AssetCreated,
AssetCreatedWritable,
@@ -42,6 +47,11 @@ export type {
CancelJobErrors,
CancelJobResponse,
CancelJobResponses,
CancelJobsData,
CancelJobsError,
CancelJobsErrors,
CancelJobsResponse,
CancelJobsResponses,
CancelSubscriptionData,
CancelSubscriptionError,
CancelSubscriptionErrors,
@@ -84,6 +94,11 @@ export type {
CreateDeletionRequestErrors,
CreateDeletionRequestResponse,
CreateDeletionRequestResponses,
CreateDesktopLoginCodeData,
CreateDesktopLoginCodeError,
CreateDesktopLoginCodeErrors,
CreateDesktopLoginCodeResponse,
CreateDesktopLoginCodeResponses,
CreateHubAssetUploadUrlData,
CreateHubAssetUploadUrlError,
CreateHubAssetUploadUrlErrors,
@@ -186,12 +201,31 @@ export type {
DeleteWorkspaceResponses,
DeletionRequest,
DeletionStatus,
DesktopLoginCodeCreateRequest,
DesktopLoginCodeCreateResponse,
DesktopLoginCodeExchangeRequest,
DesktopLoginCodeExchangeResponse,
DesktopLoginCodeRedeemRequest,
DesktopLoginCodeRedeemResponse,
DownloadExportData,
DownloadExportError,
DownloadExportErrors,
DownloadExportResponse,
DownloadExportResponses,
EnsureWorkspaceBillingLegacySnapshot,
EnsureWorkspaceBillingProvisionedData,
EnsureWorkspaceBillingProvisionedError,
EnsureWorkspaceBillingProvisionedErrors,
EnsureWorkspaceBillingProvisionedRequest,
EnsureWorkspaceBillingProvisionedResponse,
EnsureWorkspaceBillingProvisionedResponse2,
EnsureWorkspaceBillingProvisionedResponses,
ErrorResponse,
ExchangeDesktopLoginCodeData,
ExchangeDesktopLoginCodeError,
ExchangeDesktopLoginCodeErrors,
ExchangeDesktopLoginCodeResponse,
ExchangeDesktopLoginCodeResponses,
ExchangeTokenData,
ExchangeTokenError,
ExchangeTokenErrors,
@@ -230,6 +264,11 @@ export type {
GetAssetByIdErrors,
GetAssetByIdResponse,
GetAssetByIdResponses,
GetAssetContentData,
GetAssetContentError,
GetAssetContentErrors,
GetAssetContentResponse,
GetAssetContentResponses,
GetAssetSeedStatusData,
GetAssetSeedStatusResponse,
GetAssetSeedStatusResponses,
@@ -303,6 +342,11 @@ export type {
GetHistoryData,
GetHistoryError,
GetHistoryErrors,
GetHistoryEventsData,
GetHistoryEventsError,
GetHistoryEventsErrors,
GetHistoryEventsResponse,
GetHistoryEventsResponses,
GetHistoryForPromptData,
GetHistoryForPromptError,
GetHistoryForPromptErrors,
@@ -345,8 +389,6 @@ export type {
GetJwksData,
GetJwksResponse,
GetJwksResponses,
GetLegacyAssetContentData,
GetLegacyAssetContentErrors,
GetLegacyHistoryByIdData,
GetLegacyHistoryByIdErrors,
GetLegacyHistoryData,
@@ -556,6 +598,7 @@ export type {
HistoryDetailEntry,
HistoryDetailResponse,
HistoryEntry,
HistoryEventRequest,
HistoryManageRequest,
HistoryResponse,
HubAssetUploadUrlRequest,
@@ -589,6 +632,8 @@ export type {
JobCancelResponse,
JobDetailResponse,
JobEntry,
JobsCancelRequest,
JobsCancelResponse,
JobsListResponse,
JobStatusResponse,
JwkKey,
@@ -627,7 +672,19 @@ export type {
ListJobsErrors,
ListJobsResponse,
ListJobsResponses,
ListLinkedFirebaseUidsData,
ListLinkedFirebaseUidsError,
ListLinkedFirebaseUidsErrors,
ListLinkedFirebaseUidsRequest,
ListLinkedFirebaseUidsResponse,
ListLinkedFirebaseUidsResponse2,
ListLinkedFirebaseUidsResponses,
ListMembersResponse,
ListSecretProvidersData,
ListSecretProvidersError,
ListSecretProvidersErrors,
ListSecretProvidersResponse,
ListSecretProvidersResponses,
ListSecretsData,
ListSecretsError,
ListSecretsErrors,
@@ -775,6 +832,17 @@ export type {
QueueInfo,
QueueManageRequest,
QueueManageResponse,
RedeemDesktopLoginCodeData,
RedeemDesktopLoginCodeError,
RedeemDesktopLoginCodeErrors,
RedeemDesktopLoginCodeResponse,
RedeemDesktopLoginCodeResponses,
ReleaseDeletionHoldData,
ReleaseDeletionHoldError,
ReleaseDeletionHoldErrors,
ReleaseDeletionHoldResponse,
ReleaseDeletionHoldResponses,
ReleaseHoldResponse,
RemoveAssetTagsData,
RemoveAssetTagsError,
RemoveAssetTagsErrors,
@@ -785,6 +853,11 @@ export type {
RemoveWorkspaceMemberErrors,
RemoveWorkspaceMemberResponse,
RemoveWorkspaceMemberResponses,
ReportHistoryEventData,
ReportHistoryEventError,
ReportHistoryEventErrors,
ReportHistoryEventResponse,
ReportHistoryEventResponses,
ReportPartnerUsageData,
ReportPartnerUsageError,
ReportPartnerUsageErrors,
@@ -808,6 +881,8 @@ export type {
RevokeWorkspaceInviteResponse,
RevokeWorkspaceInviteResponses,
SecretListResponse,
SecretProvider,
SecretProvidersResponse,
SecretResponse,
SeedAssetsData,
SeedAssetsResponse,
@@ -819,6 +894,8 @@ export type {
SetReviewStatusResponse,
SetReviewStatusResponse2,
SetReviewStatusResponses,
ShortLinkRedirectData,
ShortLinkRedirectErrors,
SubmitFeedbackData,
SubmitFeedbackError,
SubmitFeedbackErrors,
@@ -848,6 +925,10 @@ export type {
TaskEntry,
TaskResponse,
TasksListResponse,
TeamCreditStop,
TeamCreditStopPrice,
TeamCreditStops,
TeamCreditStopSummary,
UpdateAssetData,
UpdateAssetError,
UpdateAssetErrors,
@@ -865,6 +946,7 @@ export type {
UpdateHubWorkflowRequest,
UpdateHubWorkflowResponse,
UpdateHubWorkflowResponses,
UpdateMemberRoleRequest,
UpdateMultipleSettingsData,
UpdateMultipleSettingsError,
UpdateMultipleSettingsErrors,
@@ -895,6 +977,11 @@ export type {
UpdateWorkspaceData,
UpdateWorkspaceError,
UpdateWorkspaceErrors,
UpdateWorkspaceMemberRoleData,
UpdateWorkspaceMemberRoleError,
UpdateWorkspaceMemberRoleErrors,
UpdateWorkspaceMemberRoleResponse,
UpdateWorkspaceMemberRoleResponses,
UpdateWorkspaceRequest,
UpdateWorkspaceResponse,
UpdateWorkspaceResponses,

File diff suppressed because it is too large Load Diff

View File

@@ -465,6 +465,20 @@ export const zCreateWorkflowRequest = z.object({
forked_from_workflow_version_id: z.string().optional()
})
/**
* Request body for forwarding a comfy-api audit/history event. Identify the target workspace by either user_id (cloud resolves the user's personal workspace via the converged identity, BE-1047) or an explicit workspace_id. At least one must be provided; workspace_id wins when both are set.
*/
export const zHistoryEventRequest = z.object({
user_id: z.string().optional(),
workspace_id: z.string().optional(),
event_type: z.string().min(1),
event_id: z.string().min(1),
params: z.record(z.unknown()).optional(),
auth_method: z.enum(['api_key', 'bearer_token']).optional(),
customer_ref: z.string().optional(),
timestamp: z.string().datetime().optional()
})
/**
* Response after recording partner usage data.
*/
@@ -540,11 +554,11 @@ export const zPaymentPortalRequest = z.object({
})
/**
* Response after successfully resubscribing to a billing plan.
* Response after accepting a resubscribe request.
*/
export const zResubscribeResponse = z.object({
billing_op_id: z.string(),
status: z.enum(['active']),
status: z.enum(['active', 'pending']),
message: z.string().optional()
})
@@ -585,6 +599,8 @@ export const zSubscribeResponse = z.object({
*/
export const zSubscribeRequest = z.object({
plan_slug: z.string(),
team_credit_stop_id: z.string().optional(),
billing_cycle: z.enum(['monthly', 'yearly']).optional(),
idempotency_key: z.string().optional(),
return_url: z.string().optional(),
cancel_url: z.string().optional()
@@ -626,7 +642,8 @@ export const zSubscriptionTier = z.enum([
'STANDARD',
'CREATOR',
'PRO',
'FOUNDERS_EDITION'
'FOUNDERS_EDITION',
'TEAM'
])
/**
@@ -714,6 +731,57 @@ export const zPreviewSubscribeRequest = z.object({
plan_slug: z.string()
})
/**
* Pre/post-discount price for a team credit stop, in cents.
*/
export const zTeamCreditStopPrice = z.object({
list_price_cents: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
price_cents: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
})
/**
* A selectable preset on the team pricing slider. Echoed on subscribe via
* team_credit_stop_id; the backend owns the resolved amounts. credits is a
* RAW monthly credit count (not cents). Save% is derived by the FE as
* (list_price_cents - price_cents) / list_price_cents.
*
*/
export const zTeamCreditStop = z.object({
id: z.string(),
credits: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
monthly: zTeamCreditStopPrice,
yearly: zTeamCreditStopPrice
})
/**
* Credit-stop ladder for the pricing slider (BE-1254). Returned by GET /api/billing/plans for every workspace regardless of the caller's token or workspace type (the personal/team distinction was removed); omitted only when the catalog defines no stops.
*/
export const zTeamCreditStops = z.object({
default_stop_index: z.number().int(),
stops: z.array(zTeamCreditStop)
})
/**
* Reason why a plan is unavailable
*/
@@ -773,7 +841,50 @@ export const zPlan = z.object({
*/
export const zBillingPlansResponse = z.object({
current_plan_slug: z.string().optional(),
plans: z.array(zPlan)
plans: z.array(zPlan),
team_credit_stops: zTeamCreditStops.optional()
})
/**
* The team credit stop a workspace is currently subscribed to: the
* per-workspace slider choice recorded at subscribe time
* (workspace_subscriptions.team_credit_stop_id). Amounts are owned by the
* catalog, not the subscription row. Returned on GET /api/billing/status
* for per-credit Team plans (BE-1254).
*
*/
export const zTeamCreditStopSummary = z.object({
id: z.string(),
credits_monthly: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
stop_usd: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
})
/**
* A provider the user may configure a secret for. The shape is deliberately minimal (identifier only) and reserved for future per-provider fields such as sub-keys.
*/
export const zSecretProvider = z.object({
id: z.string()
})
/**
* The providers available to the authenticated user in the current workspace.
*/
export const zSecretProvidersResponse = z.object({
data: z.array(zSecretProvider)
})
/**
@@ -813,7 +924,7 @@ export const zCreateSecretRequest = z.object({
})
/**
* A single billing event such as a charge, credit, or adjustment.
* A single history event. The cloud history-events store is the single source of truth for both billing events (charges, credits, adjustments) and user-facing usage events.
*/
export const zBillingEvent = z.object({
event_type: z.string(),
@@ -868,7 +979,8 @@ export const zBillingStatusResponse = z.object({
billing_status: zBillingStatus.optional(),
has_funds: z.boolean(),
cancel_at: z.string().datetime().optional(),
renewal_date: z.string().datetime().optional()
renewal_date: z.string().datetime().optional(),
team_credit_stop: zTeamCreditStopSummary.nullable()
})
/**
@@ -930,6 +1042,7 @@ export const zOAuthConsentChallenge = z.object({
csrf_token: z.string(),
client_display_name: z.string(),
resource_display_name: z.string(),
redirect_uri: z.string().url(),
scopes: z.array(z.string()),
workspaces: z.array(zOAuthConsentChallengeWorkspace)
})
@@ -1056,6 +1169,66 @@ export const zSyncApiKeyRequest = z.object({
customer_id: z.string().min(1)
})
/**
* The personal workspace's provisioned billing identity.
*/
export const zEnsureWorkspaceBillingProvisionedResponse = z.object({
workspace_id: z.string(),
stripe_customer_id: z.string(),
metronome_customer_id: z.string(),
metronome_contract_id: z.string()
})
/**
* The caller's already-resolved legacy (comfy-api) customer identity. When
* present and carrying provider IDs, provisioning ATTACHES this identity to
* the personal workspace (sharing the existing balance and subscription)
* instead of minting a net-new empty customer. Omit (or send with no
* provider IDs) for a free user with nothing to attach — provisioning then
* creates net-new. This closes the create-new-before-attach gap: a caller
* that already knows the legacy identity hands it over so the very first
* provisioning is an attach.
*
*/
export const zEnsureWorkspaceBillingLegacySnapshot = z.object({
stripe_customer_id: z.string().optional(),
metronome_customer_id: z.string().optional(),
metronome_contract_id: z.string().optional(),
has_funds: z.boolean().optional(),
subscription_tier: z.string().optional(),
legacy_stripe_subscription_id: z.string().optional(),
legacy_comfy_user_id: z.string().optional()
})
/**
* Request body for ensuring a user's personal workspace carries a fully
* provisioned billing identity. Sent by comfy-api's CreateCustomer (BE-1047)
* with the already canonical-resolved user identity.
*
*/
export const zEnsureWorkspaceBillingProvisionedRequest = z.object({
user_id: z.string().min(1),
email: z.string().email().min(1),
snapshot: zEnsureWorkspaceBillingLegacySnapshot.optional()
})
/**
* Firebase UIDs linked to the canonical comfy_user_id. Empty list when
* no mappings exist (not an error — callers can treat empty as "unknown
* canonical").
*
*/
export const zListLinkedFirebaseUidsResponse = z.object({
firebase_uids: z.array(z.string())
})
/**
* Request body for reverse-looking-up Firebase UIDs linked to a canonical comfy_user_id.
*/
export const zListLinkedFirebaseUidsRequest = z.object({
comfy_user_id: z.string().min(1)
})
/**
* Response confirming the validity and scope of a workspace API key.
*/
@@ -1172,7 +1345,8 @@ export const zMember = z.object({
name: z.string(),
email: z.string().email(),
role: z.enum(['owner', 'member']),
joined_at: z.string().datetime()
joined_at: z.string().datetime(),
is_original_owner: z.boolean()
})
/**
@@ -1183,6 +1357,13 @@ export const zListMembersResponse = z.object({
pagination: zPaginationInfo
})
/**
* Request body for changing a workspace member's role.
*/
export const zUpdateMemberRoleRequest = z.object({
role: z.enum(['owner', 'member'])
})
/**
* Request body for updating an existing workspace's settings.
*/
@@ -1227,6 +1408,60 @@ export const zWorkspace = z.object({
created_at: z.string().datetime()
})
/**
* Exchange poll result. Pending until the code is redeemed in the browser.
*/
export const zDesktopLoginCodeExchangeResponse = z.object({
status: z.enum(['pending', 'complete']),
custom_token: z.string().optional()
})
/**
* Request to exchange a redeemed login code for a custom token.
*/
export const zDesktopLoginCodeExchangeRequest = z.object({
code: z.string(),
code_verifier: z.string().min(43).max(128)
})
/**
* Result of redeeming a desktop login code.
*/
export const zDesktopLoginCodeRedeemResponse = z.object({
status: z.enum(['redeemed'])
})
/**
* Request to claim a desktop login code for the authenticated user.
*/
export const zDesktopLoginCodeRedeemRequest = z.object({
code: z.string()
})
/**
* A freshly minted desktop login code and its polling parameters.
*/
export const zDesktopLoginCodeCreateResponse = z.object({
code: z.string(),
expires_in: z.number().int(),
poll_interval: z.number().int()
})
/**
* Request to mint a desktop login code.
*/
export const zDesktopLoginCodeCreateRequest = z.object({
installation_id: z
.string()
.min(8)
.max(128)
.regex(/^[A-Za-z0-9._-]+$/)
.optional(),
platform: z.string().min(1).max(32),
app_version: z.string().min(1).max(64),
code_challenge: z.string().min(43).max(128)
})
/**
* Abbreviated workspace metadata used in list responses.
*/
@@ -1294,6 +1529,15 @@ export const zTasksListResponse = z.object({
pagination: zPaginationInfo
})
/**
* Result of authorizing a legal-hold release on a user's deletion.
*/
export const zReleaseHoldResponse = z.object({
firebase_id: z.string(),
released: z.boolean(),
message: z.string()
})
/**
* Current status of a user data deletion request.
*/
@@ -1363,6 +1607,20 @@ export const zJobDetailResponse = z.object({
execution_meta: z.record(z.unknown()).optional()
})
/**
* Response for POST /api/jobs/cancel.
*/
export const zJobsCancelResponse = z.object({
cancelled: z.array(z.string())
})
/**
* Request to cancel multiple jobs by ID.
*/
export const zJobsCancelRequest = z.object({
job_ids: z.array(z.string().uuid()).min(1).max(100)
})
/**
* Response for POST /api/jobs/{job_id}/cancel. Returned on both fresh cancels and idempotent no-ops.
*/
@@ -1529,6 +1787,7 @@ export const zAsset = z.object({
user_metadata: z.record(z.unknown()).optional(),
metadata: z.record(z.unknown()).readonly().optional(),
preview_url: z.string().url().optional(),
short_url: z.string().nullish(),
preview_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
@@ -1624,6 +1883,7 @@ export const zSystemStatsResponse = z.object({
python_version: z.string(),
embedded_python: z.boolean(),
comfyui_version: z.string(),
deploy_environment: z.string().optional(),
comfyui_frontend_version: z.string().optional(),
workflow_templates_version: z.string().optional(),
cloud_version: z.string().optional(),
@@ -1962,6 +2222,7 @@ export const zAssetWritable = z.object({
tags: z.array(z.string()).optional(),
user_metadata: z.record(z.unknown()).optional(),
preview_url: z.string().url().optional(),
short_url: z.string().nullish(),
preview_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
@@ -2180,7 +2441,11 @@ export const zGetJobDetailData = z.object({
path: z.object({
job_id: z.string().uuid()
}),
query: z.never().optional()
query: z
.object({
short_link: z.enum(['ephemeral_tool_chain', 'default']).optional()
})
.optional()
})
/**
@@ -2201,6 +2466,17 @@ export const zCancelJobData = z.object({
*/
export const zCancelJobResponse = zJobCancelResponse
export const zCancelJobsData = z.object({
body: zJobsCancelRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success - cancel requests dispatched (or jobs were already terminal)
*/
export const zCancelJobsResponse = zJobsCancelResponse
export const zViewFileData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2580,6 +2856,17 @@ export const zCreateSecretData = z.object({
*/
export const zCreateSecretResponse = zSecretResponse
export const zListSecretProvidersData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success
*/
export const zListSecretProvidersResponse = zSecretProvidersResponse
export const zDeleteSecretData = z.object({
body: z.never().optional(),
path: z.object({
@@ -2881,6 +3168,40 @@ export const zExchangeTokenData = z.object({
*/
export const zExchangeTokenResponse2 = zExchangeTokenResponse
export const zCreateDesktopLoginCodeData = z.object({
body: zDesktopLoginCodeCreateRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Login code created
*/
export const zCreateDesktopLoginCodeResponse = zDesktopLoginCodeCreateResponse
export const zRedeemDesktopLoginCodeData = z.object({
body: zDesktopLoginCodeRedeemRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Code redeemed (or already redeemed by the same user)
*/
export const zRedeemDesktopLoginCodeResponse = zDesktopLoginCodeRedeemResponse
export const zExchangeDesktopLoginCodeData = z.object({
body: zDesktopLoginCodeExchangeRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Pending (not yet redeemed) or complete with a custom token
*/
export const zExchangeDesktopLoginCodeResponse =
zDesktopLoginCodeExchangeResponse
export const zGetJwksData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3150,6 +3471,19 @@ export const zRemoveWorkspaceMemberData = z.object({
*/
export const zRemoveWorkspaceMemberResponse = z.void()
export const zUpdateWorkspaceMemberRoleData = z.object({
body: zUpdateMemberRoleRequest,
path: z.object({
userId: z.string()
}),
query: z.never().optional()
})
/**
* Member role updated
*/
export const zUpdateWorkspaceMemberRoleResponse = zMember
export const zListWorkspaceApiKeysData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3236,6 +3570,19 @@ export const zSetReviewStatusData = z.object({
*/
export const zSetReviewStatusResponse2 = zSetReviewStatusResponse
export const zAdminDeleteHubWorkflowData = z.object({
body: z.never().optional(),
path: z.object({
share_id: z.string()
}),
query: z.never().optional()
})
/**
* Successfully deleted
*/
export const zAdminDeleteHubWorkflowResponse = z.void()
export const zUpdateHubWorkflowData = z.object({
body: zUpdateHubWorkflowRequest,
path: z.object({
@@ -3277,6 +3624,19 @@ export const zCreateDeletionRequestResponse = z.object({
user_found_in_cloud: z.boolean()
})
export const zReleaseDeletionHoldData = z.object({
body: z.object({
firebase_id: z.string()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Release authorized; the deletion workflow will proceed
*/
export const zReleaseDeletionHoldResponse = zReleaseHoldResponse
export const zReportPartnerUsageData = z.object({
body: zPartnerUsageRequest,
path: z.never().optional(),
@@ -3288,6 +3648,38 @@ export const zReportPartnerUsageData = z.object({
*/
export const zReportPartnerUsageResponse = zPartnerUsageResponse
export const zGetHistoryEventsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z
.object({
workspace_id: z.string().optional(),
user_id: z.string().optional(),
event_type: z.string().optional(),
start_date: z.string().datetime().optional(),
end_date: z.string().datetime().optional(),
page: z.number().int().optional(),
limit: z.number().int().optional()
})
.optional()
})
/**
* Paginated cloud history events for the workspace
*/
export const zGetHistoryEventsResponse = zBillingEventsResponse
export const zReportHistoryEventData = z.object({
body: zHistoryEventRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* History event recorded successfully
*/
export const zReportHistoryEventResponse = zPartnerUsageResponse
export const zUpdateSubscriptionCacheData = z.object({
body: z.object({
user_id: z.string(),
@@ -3305,6 +3697,29 @@ export const zUpdateSubscriptionCacheResponse = z.object({
status: z.string().optional()
})
export const zListLinkedFirebaseUidsData = z.object({
body: zListLinkedFirebaseUidsRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Linked Firebase UIDs (possibly empty list)
*/
export const zListLinkedFirebaseUidsResponse2 = zListLinkedFirebaseUidsResponse
export const zEnsureWorkspaceBillingProvisionedData = z.object({
body: zEnsureWorkspaceBillingProvisionedRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* The workspace's provisioned billing identity
*/
export const zEnsureWorkspaceBillingProvisionedResponse2 =
zEnsureWorkspaceBillingProvisionedResponse
export const zInsertDynamicConfigData = z.object({
body: z.record(z.unknown()),
path: z.never().optional(),
@@ -4010,6 +4425,14 @@ export const zGetModelPreviewData = z.object({
query: z.never().optional()
})
export const zShortLinkRedirectData = z.object({
body: z.never().optional(),
path: z.object({
id: z.string()
}),
query: z.never().optional()
})
export const zGetLegacyPromptByIdData = z.object({
body: z.never().optional(),
path: z.object({
@@ -4070,14 +4493,23 @@ export const zGetLegacyUserdataV2Data = z.object({
query: z.never().optional()
})
export const zGetLegacyAssetContentData = z.object({
export const zGetAssetContentData = z.object({
body: z.never().optional(),
path: z.object({
id: z.string()
}),
query: z.never().optional()
query: z
.object({
disposition: z.enum(['inline', 'attachment']).optional()
})
.optional()
})
/**
* Asset content stream (local runtime streams the bytes directly)
*/
export const zGetAssetContentResponse = z.string()
export const zGetLegacyViewMetadataData = z.object({
body: z.never().optional(),
path: z.object({

View File

@@ -4,5 +4,5 @@
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*", "*.config.ts"]
"include": ["src/**/*"]
}

View File

@@ -4,5 +4,5 @@
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*", "vitest.config.ts"]
"include": ["src/**/*"]
}

94
pnpm-lock.yaml generated
View File

@@ -324,6 +324,9 @@ catalogs:
rollup-plugin-visualizer:
specifier: ^6.0.4
version: 6.0.4
shiki:
specifier: ^4.3.0
version: 4.3.1
storybook:
specifier: ^10.2.10
version: 10.2.10
@@ -609,6 +612,9 @@ importers:
semver:
specifier: ^7.7.2
version: 7.7.4
shiki:
specifier: 'catalog:'
version: 4.3.1
three:
specifier: 'catalog:'
version: 0.184.0
@@ -3461,32 +3467,32 @@ packages:
pinia:
optional: true
'@shikijs/core@4.1.0':
resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==}
'@shikijs/core@4.3.1':
resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==}
engines: {node: '>=20'}
'@shikijs/engine-javascript@4.1.0':
resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==}
'@shikijs/engine-javascript@4.3.1':
resolution: {integrity: sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==}
engines: {node: '>=20'}
'@shikijs/engine-oniguruma@4.1.0':
resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==}
'@shikijs/engine-oniguruma@4.3.1':
resolution: {integrity: sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==}
engines: {node: '>=20'}
'@shikijs/langs@4.1.0':
resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==}
'@shikijs/langs@4.3.1':
resolution: {integrity: sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==}
engines: {node: '>=20'}
'@shikijs/primitive@4.1.0':
resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==}
'@shikijs/primitive@4.3.1':
resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==}
engines: {node: '>=20'}
'@shikijs/themes@4.1.0':
resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==}
'@shikijs/themes@4.3.1':
resolution: {integrity: sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==}
engines: {node: '>=20'}
'@shikijs/types@4.1.0':
resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==}
'@shikijs/types@4.3.1':
resolution: {integrity: sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==}
engines: {node: '>=20'}
'@shikijs/vscode-textmate@10.0.2':
@@ -7918,8 +7924,8 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shiki@4.1.0:
resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==}
shiki@4.3.1:
resolution: {integrity: sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==}
engines: {node: '>=20'}
side-channel-list@1.0.0:
@@ -8884,8 +8890,8 @@ packages:
vue-component-type-helpers@3.3.2:
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
vue-component-type-helpers@3.3.5:
resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==}
vue-component-type-helpers@3.3.6:
resolution: {integrity: sha512-FkljacAwJ9BUoSUdpFe3VDy0sGigNlTH9+2zcXUWmZOjN8swiCkl3t48wOJun0OsUd2cEIda1l04tsxMiKIIrQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -9331,7 +9337,7 @@ snapshots:
'@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
lru-cache: 11.2.6
lru-cache: 11.5.1
'@asamuzakjp/dom-selector@6.7.6':
dependencies:
@@ -9365,7 +9371,7 @@ snapshots:
js-yaml: 4.1.1
picomatch: 4.0.4
retext-smartypants: 6.2.0
shiki: 4.1.0
shiki: 4.3.1
smol-toml: 1.6.1
unified: 11.0.5
@@ -11528,40 +11534,40 @@ snapshots:
optionalDependencies:
pinia: 3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))
'@shikijs/core@4.1.0':
'@shikijs/core@4.3.1':
dependencies:
'@shikijs/primitive': 4.1.0
'@shikijs/types': 4.1.0
'@shikijs/primitive': 4.3.1
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@4.1.0':
'@shikijs/engine-javascript@4.3.1':
dependencies:
'@shikijs/types': 4.1.0
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.6
'@shikijs/engine-oniguruma@4.1.0':
'@shikijs/engine-oniguruma@4.3.1':
dependencies:
'@shikijs/types': 4.1.0
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@4.1.0':
'@shikijs/langs@4.3.1':
dependencies:
'@shikijs/types': 4.1.0
'@shikijs/types': 4.3.1
'@shikijs/primitive@4.1.0':
'@shikijs/primitive@4.3.1':
dependencies:
'@shikijs/types': 4.1.0
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/themes@4.1.0':
'@shikijs/themes@4.3.1':
dependencies:
'@shikijs/types': 4.1.0
'@shikijs/types': 4.3.1
'@shikijs/types@4.1.0':
'@shikijs/types@4.3.1':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
@@ -11668,7 +11674,7 @@ snapshots:
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
type-fest: 2.19.0
vue: 3.5.34(typescript@5.9.3)
vue-component-type-helpers: 3.3.5
vue-component-type-helpers: 3.3.6
'@swc/helpers@0.5.21':
dependencies:
@@ -12979,7 +12985,7 @@ snapshots:
picomatch: 4.0.4
rehype: 13.0.2
semver: 7.7.4
shiki: 4.1.0
shiki: 4.3.1
smol-toml: 1.6.1
svgo: 4.0.1
tinyclip: 0.1.13
@@ -16996,14 +17002,14 @@ snapshots:
shebang-regex@3.0.0: {}
shiki@4.1.0:
shiki@4.3.1:
dependencies:
'@shikijs/core': 4.1.0
'@shikijs/engine-javascript': 4.1.0
'@shikijs/engine-oniguruma': 4.1.0
'@shikijs/langs': 4.1.0
'@shikijs/themes': 4.1.0
'@shikijs/types': 4.1.0
'@shikijs/core': 4.3.1
'@shikijs/engine-javascript': 4.3.1
'@shikijs/engine-oniguruma': 4.3.1
'@shikijs/langs': 4.3.1
'@shikijs/themes': 4.3.1
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
@@ -18138,7 +18144,7 @@ snapshots:
vue-component-type-helpers@3.3.2: {}
vue-component-type-helpers@3.3.5: {}
vue-component-type-helpers@3.3.6: {}
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
dependencies:

View File

@@ -117,6 +117,7 @@ catalog:
primevue: ^4.2.5
reka-ui: 2.5.0
rollup-plugin-visualizer: ^6.0.4
shiki: ^4.3.0
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.3.0

View File

@@ -1,4 +1,7 @@
@import '@comfyorg/design-system/css/style.css';
/* In-App Agent panel (FE-1187) theme: aliases agent-* tokens onto host semantic
variables. Imported after the design-system so those host variables exist. */
@import '../../workbench/extensions/agent/agentTheme.css';
/* Use 0.001ms instead of 0s so transitionend/animationend events still fire
and JS listeners aren't broken. */

View File

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

View File

@@ -7,7 +7,7 @@ import Password from 'primevue/password'
import PrimeVue from 'primevue/config'
import ProgressSpinner from 'primevue/progressspinner'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, nextTick, ref } from 'vue'
import { computed, defineComponent, h, nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
@@ -38,29 +38,45 @@ vi.mock('@/stores/authStore', () => ({
}))
const mockTurnstileEnabled = ref(false)
const mockTurnstileEnforced = ref(false)
const mockTurnstileToken = ref('')
const mockTurnstileUnavailable = ref(false)
const mockReset = vi.fn()
let emitTurnstileToken: ((token: string) => void) | undefined
let emitTurnstileUnavailable: ((unavailable: boolean) => void) | undefined
// The reset-on-toggle behavior lives in useTurnstileGate itself (see
// useTurnstile.test.ts); this fake just wires token/unavailable through to
// `waiting` the same way so SignUpForm's submit gating can be exercised.
vi.mock('@/composables/auth/useTurnstile', () => ({
useTurnstile: () => ({
enabled: mockTurnstileEnabled,
enforced: mockTurnstileEnforced
enabled: mockTurnstileEnabled
}),
useTurnstileGate: () => ({
token: mockTurnstileToken,
unavailable: mockTurnstileUnavailable,
waiting: computed(
() =>
mockTurnstileEnabled.value &&
!mockTurnstileToken.value &&
!mockTurnstileUnavailable.value
)
})
}))
// Stub the real widget (which loads the external Turnstile script) with one that
// exposes a spyable reset() and lets a test drive the v-model token the way a
// solved challenge would.
// exposes a spyable reset() and lets a test drive the v-model token/unavailable
// the way a solved challenge (or a broken/slow widget) would.
vi.mock('./TurnstileWidget.vue', async () => {
const { defineComponent: defineMock } = await import('vue')
return {
default: defineMock({
name: 'TurnstileWidget',
emits: ['update:token'],
emits: ['update:token', 'update:unavailable'],
setup(_, { expose, emit }) {
expose({ reset: mockReset })
emitTurnstileToken = (token: string) => emit('update:token', token)
emitTurnstileUnavailable = (unavailable: boolean) =>
emit('update:unavailable', unavailable)
return () => null
}
})
@@ -92,9 +108,11 @@ describe('SignUpForm', () => {
beforeEach(() => {
mockLoadingRef.value = false
mockTurnstileEnabled.value = false
mockTurnstileEnforced.value = false
mockTurnstileToken.value = ''
mockTurnstileUnavailable.value = false
mockReset.mockClear()
emitTurnstileToken = undefined
emitTurnstileUnavailable = undefined
})
afterEach(() => {
@@ -211,43 +229,22 @@ describe('SignUpForm', () => {
})
})
describe('Turnstile token hygiene', () => {
it('clears the stale token when Turnstile becomes disabled', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const { user } = renderComponent()
await fillValidSignup(user)
emitTurnstileToken!('stale-token')
await nextTick()
expect(
screen.getByRole('button', { name: signUpButton })
).not.toBeDisabled()
mockTurnstileEnabled.value = false
await nextTick()
// re-enable: the stale token must have been cleared so submit is blocked again
mockTurnstileEnabled.value = true
await nextTick()
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
})
})
// Regression coverage for the shadow-mode race: previously submit was only
// gated in 'enforce' mode, so most real signups in 'shadow' mode raced
// ahead of the async Cloudflare challenge and reached the backend with an
// empty token. Gating now depends only on whether the widget is enabled
// (shadow or enforce both render it), so both modes behave identically here.
describe('Turnstile submit gating', () => {
it('disables the submit button in enforce mode until a token is present', async () => {
it('disables the submit button until a token is present', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
renderComponent()
await nextTick()
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
})
it('does not emit submit in enforce mode while the token is empty', async () => {
it('does not emit submit while the token is empty', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
@@ -257,9 +254,8 @@ describe('SignUpForm', () => {
expect(onSubmit).not.toHaveBeenCalled()
})
it('emits submit with the token in enforce mode once the challenge is solved', async () => {
it('emits submit with the token once the challenge is solved', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
@@ -271,13 +267,14 @@ describe('SignUpForm', () => {
expect(onSubmit).toHaveBeenCalledWith(expectedValues, 'token-xyz')
})
it('emits submit without a token in shadow mode (never blocks)', async () => {
it('emits submit without a token once the widget reports itself unavailable (broken/slow load fallback)', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = false
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
emitTurnstileUnavailable!(true)
await nextTick()
await user.click(screen.getByRole('button', { name: signUpButton }))
expect(onSubmit).toHaveBeenCalledWith(expectedValues, undefined)

View File

@@ -33,10 +33,11 @@
v-if="turnstileEnabled"
ref="turnstileWidget"
v-model:token="turnstileToken"
v-model:unavailable="turnstileUnavailable"
/>
<small
v-show="submitBlockedByTurnstile"
v-show="waitingForTurnstile"
id="comfy-org-sign-up-turnstile-hint"
role="status"
aria-live="polite"
@@ -51,11 +52,9 @@
v-else
type="submit"
class="mt-4 h-10 font-medium"
:disabled="!$form.valid || submitBlockedByTurnstile"
:disabled="!$form.valid || waitingForTurnstile"
:aria-describedby="
submitBlockedByTurnstile
? 'comfy-org-sign-up-turnstile-hint'
: undefined
waitingForTurnstile ? 'comfy-org-sign-up-turnstile-hint' : undefined
"
>
{{ t('auth.signup.signUpButton') }}
@@ -70,11 +69,11 @@ import { zodResolver } from '@primevue/forms/resolvers/zod'
import { useThrottleFn } from '@vueuse/core'
import InputText from 'primevue/inputtext'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { computed, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTurnstile } from '@/composables/auth/useTurnstile'
import { useTurnstile, useTurnstileGate } from '@/composables/auth/useTurnstile'
import { signUpSchema } from '@/schemas/signInSchema'
import type { SignUpData } from '@/schemas/signInSchema'
import { useAuthStore } from '@/stores/authStore'
@@ -86,25 +85,21 @@ const { t } = useI18n()
const authStore = useAuthStore()
const loading = computed(() => authStore.loading)
const { enabled: turnstileEnabled, enforced: turnstileEnforced } =
useTurnstile()
const turnstileToken = ref('')
const { enabled: turnstileEnabled } = useTurnstile()
const {
token: turnstileToken,
unavailable: turnstileUnavailable,
waiting: waitingForTurnstile
} = useTurnstileGate(turnstileEnabled)
const turnstileWidget =
useTemplateRef<InstanceType<typeof TurnstileWidget>>('turnstileWidget')
const submitBlockedByTurnstile = computed(
() => turnstileEnforced.value && !turnstileToken.value
)
watch(turnstileEnabled, (on) => {
if (!on) turnstileToken.value = ''
})
const emit = defineEmits<{
submit: [values: SignUpData, turnstileToken?: string]
}>()
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
if (event.valid && !submitBlockedByTurnstile.value) {
if (event.valid && !waitingForTurnstile.value) {
emit(
'submit',
event.values as SignUpData,

View File

@@ -261,4 +261,138 @@ describe('TurnstileWidget', () => {
expect(api.remove).toHaveBeenCalledWith('widget-id')
})
// A widget that never resolves (broken script, ad-blocker, CDN outage, or a
// hung challenge) must eventually tell the parent it cannot be relied on,
// so submission can fall back instead of blocking a legitimate signup
// forever.
describe('unavailable fallback', () => {
it('reports unavailable when the Turnstile script fails to load', async () => {
mockLoadTurnstile.mockRejectedValue(new Error('script failed'))
const { emitted } = renderWidget()
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
})
it('reports unavailable on a challenge error', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
await flush()
options()!['error-callback']!()
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
})
it('clears the unavailable fallback once a token is solved', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
await flush()
options()!['error-callback']!()
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
options()!.callback!('token-abc')
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
})
it('falls back once the widget fails to resolve within the load timeout', async () => {
vi.useFakeTimers()
try {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
// Let the onMounted hook's `await loadTurnstile()` microtask settle
// and render() run, without yet advancing to the timeout itself.
await vi.advanceTimersByTimeAsync(0)
expect(options()).toBeDefined()
expect(emitted()['update:unavailable']).toBeUndefined()
await vi.advanceTimersByTimeAsync(9_000)
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
} finally {
vi.useRealTimers()
}
})
it('does not fall back once a token arrives before the load timeout', async () => {
vi.useFakeTimers()
try {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
await vi.advanceTimersByTimeAsync(0)
options()!.callback!('token-abc')
await vi.advanceTimersByTimeAsync(9_000)
expect(emitted()['update:unavailable']).toBeUndefined()
} finally {
vi.useRealTimers()
}
})
it('resets the widget to fetch a fresh challenge on token expiry', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
renderWidget()
await flush()
options()!.callback!('token-abc')
options()!['expired-callback']!()
await flush()
expect(api.reset).toHaveBeenCalledWith('widget-id')
})
it('falls back if a post-solve expiry is not followed by a fresh token within the load timeout', async () => {
vi.useFakeTimers()
try {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
const { emitted } = renderWidget()
await vi.advanceTimersByTimeAsync(0)
// Establish a solved, available widget: an initial error marks it
// unavailable, then solving a challenge clears that (the same
// transition the existing "clears the unavailable fallback" test
// verifies), so the expiry below is the only thing driving fallback.
options()!['error-callback']!()
options()!.callback!('token-abc')
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
// The token later expires (e.g. tab backgrounded past its ~300s
// lifetime) without the widget itself erroring.
options()!['expired-callback']!()
await vi.advanceTimersByTimeAsync(0)
// A fresh challenge was requested, but nothing solves it before the
// re-armed load timeout elapses, so submission must eventually be
// unblocked rather than staying stuck forever.
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
await vi.advanceTimersByTimeAsync(9_000)
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
} finally {
vi.useRealTimers()
}
})
})
})

View File

@@ -12,6 +12,7 @@
</template>
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -20,6 +21,14 @@ import { getTurnstileSiteKey } from '@/config/turnstile'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
const token = defineModel<string>('token', { default: '' })
/**
* Set true whenever the widget cannot be relied on to ever produce a token:
* the Cloudflare script failed to load, the rendered challenge errored out,
* or it simply hasn't resolved within `TURNSTILE_LOAD_TIMEOUT_MS`. The parent
* uses this to stop waiting on a token so a broken/slow widget (network
* issue, ad-blocker, CDN outage) can never permanently block signup.
*/
const unavailable = defineModel<boolean>('unavailable', { default: false })
const { t } = useI18n()
const colorPaletteStore = useColorPaletteStore()
@@ -28,6 +37,16 @@ const containerRef = ref<HTMLDivElement>()
const errorMessage = ref('')
let widgetId: string | undefined
/** How long to wait for the widget to resolve before falling back. */
const TURNSTILE_LOAD_TIMEOUT_MS = 9_000
const { start: armTimeout, stop: clearLoadTimeout } = useTimeoutFn(
() => {
unavailable.value = true
},
TURNSTILE_LOAD_TIMEOUT_MS,
{ immediate: false }
)
const clearToken = () => {
token.value = ''
}
@@ -46,12 +65,18 @@ const reset = () => {
errorMessage.value = ''
if (widgetId && window.turnstile) {
window.turnstile.reset(widgetId)
// A widget that renders can request a fresh challenge, so give it
// another chance before falling back again.
unavailable.value = false
armTimeout()
}
}
defineExpose({ reset })
onMounted(async () => {
armTimeout()
try {
const turnstile = await loadTurnstile()
if (!containerRef.value) return
@@ -64,23 +89,37 @@ onMounted(async () => {
sitekey: getTurnstileSiteKey(),
theme,
callback: (newToken: string) => {
clearLoadTimeout()
errorMessage.value = ''
unavailable.value = false
token.value = newToken
},
'expired-callback': () => {
clearToken()
errorMessage.value = t('auth.turnstile.expired')
if (widgetId && window.turnstile) {
window.turnstile.reset(widgetId)
// A solved token can expire on its own (e.g. the tab was
// backgrounded past the token's ~300s lifetime) without the widget
// ever erroring, so proactively request a fresh challenge and
// re-arm the load timeout in case it doesn't resolve in time.
armTimeout()
}
},
'error-callback': () => {
clearToken()
clearLoadTimeout()
console.warn('Turnstile challenge failed')
errorMessage.value = t('auth.turnstile.failed')
unavailable.value = true
if (widgetId && window.turnstile) window.turnstile.reset(widgetId)
}
})
} catch (error) {
clearLoadTimeout()
console.warn('Turnstile failed to load', error)
errorMessage.value = t('auth.turnstile.failed')
unavailable.value = true
}
})

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"
@@ -181,6 +190,7 @@ import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useNewUserService } from '@/services/useNewUserService'
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
import AgentPanelRoot from '@/workbench/extensions/agent/AgentPanelRoot.vue'
import { storeToRefs } from 'pinia'
import { useBootstrapStore } from '@/stores/bootstrapStore'

View File

@@ -147,7 +147,9 @@ describe('TabErrors.vue', () => {
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
)
).toBeInTheDocument()
expect(screen.queryByText('Error details')).not.toBeInTheDocument()
// Raw prompt-error details pass through to the card so "View details"
// shows the underlying failure.
expect(screen.getByText('Error details')).toBeInTheDocument()
})
it('renders node validation errors grouped by catalog copy', async () => {

View File

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

View File

@@ -358,6 +358,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
errors: [
{
message: error.message,
details: error.details,
...resolvedDisplay
}
]

View File

@@ -79,6 +79,11 @@ vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => ({ shiftDown: false })
}))
vi.mock('@/workbench/extensions/agent/stores/agent/agentPanelStore', () => ({
useAgentPanelStore: () =>
reactive({ isOpen: false, enabled: false, toggle: vi.fn() })
}))
vi.mock('@/utils/mouseDownUtil', () => ({
whileMouseDown: vi.fn()
}))

View File

@@ -84,6 +84,23 @@
data-testid="integrated-tab-bar-actions"
class="ml-auto flex shrink-0 items-center gap-2 px-2"
>
<button
v-if="agentPanelEnabled"
type="button"
:class="
cn(
'no-drag flex h-6 shrink-0 cursor-pointer items-center gap-2 rounded-sm border px-2 text-xs text-base-foreground transition-colors',
isAgentPanelOpen
? 'border-plum-500 bg-plum-600/20'
: 'border-plum-600 bg-ink-700 hover:border-plum-500'
)
"
:aria-label="$t('agent.askComfyAgent')"
@click="agentPanelStore.toggle()"
>
<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 +123,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 +145,7 @@ 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 { useAgentPanelStore } from '@/workbench/extensions/agent/stores/agent/agentPanelStore'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import { whileMouseDown } from '@/utils/mouseDownUtil'
@@ -145,6 +165,9 @@ const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const commandStore = useCommandStore()
const agentPanelStore = useAgentPanelStore()
const { isOpen: isAgentPanelOpen, enabled: agentPanelEnabled } =
storeToRefs(agentPanelStore)
const { isLoggedIn } = useCurrentUser()
// Dismiss a tab's terminal status badge once it has been viewed

View File

@@ -1,9 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import {
isTurnstileEnabled,
normalizeTurnstileMode,
useTurnstile
useTurnstile,
useTurnstileGate
} from '@/composables/auth/useTurnstile'
import { getTurnstileSiteKey } from '@/config/turnstile'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
@@ -137,3 +139,63 @@ describe('useTurnstile', () => {
})
})
})
describe('useTurnstileGate', () => {
it('waits while enabled with no token yet', () => {
const { waiting } = useTurnstileGate(ref(true))
expect(waiting.value).toBe(true)
})
it('never waits while disabled', () => {
const { waiting } = useTurnstileGate(ref(false))
expect(waiting.value).toBe(false)
})
it('stops waiting once a token arrives', () => {
const { token, waiting } = useTurnstileGate(ref(true))
token.value = 'token-abc'
expect(waiting.value).toBe(false)
})
it('stops waiting once the widget reports itself unavailable', () => {
const { unavailable, waiting } = useTurnstileGate(ref(true))
unavailable.value = true
expect(waiting.value).toBe(false)
})
it('clears stale token/unavailable state when enabled turns off', async () => {
const enabled = ref(true)
const { token, unavailable } = useTurnstileGate(enabled)
token.value = 'stale-token'
unavailable.value = true
enabled.value = false
await nextTick()
expect(token.value).toBe('')
expect(unavailable.value).toBe(false)
})
// Regression coverage: the reset used to only run on the enabled->disabled
// transition, so state written while the widget was briefly disabled could
// survive into the next enabled widget instance.
it('clears stale token/unavailable state when enabled turns back on', async () => {
const enabled = ref(true)
const { token, unavailable } = useTurnstileGate(enabled)
enabled.value = false
await nextTick()
token.value = 'stale-token'
unavailable.value = true
enabled.value = true
await nextTick()
expect(token.value).toBe('')
expect(unavailable.value).toBe(false)
})
})

View File

@@ -1,4 +1,5 @@
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { getTurnstileSiteKey } from '@/config/turnstile'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
@@ -42,3 +43,31 @@ export function useTurnstile() {
return { mode, siteKey, enabled, enforced }
}
/**
* Submit-gating state for the signup form's Turnstile widget: a token/
* unavailable pair, plus `waiting`, which is true while a real token is still
* needed. Waits in both shadow and enforce mode (`enabled`), not just
* `enforced`, so shadow mode's token can't race the async Cloudflare
* challenge; falls back open once the widget reports `unavailable` so a
* broken/slow load can never permanently block signup.
*
* `token`/`unavailable` reset on every `enabled` transition, in either
* direction, so state from a previous widget instance can never leak into a
* freshly (re-)rendered one.
*/
export function useTurnstileGate(enabled: Ref<boolean>) {
const token = ref('')
const unavailable = ref(false)
const waiting = computed(
() => enabled.value && !token.value && !unavailable.value
)
watch(enabled, () => {
token.value = ''
unavailable.value = false
})
return { token, unavailable, waiting }
}

View File

@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyExtension } from '@/types/comfy'
// Per-test mock state, hoisted so the vi.mock factories below may close over it.
// The extension's only job is the fail-closed flag gate: it flips the panel store's
// `enabled` and closes an open panel when the flag turns off (the tab-bar button and the
// dock both key off the store). The posthog mock exposes isFeatureEnabled (whose return
// the gate reads) and onFeatureFlags (which captures the reload listener so the test can
// drive flag transitions).
const mocks = vi.hoisted(() => ({
capturedExtensions: [] as ComfyExtension[],
agentStore: { enabled: false, close: vi.fn() },
flagEnabled: undefined as boolean | undefined,
flagListener: null as (() => void) | null
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({
registerExtension: (ext: ComfyExtension) => {
mocks.capturedExtensions.push(ext)
}
})
}))
vi.mock('@/workbench/extensions/agent/stores/agent/agentPanelStore', () => ({
useAgentPanelStore: () => mocks.agentStore
}))
vi.mock('posthog-js', () => ({
default: {
isFeatureEnabled: () => mocks.flagEnabled,
onFeatureFlags: (listener: () => void) => {
mocks.flagListener = listener
return () => {}
}
}
}))
const flush = (): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, 0))
async function loadEntryAndSetup(): Promise<void> {
await import('./agentPanel')
const ext = mocks.capturedExtensions.find(
(e) => e.name === 'Comfy.AgentPanel'
)
expect(ext).toBeDefined()
ext!.setup!({} as Parameters<NonNullable<ComfyExtension['setup']>>[0])
// setup fires setupFlagGate, which awaits the (mocked) posthog-js dynamic import before
// it captures the onFeatureFlags listener and runs the initial sync(). Flush macrotasks
// until that listener is registered so tests can drive flag transitions through it.
for (let i = 0; i < 20 && mocks.flagListener === null; i++) await flush()
expect(mocks.flagListener).toBeTypeOf('function')
}
describe('AgentPanel extension flag gate', () => {
beforeEach(() => {
mocks.capturedExtensions.length = 0
mocks.agentStore.close.mockClear()
mocks.agentStore.enabled = false
mocks.flagEnabled = undefined
mocks.flagListener = null
vi.resetModules()
})
it('leaves the panel disabled while the flag is undefined', async () => {
await loadEntryAndSetup()
expect(mocks.agentStore.enabled).toBe(false)
})
it('enables the panel when the flag turns true', async () => {
await loadEntryAndSetup()
mocks.flagEnabled = true
mocks.flagListener!()
expect(mocks.agentStore.enabled).toBe(true)
})
it('disables and closes the panel when the flag flips back to false', async () => {
await loadEntryAndSetup()
mocks.flagEnabled = true
mocks.flagListener!()
mocks.flagEnabled = false
mocks.flagListener!()
expect(mocks.agentStore.enabled).toBe(false)
expect(mocks.agentStore.close).toHaveBeenCalled()
})
})

View File

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

View File

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

View File

@@ -886,6 +886,7 @@
"nodes": "Nodes",
"workflows": "Workflows",
"templates": "Templates",
"agentPanel": "Comfy Agent",
"assets": "Assets",
"mediaAssets": {
"title": "Media Assets",
@@ -910,6 +911,7 @@
"queue": "Queue",
"nodes": "Nodes",
"models": "Models",
"agentPanel": "Comfy Agent",
"assets": "Assets",
"workflows": "Work­flows",
"templates": "Tem­plates",
@@ -4316,6 +4318,10 @@
"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."
}
}
},
@@ -4496,5 +4502,81 @@
"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",
"next": "Next",
"skip": "Skip",
"gotIt": "Got it",
"startingPointTitle": "How do you want to start?",
"startFromScratch": "Start from scratch",
"browseTemplates": "Browse templates",
"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",
"mention": "Mention a node",
"modelAuto": "Auto",
"send": "Send",
"stop": "Stop",
"thinking": "Thinking...",
"reasoning": "Reasoning",
"helpful": "Helpful",
"notHelpful": "Not helpful",
"toggleRaw": "Toggle raw markdown",
"ranToolCalls": "Ran {count} tool call | Ran {count} tool calls",
"savedToAssets": "Saved to your Assets",
"copy": "Copy",
"copied": "Copied!",
"remove": "Remove",
"friend": "there",
"you": "You",
"sessionOptions": "Chat options",
"takeControl": "Take control",
"approve": "Approve",
"deny": "Deny",
"revert": "Revert",
"approvalTitle": "Approval needed",
"waiting": "Waiting for approval...",
"agentEditing": "Comfy Agent is editing the canvas",
"conflictTitle": "You edited while the agent was working",
"conflictBody": "Choose whose changes to keep.",
"keepMine": "Keep my changes",
"letAgentContinue": "Let the agent continue",
"openNewTab": "Open in a 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",
"draftApplyFailed": "Couldn't apply the agent's draft to the canvas",
"coachTitle": "Meet the agent",
"coachBody": "Ask it to build or edit graphs."
}
}

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import type { AuditLog } from '@/services/customerEventsService'
import type {
AddCreditsClickMetadata,
AgentMessageFeedbackMetadata,
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
@@ -283,6 +284,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackUiButtonClicked?.(metadata))
}
trackAgentMessageFeedback(metadata: AgentMessageFeedbackMetadata): void {
this.dispatch((provider) => provider.trackAgentMessageFeedback?.(metadata))
}
trackPageView(pageName: string, properties?: PageViewMetadata): void {
this.dispatch((provider) => provider.trackPageView?.(pageName, properties))
}

View File

@@ -195,6 +195,46 @@ describe('PostHogTelemetryProvider', () => {
})
})
describe('platform axes (client / deployment)', () => {
afterEach(() => {
delete window.__comfyDesktop2
})
it('registers client=web and deployment=cloud in a plain browser', async () => {
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockRegister).toHaveBeenCalledWith({
client: 'web',
deployment: 'cloud'
})
})
it('registers client=desktop when the desktop preload bridge is present', async () => {
window.__comfyDesktop2 = {
isRemote: () => false,
Telemetry: { capture: vi.fn() }
}
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockRegister).toHaveBeenCalledWith({
client: 'desktop',
deployment: 'cloud'
})
})
it('registers platform axes before flushing pre-init queued events', async () => {
const provider = createProvider()
provider.trackSignupOpened()
await vi.dynamicImportSettled()
const registerOrder = hoisted.mockRegister.mock.invocationCallOrder[0]
const captureOrder = hoisted.mockCapture.mock.invocationCallOrder[0]
expect(registerOrder).toBeLessThan(captureOrder)
})
})
describe('desktop entry capture', () => {
function setLocation(search: string): void {
Object.defineProperty(window.location, 'search', {
@@ -208,12 +248,20 @@ describe('PostHogTelemetryProvider', () => {
setLocation('')
})
// The platform-axes register (client/deployment) always fires, so these
// assert no register call carrying desktop-entry attribution props.
function desktopEntryRegisterCalls(): unknown[][] {
return hoisted.mockRegister.mock.calls.filter(
([props]) => props && 'source_app' in (props as Record<string, unknown>)
)
}
it('does not register desktop props when utm_source is absent', async () => {
setLocation('')
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockRegister).not.toHaveBeenCalled()
expect(desktopEntryRegisterCalls()).toHaveLength(0)
})
it('does not register desktop props when utm_source is not comfy.desktop', async () => {
@@ -221,7 +269,7 @@ describe('PostHogTelemetryProvider', () => {
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockRegister).not.toHaveBeenCalled()
expect(desktopEntryRegisterCalls()).toHaveLength(0)
})
it('registers source_app and desktop_device_id when arriving from desktop', async () => {

View File

@@ -11,6 +11,7 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
AddCreditsClickMetadata,
AgentMessageFeedbackMetadata,
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
@@ -143,6 +144,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
before_send: createPostHogBeforeSend()
})
this.isInitialized = true
// Before flushEventQueue so pre-init events also carry the
// platform super properties.
this.registerPlatformProps()
this.flushEventQueue()
this.registerDesktopEntryProps()
@@ -285,6 +289,18 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
)
}
private registerPlatformProps(): void {
if (!this.posthog) return
try {
this.posthog.register({
client: window.__comfyDesktop2 ? 'desktop' : 'web',
deployment: 'cloud'
})
} catch (error) {
console.error('Failed to register platform props:', error)
}
}
private registerDesktopEntryProps(): void {
if (!this.posthog) return
const props = readDesktopEntryProps()
@@ -549,6 +565,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.UI_BUTTON_CLICKED, metadata)
}
trackAgentMessageFeedback(metadata: AgentMessageFeedbackMetadata): void {
this.trackEvent(TelemetryEvents.AGENT_MESSAGE_FEEDBACK, metadata)
}
trackPageView(pageName: string, properties?: PageViewMetadata): void {
this.captureRaw(TelemetryEvents.PAGE_VIEW, {
page_name: pageName,

View File

@@ -375,6 +375,15 @@ 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
}
/**
* Help center opened metadata
*/
@@ -615,6 +624,9 @@ export interface TelemetryProvider {
// Generic UI button click events
trackUiButtonClicked?(metadata: UiButtonClickMetadata): void
// In-App Agent message rating (PM-98)
trackAgentMessageFeedback?(metadata: AgentMessageFeedbackMetadata): void
// Page view tracking
trackPageView?(pageName: string, properties?: PageViewMetadata): void
}
@@ -717,6 +729,9 @@ export const TelemetryEvents = {
// Generic UI Button Click
UI_BUTTON_CLICKED: 'app:ui_button_clicked',
// In-App Agent
AGENT_MESSAGE_FEEDBACK: 'app:agent_message_feedback',
// Page View
PAGE_VIEW: 'app:page_view'
} as const
@@ -766,6 +781,7 @@ export type TelemetryEventProperties =
| HelpCenterOpenedMetadata
| HelpResourceClickedMetadata
| HelpCenterClosedMetadata
| AgentMessageFeedbackMetadata
| WorkflowCreatedMetadata
| EnterLinearMetadata
| ShareFlowMetadata

View File

@@ -0,0 +1,561 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { i18n } from '@/i18n'
import { app } from '@/scripts/app'
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useToastStore } from '@/platform/updates/common/toastStore'
// A controllable stand-in for the host api: an EventTarget with a swappable socket, the
// exact surface the reconnecting event source binds to. Emitting a malformed agent frame
// through its socket is the one seam that drives a session notice at mount without a live
// backend, which is what FIX 5 forwards to the toast. Hoisted so the api mock factory can
// close over the same listener map the test emits through.
const socket = vi.hoisted(() => {
type Listener = (event?: { data: unknown }) => void
const listeners = new Map<string, Set<Listener>>()
const fake = {
readyState: 1,
addEventListener(type: string, listener: Listener) {
const set = listeners.get(type) ?? new Set()
set.add(listener)
listeners.set(type, set)
},
removeEventListener(type: string, listener: Listener) {
listeners.get(type)?.delete(listener)
}
}
const emit = (type: string, event?: { data: unknown }): void => {
for (const listener of listeners.get(type) ?? []) listener(event)
}
const clear = (): void => listeners.clear()
return { fake, emit, clear }
})
vi.mock('@/scripts/api', () => {
const target = new EventTarget()
return {
api: {
socket: socket.fake,
addEventListener: target.addEventListener.bind(target),
removeEventListener: target.removeEventListener.bind(target)
}
}
})
vi.mock('@/scripts/app', () => ({
app: { loadGraphData: vi.fn() }
}))
vi.mock('@/platform/workflow/validation/schemas/workflowSchema', () => ({
validateComfyWorkflow: vi.fn(async (content: unknown) => content)
}))
// Light stand-in for the host execution-error store the draft-apply failure writes to.
const executionErrors = vi.hoisted(() => ({
lastPromptError: null as {
type: string
message: string
details: string
} | null,
showErrorOverlay: vi.fn()
}))
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: () => executionErrors
}))
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => ({ workspaceToken: undefined })
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ userDisplayName: { value: 'Jo Rivera' } })
}))
const trackAgentMessageFeedback = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackAgentMessageFeedback })
}))
import type { TurnId } from './schemas/agentApiSchema'
import { zAgentWsEvent } from './schemas/agentApiSchema'
import type { AgentChatEvent } from './services/agent/agentEventTransport'
import { useAgentChatHistoryStore } from './stores/agent/agentChatHistoryStore'
import { useAgentConversationStore } from './stores/agent/agentConversationStore'
import AgentPanelRoot from './AgentPanelRoot.vue'
const zAgentWsEventForTest = (raw: unknown): AgentChatEvent =>
zAgentWsEvent.parse(raw) as AgentChatEvent
describe('AgentPanelRoot session notices', () => {
beforeEach(() => {
setActivePinia(createPinia())
socket.clear()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('forwards a session notice to the host toast as an error', async () => {
render(AgentPanelRoot, { global: { plugins: [i18n] } })
const toast = useToastStore()
// A malformed agent_message_done with no readable message_id makes the session push an
// error notice (i18n agent.malformedEvent). The root watch must surface it as a toast.
socket.emit('message', {
data: JSON.stringify({ type: 'agent_message_done', data: {} })
})
await nextTick()
expect(toast.messagesToAdd).toHaveLength(1)
expect(toast.messagesToAdd[0]).toMatchObject({
severity: 'error',
summary: i18n.global.t('agent.malformedEvent')
})
})
})
describe('AgentPanelRoot attach flow', () => {
beforeEach(() => {
setActivePinia(createPinia())
socket.clear()
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('uploads a picked file, stages its ref, and forwards it on the next send', async () => {
const messageBodies: unknown[] = []
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
if (url.endsWith('/api/upload/image')) {
return new Response(
JSON.stringify({
name: 'uploaded_cat.png',
subfolder: '',
type: 'input'
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
}
// The messages POST that carries the send payload.
messageBodies.push(JSON.parse(String(init?.body)))
return new Response(
JSON.stringify({ thread_id: 'th-1', message_id: 'm-1' }),
{ status: 202, headers: { 'Content-Type': 'application/json' } }
)
})
vi.stubGlobal('fetch', fetchMock)
render(AgentPanelRoot, { global: { plugins: [i18n] } })
const file = new File(['x'], 'cat.png', { type: 'image/png' })
const input = screen.getByTestId<HTMLInputElement>('agent-file-input')
await userEvent.upload(input, file)
// The uploaded file's server name renders as a staged chip.
expect(await screen.findByText('cat.png')).toBeInTheDocument()
await userEvent.type(screen.getByRole('textbox'), 'make it pop')
await userEvent.click(screen.getByRole('button', { name: 'Send' }))
expect(messageBodies).toHaveLength(1)
expect(messageBodies[0]).toMatchObject({
content: 'make it pop',
attachments: ['uploaded_cat.png']
})
})
})
describe('AgentPanelRoot draft binding', () => {
beforeEach(() => {
setActivePinia(createPinia())
socket.clear()
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('binds the draft to the workflow id from the message ack and reloads the canvas on a patch', async () => {
const fetchMock = vi.fn(async (url: string) => {
// The server owns the workflow and returns its id in the message ack.
if (url.includes('/messages')) {
return new Response(
JSON.stringify({
thread_id: 'th-1',
message_id: 'm-1',
workflow_id: 'wf-42'
}),
{ status: 202, headers: { 'Content-Type': 'application/json' } }
)
}
return new Response('{}', { status: 200 })
})
vi.stubGlobal('fetch', fetchMock)
render(AgentPanelRoot, { global: { plugins: [i18n] } })
await userEvent.type(screen.getByRole('textbox'), 'build a graph')
await userEvent.click(screen.getByRole('button', { name: 'Send' }))
await screen.findByRole('button', { name: 'Stop' })
// A draft_patch carrying the ack's workflow id now drives the canvas; a foreign one does not.
const graph = { version: 0.4, nodes: [{ id: 1 }] }
socket.emit('message', {
data: JSON.stringify({
type: 'draft_patch',
data: { workflow_id: 'other', base_version: 0, version: 1, content: {} }
})
})
socket.emit('message', {
data: JSON.stringify({
type: 'draft_patch',
data: {
workflow_id: 'wf-42',
base_version: 0,
version: 1,
content: graph
}
})
})
await vi.waitFor(() =>
expect(app.loadGraphData).toHaveBeenCalledWith(graph)
)
expect(app.loadGraphData).toHaveBeenCalledTimes(1)
})
it('surfaces one workflow error per rejection streak and recovers on a valid draft', async () => {
const fetchMock = vi.fn(async (url: string) => {
if (url.includes('/messages')) {
return new Response(
JSON.stringify({
thread_id: 'th-1',
message_id: 'm-1',
workflow_id: 'wf-42'
}),
{ status: 202, headers: { 'Content-Type': 'application/json' } }
)
}
return new Response('{}', { status: 200 })
})
vi.stubGlobal('fetch', fetchMock)
vi.spyOn(console, 'warn').mockImplementation(() => {})
// Module-level fns retain calls across tests (restoreAllMocks only restores spies).
vi.mocked(app.loadGraphData).mockClear()
vi.mocked(validateComfyWorkflow).mockClear()
executionErrors.lastPromptError = null
executionErrors.showErrorOverlay.mockClear()
// The schema rejects the first two patches (e.g. content missing its required
// version field, as observed live); the third passes through and must both load
// the canvas and reset the failure streak so a later rejection surfaces again.
vi.mocked(validateComfyWorkflow)
.mockImplementationOnce(async (_content, onError) => {
onError?.('rejected: version required')
return null
})
.mockImplementationOnce(async (_content, onError) => {
onError?.('rejected: version required')
return null
})
render(AgentPanelRoot, { global: { plugins: [i18n] } })
await userEvent.type(screen.getByRole('textbox'), 'build a graph')
await userEvent.click(screen.getByRole('button', { name: 'Send' }))
await screen.findByRole('button', { name: 'Stop' })
const patch = (version: number): void =>
socket.emit('message', {
data: JSON.stringify({
type: 'draft_patch',
data: {
workflow_id: 'wf-42',
base_version: version - 1,
version,
content: { nodes: [] }
}
})
})
// One patch per tick: the version watcher batches same-tick bumps into one fire.
patch(1)
await vi.waitFor(() =>
expect(vi.mocked(validateComfyWorkflow)).toHaveBeenCalledTimes(1)
)
patch(2)
await vi.waitFor(() =>
expect(vi.mocked(validateComfyWorkflow)).toHaveBeenCalledTimes(2)
)
expect(app.loadGraphData).not.toHaveBeenCalled()
// The failure lands on the host workflow-error surface, once per streak.
expect(executionErrors.showErrorOverlay).toHaveBeenCalledTimes(1)
expect(executionErrors.lastPromptError).toMatchObject({
type: 'agent_draft_apply_failed',
details: 'rejected: version required'
})
patch(3)
await vi.waitFor(() => expect(app.loadGraphData).toHaveBeenCalledTimes(1))
// A rejection after a successful apply is a NEW streak and surfaces again.
vi.mocked(validateComfyWorkflow).mockImplementationOnce(
async (_content, onError) => {
onError?.('rejected again')
return null
}
)
patch(4)
await vi.waitFor(() =>
expect(executionErrors.showErrorOverlay).toHaveBeenCalledTimes(2)
)
})
it('surfaces a loadGraphData rejection as the same workflow error', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) =>
url.includes('/messages')
? new Response(
JSON.stringify({
thread_id: 'th-1',
message_id: 'm-1',
workflow_id: 'wf-42'
}),
{ status: 202, headers: { 'Content-Type': 'application/json' } }
)
: new Response('{}', { status: 200 })
)
)
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.mocked(app.loadGraphData)
.mockClear()
.mockRejectedValueOnce(new Error('graph configure exploded'))
vi.mocked(validateComfyWorkflow).mockClear()
executionErrors.lastPromptError = null
executionErrors.showErrorOverlay.mockClear()
render(AgentPanelRoot, { global: { plugins: [i18n] } })
await userEvent.type(screen.getByRole('textbox'), 'build a graph')
await userEvent.click(screen.getByRole('button', { name: 'Send' }))
await screen.findByRole('button', { name: 'Stop' })
socket.emit('message', {
data: JSON.stringify({
type: 'draft_patch',
data: {
workflow_id: 'wf-42',
base_version: 0,
version: 1,
content: { version: 0.4, nodes: [] }
}
})
})
await vi.waitFor(() =>
expect(executionErrors.showErrorOverlay).toHaveBeenCalledTimes(1)
)
expect(executionErrors.lastPromptError).toMatchObject({
type: 'agent_draft_apply_failed',
details: 'graph configure exploded'
})
})
})
describe('AgentPanelRoot history', () => {
beforeEach(() => {
setActivePinia(createPinia())
socket.clear()
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('populates Chat History from the server thread list on mount', async () => {
const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith('/api/agent/threads')) {
// Live envelope shape: {threads, pagination}; title is "" until the server
// names the thread, with the first prompt in preview.
return new Response(
JSON.stringify({
threads: [
{
id: 'th-9',
title: 'build a text to image graph',
last_message_at: '2026-07-07T10:00:00Z'
},
{
id: 'th-10',
title: '',
preview: 'make a duck',
last_message_at: '2026-07-07T09:00:00Z'
}
],
pagination: { page: 1 }
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
}
return new Response('[]', {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
})
vi.stubGlobal('fetch', fetchMock)
render(AgentPanelRoot, { global: { plugins: [i18n] } })
const history = useAgentChatHistoryStore()
await vi.waitFor(() => expect(history.sessions).toHaveLength(2))
expect(history.sessions[0]).toMatchObject({
id: 'th-9',
title: 'build a text to image graph'
})
// An unnamed thread falls back to its preview for the row title.
expect(history.sessions[1]).toMatchObject({
id: 'th-10',
title: 'make a duck'
})
})
it('marks the adopted thread as the current session', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) =>
url.endsWith('/api/agent/threads')
? new Response('{"threads":[]}', {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
: new Response('[]', {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
)
)
render(AgentPanelRoot, { global: { plugins: [i18n] } })
const convo = useAgentConversationStore()
convo.setThreadId('th-active')
await nextTick()
const history = useAgentChatHistoryStore()
expect(history.activeId).toBe('th-active')
})
})
describe('AgentPanelRoot feedback capture', () => {
beforeEach(() => {
setActivePinia(createPinia())
socket.clear()
trackAgentMessageFeedback.mockClear()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('forwards a thumbs vote to telemetry with the message id and vote', async () => {
render(AgentPanelRoot, { global: { plugins: [i18n] } })
// Seed a settled assistant turn so its feedback control renders.
const store = useAgentConversationStore()
const turnId = 'turn-9' as TurnId
store.recordUser(turnId, 'make a cat')
store.startTurn(turnId)
store.ingest(
zAgentWsEventForTest({
type: 'agent_message_delta',
data: { delta: 'Here is a cat', message_id: 'turn-9', thread_id: 'th' }
})
)
store.ingest(
zAgentWsEventForTest({
type: 'agent_message_done',
data: { message_id: 'turn-9', thread_id: 'th', usage: null }
})
)
await nextTick()
await userEvent.click(
await screen.findByRole('button', { name: 'Helpful' })
)
// A second click on the active thumb retracts the vote; null must forward, not be dropped.
await userEvent.click(
await screen.findByRole('button', { name: 'Helpful' })
)
expect(trackAgentMessageFeedback.mock.calls).toEqual([
[{ message_id: 'turn-9', vote: 'up' }],
[{ message_id: 'turn-9', vote: null }]
])
})
})
describe('AgentPanelRoot lifecycle', () => {
beforeEach(() => {
setActivePinia(createPinia())
socket.clear()
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('cancels the in-flight turn when the panel unmounts', async () => {
const urls: string[] = []
const fetchMock = vi.fn(async (url: string) => {
urls.push(url)
return new Response(
JSON.stringify({ thread_id: 'th-1', message_id: 'm-1' }),
{ status: 202, headers: { 'Content-Type': 'application/json' } }
)
})
vi.stubGlobal('fetch', fetchMock)
const { unmount } = render(AgentPanelRoot, { global: { plugins: [i18n] } })
await userEvent.type(screen.getByRole('textbox'), 'hello')
await userEvent.click(screen.getByRole('button', { name: 'Send' }))
// The turn is in flight once the Stop control shows (activeTurnId + thread adopted).
await screen.findByRole('button', { name: 'Stop' })
unmount()
// Closing the panel must POST the cancel so the turn does not keep billing unheard.
await vi.waitFor(() =>
expect(urls.some((url) => url.endsWith('/cancel'))).toBe(true)
)
})
})
describe('AgentPanelRoot greeting', () => {
beforeEach(() => {
setActivePinia(createPinia())
socket.clear()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('personalizes the empty-state greeting with the account first name', async () => {
// useCurrentUser is mocked to "Jo Rivera"; the greeting shows the first name only.
render(AgentPanelRoot, { global: { plugins: [i18n] } })
expect(await screen.findByText('Hello Jo,')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,260 @@
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useTelemetry } from '@/platform/telemetry'
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
import AgentPanel from './components/agent/AgentPanel.vue'
import OnboardingCoach from './components/agent/OnboardingCoach.vue'
import { useAttachment } from './composables/agent/useAttachment'
import type { CoachStep } from './composables/agent/useOnboarding'
import type { ComposerAttachment } from './composables/agent/useComposer'
import type { AgentThreadSummary } from './schemas/agentApiSchema'
import type { ChatSession } from './stores/agent/agentChatHistoryStore'
import { useAgentSession } from './composables/agent/useAgentSession'
import { useDraftCanvasApply } from './composables/agent/useDraftCanvasApply'
import { buildTranscriptMarkdown } from './services/agent/agentTranscript'
import { createAgentRestClient } from './services/agent/agentRestClient'
import { createReconnectingEventSource } from './services/agent/agentEventSource'
import { useAgentChatHistoryStore } from './stores/agent/agentChatHistoryStore'
import { useAgentPanelStore } from './stores/agent/agentPanelStore'
const { t } = useI18n()
const toast = useToastStore()
const workspaceAuthStore = useWorkspaceAuthStore()
const { userDisplayName } = useCurrentUser()
const userName = computed(
() => userDisplayName.value?.trim().split(/\s+/)[0] || undefined
)
const rest = createAgentRestClient({
getAuthToken: () => workspaceAuthStore.workspaceToken ?? undefined
})
// Follows api.socket across reconnects (the api nulls and replaces its socket on
// close/reopen) so the panel is not left deaf after a reconnect.
const events = createReconnectingEventSource(api)
const {
sendMessage,
stopTurn,
newChat,
start,
stop,
entries,
isStreaming,
status,
notices,
threadId,
listThreads,
loadThread
} = useAgentSession({ rest, events })
const noticeSeverity = {
error: 'error',
warning: 'warn',
info: 'info'
} as const
let noticesSeen = 0
watch(
() => notices.value.length,
(length) => {
for (const notice of notices.value.slice(noticesSeen)) {
toast.add({
severity: noticeSeverity[notice.level],
summary: notice.text
})
}
noticesSeen = length
}
)
// The draft rides the wire untyped; validate it through the host workflow schema
// before loading. A failed canvas apply is a WORKFLOW error, so it surfaces through
// the host error overlay ("View details" opens the Errors tab), once per failure
// streak (a turn can carry many patches).
const executionErrorStore = useExecutionErrorStore()
let draftRejectionNotified = false
function surfaceDraftApplyFailure(details: string): void {
console.warn(details)
if (draftRejectionNotified) return
draftRejectionNotified = true
executionErrorStore.lastPromptError = {
type: 'agent_draft_apply_failed',
message: t('agent.draftApplyFailed'),
details
}
executionErrorStore.showErrorOverlay()
}
useDraftCanvasApply((content) => {
void (async () => {
const workflow = await validateComfyWorkflow(content, (error) => {
surfaceDraftApplyFailure(error)
})
if (!workflow) return
try {
await app.loadGraphData(workflow)
draftRejectionNotified = false
} catch (error) {
surfaceDraftApplyFailure(
error instanceof Error ? error.message : String(error)
)
}
})()
})
start()
onBeforeUnmount(() => {
// Cancel any in-flight turn so it does not keep generating and billing while the panel
// is closed; unsubscribe only after the cancel settles so its ack is not torn down.
void stopTurn().finally(stop)
})
const history = useAgentChatHistoryStore()
const agentPanelStore = useAgentPanelStore()
const { copy } = useClipboard({ legacy: true })
// A null vote is a retraction of a prior thumb and is forwarded so the eval pipeline
// records it rather than dropping it.
function onFeedback(turnId: string, vote: 'up' | 'down' | null): void {
useTelemetry()?.trackAgentMessageFeedback({ message_id: turnId, vote })
}
// title is "" until the server names the thread, so the row falls back to the preview
// (the first prompt).
function toChatSession(thread: AgentThreadSummary): ChatSession {
const stamp = thread.last_message_at ?? thread.updated_at ?? thread.created_at
const updatedAt = stamp ? Date.parse(stamp) : Date.now()
return {
id: thread.id,
title: thread.title || thread.preview || t('agent.untitledChat'),
updatedAt: Number.isNaN(updatedAt) ? Date.now() : updatedAt
}
}
async function refreshHistory(): Promise<void> {
try {
history.replaceAll((await listThreads()).map(toChatSession))
} catch (error) {
console.warn('[agent] listThreads failed:', error)
}
}
watch(threadId, (id) => history.setActive(id), { immediate: true })
void refreshHistory()
async function onSelectHistory(id: string): Promise<void> {
await loadThread(id)
void refreshHistory()
}
// Only the active in-memory conversation has a transcript; a non-active id has nothing
// to serialize and gets an info toast instead.
function onCopyMarkdown(id: string): void {
if (id === history.activeId) void copy(buildTranscriptMarkdown(entries.value))
else toast.add({ severity: 'info', summary: t('agent.copyUnavailable') })
}
const coachSteps: CoachStep[] = [
{
target: '#agent-panel-root',
title: t('agent.coachTitle'),
body: t('agent.coachBody')
}
]
function onSend(text: string, attachments: ComposerAttachment[]): void {
void sendMessage(
text,
attachments.map((attachment) => attachment.ref)
)
}
function onStop(): void {
void stopTurn()
}
function onNewChat(): void {
newChat()
}
// uploadImage answers {name, subfolder, type}; the send path forwards `name` as the
// LoadImage-style ref.
const panelRef = ref<InstanceType<typeof AgentPanel>>()
const fileInput = ref<HTMLInputElement>()
const attachment = useAttachment({
upload: async (file) => ({
ref: (await rest.uploadImage(file, file.name)).name
}),
onError: (message) => toast.add({ severity: 'error', summary: message })
})
function onAttach(): void {
fileInput.value?.click()
}
async function onFilesPicked(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const files = input.files
if (files && files.length > 0) {
for (const staged of await attachment.addFiles(Array.from(files))) {
panelRef.value?.addAttachment(staged)
}
}
// Clear so re-picking the same file fires change again.
input.value = ''
}
</script>
<template>
<div id="agent-panel-root" class="size-full">
<input
ref="fileInput"
type="file"
accept="image/*,video/*"
multiple
class="hidden"
data-testid="agent-file-input"
@change="onFilesPicked"
/>
<AgentPanel
ref="panelRef"
:entries
:user-name="userName"
:streaming="isStreaming"
:submitting="status === 'thinking'"
:can-attach="true"
:is-maximized="agentPanelStore.isMaximized"
:history-groups="history.grouped"
@send="onSend"
@stop="onStop"
@attach="onAttach"
@feedback="onFeedback"
@new-chat="onNewChat"
@toggle-size="agentPanelStore.toggleMaximize()"
@close="agentPanelStore.close()"
@open-history="refreshHistory()"
@select-history="onSelectHistory"
@delete-history="history.remove($event)"
@copy-history="onCopyMarkdown"
/>
<OnboardingCoach
:steps="coachSteps"
storage-key="Comfy.AgentPanel.onboarded"
/>
</div>
</template>

View File

@@ -0,0 +1,22 @@
# 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).
## Staged (built, tested, not yet wired)
These surfaces are complete and covered by tests but are not wired into the shipped panel
yet; they are V0 tech-design surfaces awaiting host wiring:
- `components/agent/ActiveTabStrip.vue` - tab context
- `components/agent/composer/AssetTray.vue` - cloud-asset @-tag tray
- `components/agent/composer/SelectionActionChips.vue` - canvas selection
- `composables/agent/useCanvasSelection.ts` - canvas selection
## UI primitives
The panel intentionally keeps its own UI primitives (`components/ui/*`) pending a separate
design-system reconciliation PR.

View File

@@ -0,0 +1,132 @@
/* In-App Agent panel (FE-1187) theme, owned by the feature (not the design-system
master). The agent components render on agent-* utility classes (bg-agent-surface,
text-agent-fg, border-agent-border, ...); this file routes those tokens onto host
semantic variables so the panel tracks host theme flips (light mode included)
automatically. @theme inline substitutes the var reference into the generated
utilities, which is what makes the flip work without touching any component.
The panel's Figma frames are dark-only; light-mode rendering follows the host theme
here, an intentional divergence deferred to the FE-1187 design-alignment pass. */
@theme inline {
/* agent token -> host semantic token
Aliases tuned to the design prototype's token choices (visual reference). */
/* surface -> base background (#171718 dark) */
--color-agent-surface: var(--color-base-background);
/* surface-raised -> secondary background (composer, chips, bubbles) */
--color-agent-surface-raised: var(--color-secondary-background);
/* surface-hover -> secondary background hover */
--color-agent-surface-hover: var(--color-secondary-background-hover);
/* fg -> base foreground */
--color-agent-fg: var(--color-base-foreground);
/* fg-muted -> muted foreground */
--color-agent-fg-muted: var(--color-muted-foreground);
/* fg-subtle: the reference uses muted-foreground for every secondary text run */
--color-agent-fg-subtle: var(--color-muted-foreground);
/* border -> node component border (header/session dividers) */
--color-agent-border: var(--color-component-node-border);
/* border-strong -> default border (composer resting border) */
--color-agent-border-strong: var(--color-border-default);
/* accent -> primary background */
--color-agent-accent: var(--color-primary-background);
/* accent-fg: text on the always-blue accent stays white in both themes; no host
on-primary token flips correctly here. Design-review item for FE-1187. */
--color-agent-accent-fg: #ffffff;
/* danger -> destructive background */
--color-agent-danger: var(--color-destructive-background);
/* success -> success background */
--color-agent-success: var(--color-success-background);
/* pill: subtle chip surface with no defensible host pairing distinct from
surface-raised; kept literal pending the FE-1187 design pass. */
--color-agent-pill: #303036;
/* Radius is not theme-flipped and the host exposes no matching --radius-* step, so
the agent radius stays a literal here. */
--radius-agent: 0.75rem;
}
/* Structural (theme-independent) collapsible animation tokens + their keyframes,
driven by the --reka-collapsible-content-height var Reka sets on the content
element. Used by the agent CollapsibleContent for reasoning/tool disclosures. */
@theme {
--animate-collapsible-down: collapsible-down 200ms ease-out;
--animate-collapsible-up: collapsible-up 200ms ease-out;
}
@keyframes collapsible-down {
from {
height: 0;
}
to {
height: var(--reka-collapsible-content-height);
}
}
@keyframes collapsible-up {
from {
height: var(--reka-collapsible-content-height);
}
to {
height: 0;
}
}
/* Shimmering "Thinking…" text (design reference): a light band sweeps across the muted
label while the agent reasons. Falls back to the muted color under reduced-motion. */
@keyframes agent-shimmer {
from {
background-position: 150% center;
}
to {
background-position: -50% center;
}
}
.agent-shimmer-text {
background: linear-gradient(
90deg,
var(--color-muted-foreground) 35%,
var(--color-base-foreground) 50%,
var(--color-muted-foreground) 65%
);
background-size: 200% auto;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: agent-shimmer 1.8s linear infinite;
}
.disable-animations .agent-shimmer-text {
animation: none;
color: var(--color-muted-foreground);
-webkit-text-fill-color: currentcolor;
}
@media (prefers-reduced-motion: reduce) {
.agent-shimmer-text {
animation: none;
color: var(--color-muted-foreground);
-webkit-text-fill-color: currentcolor;
}
}
/* Tailwind Preflight is disabled host-wide (PrimeVue + litegraph coexistence), so raw form
elements in the panel inherit UA chrome: a <button> gets the buttonface fill + 2px outset
border, a <textarea>/<input> gets its own border, font and padding. Re-apply Preflight's
normalization, scoped to the panel root and its teleported reka surfaces (dialogs,
dropdowns, drawer carry .agent-scope). :where() holds the selectors at zero specificity so
each element's own bg-* / border utilities still win. */
@layer base {
:where(#agent-panel-root, .agent-scope)
:where(button, [type='button'], [type='reset'], [type='submit']) {
appearance: none;
background-color: transparent;
border: 0 solid;
}
:where(#agent-panel-root, .agent-scope) :where(textarea, input, select) {
appearance: none;
border: 0 solid;
background-color: transparent;
font: inherit;
color: inherit;
}
}

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
// Staged for FE-1187 host wiring (tab context is a V0 tech-design surface); built and tested, not yet wired into the shipped panel.
import type { ActiveTab } from '../../composables/agent/useCanvasContext'
// Shows which workflow the agent is acting on. Hidden when there is no active tab.
const { tab } = defineProps<{ tab: ActiveTab | null }>()
</script>
<template>
<div
v-if="tab"
class="border-agent-border text-agent-fg-muted flex items-center gap-1.5 border-b px-4 py-1.5 text-xs"
>
<span class="icon-[lucide--panels-top-left] size-3.5" />
<span class="truncate">{{ tab.name }}</span>
</div>
</template>

View File

@@ -0,0 +1,185 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ComposerAttachment } from '../../composables/agent/useComposer'
import type {
ApprovalCard,
ConflictChoice,
LockState
} from './safety/safetyTypes'
import type { ConversationEntry } from '../../stores/agent/agentConversationStore'
import type { HistoryGroups } from '../../stores/agent/agentChatHistoryStore'
import ChatHistoryScreen from './ChatHistoryScreen.vue'
import Composer from './Composer.vue'
import ConversationView from './ConversationView.vue'
import EmptyState from './EmptyState.vue'
import PanelHeader from './PanelHeader.vue'
import SessionBar from './SessionBar.vue'
import ApprovalCardView from './safety/ApprovalCard.vue'
import ConflictDialog from './safety/ConflictDialog.vue'
import LockBanner from './safety/LockBanner.vue'
import RevertButton from './safety/RevertButton.vue'
const {
entries,
userName,
streaming = false,
submitting = false,
approvalCards = [],
lockState = 'UNLOCKED',
conflictOpen = false,
canRevert = false,
canAttach = false,
isMaximized = false,
historyGroups
} = defineProps<{
entries: ConversationEntry[]
userName?: string
streaming?: boolean
submitting?: boolean
approvalCards?: ApprovalCard[]
lockState?: LockState
conflictOpen?: boolean
canRevert?: boolean
canAttach?: boolean
isMaximized?: boolean
historyGroups: HistoryGroups
}>()
const emit = defineEmits<{
send: [text: string, attachments: ComposerAttachment[]]
stop: []
attach: []
feedback: [turnId: string, vote: 'up' | 'down' | null]
answer: [approvalId: string, approved: boolean]
takeControl: []
resolveConflict: [choice: ConflictChoice]
revert: []
newChat: []
toggleSize: []
close: []
openHistory: []
selectHistory: [id: string]
deleteHistory: [id: string]
copyHistory: [id: string]
}>()
// In-panel Chat History screen (B12): the session bar opens it, the back arrow / picking a
// chat / starting a new chat closes it. Rendered inline instead of a teleported drawer.
const showHistory = ref(false)
function onNewChat(): void {
showHistory.value = false
emit('newChat')
}
function onOpenHistory(): void {
showHistory.value = true
emit('openHistory')
}
function onSelectHistory(id: string): void {
showHistory.value = false
emit('selectHistory', id)
}
const composerRef = ref<InstanceType<typeof Composer>>()
const { t } = useI18n()
// Session bar title (B4): the first user prompt titles the session; a fresh session reads
// "Untitled" (SessionBar's fallback), per the DES-455 chat-history flow.
const sessionTitle = computed(() => {
const firstUser = entries.find(
(entry): entry is Extract<ConversationEntry, { role: 'user' }> =>
entry.role === 'user'
)
return firstUser?.text.trim().slice(0, 60) || undefined
})
// The root wires the file picker + upload and stages the result back through here, so the
// panel forwards a staged attachment down to the composer without owning the upload path.
function addAttachment(attachment: ComposerAttachment): void {
composerRef.value?.addAttachment(attachment)
}
defineExpose({ addAttachment })
</script>
<template>
<section
class="bg-agent-surface text-agent-fg @container flex h-full flex-col overflow-hidden"
>
<PanelHeader
:is-maximized="isMaximized"
@new-chat="onNewChat"
@toggle-size="emit('toggleSize')"
@close="emit('close')"
/>
<template v-if="showHistory">
<ChatHistoryScreen
:groups="historyGroups"
class="min-h-0 flex-1"
@back="showHistory = false"
@select="onSelectHistory"
@delete="emit('deleteHistory', $event)"
@copy-markdown="emit('copyHistory', $event)"
/>
</template>
<template v-else>
<SessionBar :title="sessionTitle" @open-history="onOpenHistory" />
<LockBanner :state="lockState" @take-control="emit('takeControl')" />
<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">
<div v-if="approvalCards.length || canRevert" class="space-y-2 px-3 pb-1">
<ApprovalCardView
v-for="card in approvalCards"
:key="card.approvalId"
:card="card"
@answer="(id, approved) => emit('answer', id, approved)"
/>
<div v-if="canRevert" class="flex justify-end">
<RevertButton :can-revert="canRevert" @revert="emit('revert')" />
</div>
</div>
<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"
@send="(text, attachments) => emit('send', text, attachments)"
@stop="emit('stop')"
@attach="emit('attach')"
/>
<p class="text-agent-fg-muted my-0 text-center text-xs">
{{ t('agent.caption') }}
</p>
</div>
</footer>
</template>
<ConflictDialog
:open="conflictOpen"
@resolve="emit('resolveConflict', $event)"
/>
</section>
</template>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
ChatSession,
HistoryGroups
} from '../../stores/agent/agentChatHistoryStore'
// In-panel Chat History screen (Figma B12): replaces the conversation within the panel,
// with a "<- Chat History" back affordance. Sessions are grouped by recency; a row loads
// its chat, and a hover reveals copy / delete. Rendered inline (not a teleported drawer)
// so it never fights the docked panel's stacking context.
const { groups } = defineProps<{ groups: HistoryGroups }>()
const emit = defineEmits<{
back: []
select: [id: string]
delete: [id: string]
copyMarkdown: [id: string]
}>()
const { t } = useI18n()
const sections = computed(() =>
(
[
['current', t('agent.historyCurrent'), groups.current],
['today', t('agent.historyToday'), groups.today],
['yesterday', t('agent.historyYesterday'), groups.yesterday],
['earlier', t('agent.historyEarlier'), groups.earlier]
] as const
).filter(([, , items]) => items.length > 0)
)
const isEmpty = computed(() => sections.value.length === 0)
function pick(session: ChatSession): void {
emit('select', session.id)
}
</script>
<template>
<div class="flex h-full flex-col overflow-hidden">
<div class="flex shrink-0 items-center px-2 py-1.5">
<button
v-tooltip.bottom="{ value: t('agent.backToChat'), showDelay: 500 }"
type="button"
class="text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg flex h-6 cursor-pointer items-center gap-1 rounded-sm px-2 text-xs transition-colors"
@click="emit('back')"
>
<span class="icon-[lucide--arrow-left] size-3.5 shrink-0" />
<span>{{ t('agent.history') }}</span>
</button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-2">
<p
v-if="isEmpty"
class="text-agent-fg-muted px-2 py-8 text-center text-sm"
>
{{ t('agent.historyEmpty') }}
</p>
<section v-for="[key, label, items] in sections" :key="key" class="mb-3">
<p class="text-agent-fg-muted my-0 px-2 py-1 text-xs font-medium">
{{ label }}
</p>
<div
v-for="session in items"
:key="session.id"
class="group hover:bg-agent-surface-hover flex items-center gap-2 rounded-md px-2 py-1.5"
>
<button
type="button"
class="text-agent-fg flex min-w-0 flex-1 cursor-pointer items-center gap-2 text-left text-sm"
@click="pick(session)"
>
<span
class="text-agent-fg-muted icon-[lucide--circle-check] size-3.5 shrink-0"
/>
<span class="truncate">{{
session.title || t('agent.untitledChat')
}}</span>
</button>
<div class="hidden shrink-0 items-center gap-0.5 group-hover:flex">
<button
type="button"
class="text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg flex cursor-pointer items-center justify-center rounded-sm p-0.5 transition-colors"
:aria-label="t('agent.copyMarkdown')"
@click="emit('copyMarkdown', session.id)"
>
<span class="icon-[lucide--copy] size-3.5" />
</button>
<button
type="button"
class="text-agent-fg-muted hover:text-agent-danger hover:bg-agent-surface-hover flex cursor-pointer items-center justify-center rounded-sm p-0.5 transition-colors"
:aria-label="t('agent.delete')"
@click="emit('delete', session.id)"
>
<span class="icon-[lucide--trash-2] size-3.5" />
</button>
</div>
</div>
</section>
</div>
</div>
</template>

View File

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

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Textarea from '../ui/Textarea.vue'
import type { ComposerAttachment } from '../../composables/agent/useComposer'
import { useComposer } from '../../composables/agent/useComposer'
import { cn } from '@comfyorg/tailwind-utils'
import AttachmentChip from './composer/AttachmentChip.vue'
const {
streaming = false,
submitting = false,
canAttach = false
} = defineProps<{
streaming?: boolean
submitting?: boolean
canAttach?: boolean
}>()
const emit = defineEmits<{
send: [text: string, attachments: ComposerAttachment[]]
stop: []
attach: []
}>()
const { t } = useI18n()
const composer = useComposer({
onSend: (text, attachments) => emit('send', text, attachments),
isStreaming: () => streaming,
onStop: () => emit('stop')
})
// Enter sends; Shift+Enter inserts a newline (default textarea behavior). An Enter that
// commits an IME composition (CJK input) must NOT send the half-composed text.
function onEnter(event: KeyboardEvent): void {
if (event.isComposing || event.shiftKey) return
event.preventDefault()
composer.submit()
}
// Parent (asset tray / file picker) stages attachments through this.
defineExpose({
insert: composer.insert,
addAttachment: composer.addAttachment
})
</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="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"
@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 px-4 py-3"
@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>
<button
type="button"
: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"
@click="composer.insert('@')"
>
<span class="icon-[lucide--at-sign] size-4" />
</button>
</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,170 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as VueUse from '@vueuse/core'
// Capture the IntersectionObserver callback useIntersectionObserver registers so the test
// can drive the "at bottom" signal directly — happy-dom has no real observer, and the pill
// only shows once the sentinel scrolls out of view.
const intersectionCallbacks = vi.hoisted(
() => [] as ((entries: { isIntersecting: boolean }[]) => void)[]
)
vi.mock('@vueuse/core', async (importOriginal) => ({
...(await importOriginal<typeof VueUse>()),
useIntersectionObserver: (
_target: unknown,
callback: (entries: { isIntersecting: boolean }[]) => void
) => {
intersectionCallbacks.push(callback)
return { stop: () => {} }
}
}))
import { i18n } from '@/i18n'
import type { TurnId } from '../../schemas/agentApiSchema'
import { zAgentWsEvent } from '../../schemas/agentApiSchema'
import type { AgentChatEvent } from '../../services/agent/agentEventTransport'
import type {
AssistantMessage,
FilePart,
ReasoningPart
} from '../../services/agent/agentMessageParts'
import type { ConversationEntry } from '../../stores/agent/agentConversationStore'
import { useAgentConversationStore } from '../../stores/agent/agentConversationStore'
import ConversationView from './ConversationView.vue'
const T = 'msg-1' as TurnId
const chat = (raw: unknown): AgentChatEvent =>
zAgentWsEvent.parse(raw) as AgentChatEvent
const thinking = (id: string, delta: string) =>
chat({
type: 'agent_thinking',
data: { delta, message_id: id, thread_id: 'th' }
})
const delta = (id: string, text: string) =>
chat({
type: 'agent_message_delta',
data: { delta: text, message_id: id, thread_id: 'th' }
})
const toolCall = (id: string, name: string, status: string) =>
chat({
type: 'agent_tool_call',
data: { tool_name: name, status, args: [], message_id: id, thread_id: 'th' }
})
const done = (id: string) =>
chat({
type: 'agent_message_done',
data: { message_id: id, thread_id: 'th', usage: null }
})
const Harness = defineComponent({
components: { ConversationView },
setup() {
const store = useAgentConversationStore()
return { store }
},
template: `<ConversationView :entries="store.entries" user-name="Ada" />`
})
function mountHarness() {
const pinia = createPinia()
setActivePinia(pinia)
const utils = render(Harness, { global: { plugins: [pinia, i18n] } })
return { store: useAgentConversationStore(), ...utils }
}
describe('ConversationView', () => {
beforeEach(() => {
setActivePinia(createPinia())
intersectionCallbacks.length = 0
})
it('wire-driven v1 turn renders user pill, spinner, reasoning-free text, tool group', async () => {
const { store } = mountHarness()
store.recordUser(T, 'make a cat')
store.startTurn(T)
store.ingest(thinking('msg-1', 'pondering'))
// The thinking chip shows before any text streams.
expect(await screen.findByText('Thinking...')).toBeInTheDocument()
store.ingest(delta('msg-1', 'Here is a **cat**'))
store.ingest(toolCall('msg-1', 'add_node', 'ok'))
store.ingest(done('msg-1'))
expect(await screen.findByText('make a cat')).toBeInTheDocument()
expect(screen.getByText('Ran 1 tool call')).toBeInTheDocument()
// The markdown bold survives sanitization as a real <strong> element.
expect(screen.getByText('cat', { selector: 'strong' })).toBeInTheDocument()
expect(store.entries.at(-1)).toMatchObject({
role: 'assistant',
streaming: false
})
})
it('renders parts-driven surfaces without a wire source', async () => {
// These surfaces have no v1 wire event yet: thinking is transient (agent_thinking
// never persists a ReasoningPart) and assets arrive via the host run path, not the
// chat socket. The components must still render them from a hand-built parts list.
const reasoning: ReasoningPart = {
type: 'reasoning',
text: 'pondering',
state: 'done'
}
const file: FilePart = {
type: 'file',
mediaType: 'image',
url: 'https://x/cat.png',
filename: 'cat.png'
}
const assistant: AssistantMessage = {
id: 'msg-1' as TurnId,
role: 'assistant',
parts: [reasoning, file],
tokens: 0,
streaming: false,
thinking: false
}
const entries: ConversationEntry[] = [assistant]
render(ConversationView, {
props: { entries },
global: { plugins: [i18n] }
})
expect(await screen.findByText('Reasoning')).toBeInTheDocument()
expect(screen.getByRole('img')).toHaveAttribute('src', 'https://x/cat.png')
})
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' }],
tokens: 0,
streaming: false,
thinking: false
}
const scrollIntoView = vi.fn()
Element.prototype.scrollIntoView = scrollIntoView
render(ConversationView, {
props: { entries: [assistant] },
global: { plugins: [i18n] }
})
// At bottom initially: no pill.
expect(screen.queryByText('Latest')).not.toBeInTheDocument()
// Drive the sentinel out of view -> the pill appears.
for (const cb of intersectionCallbacks) cb([{ isIntersecting: false }])
const pill = await screen.findByRole('button', { name: 'Latest' })
await userEvent.click(pill)
expect(scrollIntoView).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import { useIntersectionObserver } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ScrollArea from '../ui/ScrollArea.vue'
import type { ConversationEntry } from '../../stores/agent/agentConversationStore'
import AgentMessage from './message/AgentMessage.vue'
import UserMessage from './message/UserMessage.vue'
const { entries } = defineProps<{
entries: ConversationEntry[]
}>()
const emit = defineEmits<{
feedback: [turnId: string, vote: 'up' | 'down' | null]
}>()
const { t } = useI18n()
// Keep the newest turn in view as content streams in, but ONLY when the user is already at
// the bottom — if they scrolled up to re-read, a streaming delta must not yank them back.
// The bottom sentinel's visibility is the "at bottom" signal.
const bottom = ref<HTMLElement>()
const atBottom = ref(true)
useIntersectionObserver(bottom, ([entry]) => {
atBottom.value = entry?.isIntersecting ?? true
})
function scrollToLatest(): void {
bottom.value?.scrollIntoView({ block: 'end' })
}
// Only the LAST entry can grow while a turn streams (earlier ones are settled), so this
// signal replaces a deep watch that re-walked every entry per token.
const latestContentSignal = computed(() => {
const last = entries.at(-1)
if (!last) return '0'
const size = 'parts' in last ? JSON.stringify(last.parts).length : 0
return `${entries.length}:${size}`
})
watch(
latestContentSignal,
async () => {
if (!atBottom.value) return
await nextTick()
scrollToLatest()
},
{ flush: 'post' }
)
</script>
<template>
<div class="relative h-full">
<ScrollArea class="h-full" viewport-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" />
<AgentMessage
v-else
:message="entry"
@feedback="emit('feedback', entry.id, $event)"
/>
</template>
<div ref="bottom" />
</div>
</ScrollArea>
<button
v-if="!atBottom"
type="button"
class="rounded-agent border-agent-border bg-agent-surface-raised text-agent-fg-muted hover:text-agent-fg absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1 border px-3 py-1.5 text-xs shadow-md transition-colors"
@click="scrollToLatest"
>
<span class="icon-[lucide--arrow-down] size-3.5" />
{{ t('agent.latest') }}
</button>
</div>
</template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import SuggestedPromptChip from './composer/SuggestedPromptChip.vue'
const { userName } = defineProps<{ userName?: string }>()
const emit = defineEmits<{ insert: [text: string] }>()
const { t, tm } = useI18n()
const prompts = computed(() => tm('agent.suggestedPrompts') as string[])
// Leading icon per suggested prompt (Figma B5), in the same order as agent.suggestedPrompts.
const promptIcons = [
'icon-[lucide--lightbulb]',
'icon-[lucide--list]',
'icon-[lucide--search]',
'icon-[lucide--message-circle-warning]',
'icon-[lucide--workflow]'
]
</script>
<template>
<div class="flex h-full flex-col p-4">
<div
class="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 p-6 text-center"
>
<div
class="mb-2 flex size-12 items-center justify-center rounded-xl border border-plum-600 bg-ink-700"
>
<span
class="icon-[comfy--comfy-c] size-6 text-brand-yellow drop-shadow-[0_0_12px_currentColor]"
aria-hidden="true"
/>
</div>
<div
class="text-agent-fg flex max-w-sm flex-col items-center gap-2 text-base/snug font-semibold tracking-tight @min-[570px]:text-2xl/snug"
>
<p class="my-0">
{{ t('agent.greeting', { name: userName ?? t('agent.friend') }) }}
</p>
<p class="my-0">
{{ t('agent.greetingQuestion') }}
</p>
</div>
</div>
<div class="flex shrink-0 flex-wrap gap-2 @min-[460px]:justify-center">
<SuggestedPromptChip
v-for="(prompt, index) in prompts"
:key="index"
:text="prompt"
:icon="promptIcons[index] ?? 'icon-[lucide--sparkles]'"
@insert="emit('insert', $event)"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { useWindowSize } from '@vueuse/core'
import { ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '../ui/Button.vue'
import type { CoachStep } from '../../composables/agent/useOnboarding'
import { useOnboarding } from '../../composables/agent/useOnboarding'
// Host-DOM anchored: each step points at a host selector (e.g. '#graph-canvas'). Where the
// anchor is absent (the standalone harness) the coach simply shows nothing — it must NOT
// advance/finish, which would burn the persist-once flag before the user ever saw a step.
const { steps, storageKey } = defineProps<{
steps: CoachStep[]
storageKey?: string
}>()
const { t } = useI18n()
const { active, currentStep, isLast, next, skip } = useOnboarding({
steps,
storageKey
})
const { width, height } = useWindowSize()
const CARD_W = 256
const CARD_H = 160
const MARGIN = 8
const cardStyle = ref<Record<string, string> | null>(null)
// flush:'post' so the effect runs AFTER render — a synchronous pre-mount pass would miss
// every app-rendered anchor. A missing anchor leaves the card hidden without consuming the
// step. NOTE(follow-up): re-check via MutationObserver to skip a step whose anchor never
// appears rather than blocking on it.
watchEffect(
() => {
cardStyle.value = null
if (!active.value || !currentStep.value) return
const target = document.querySelector(currentStep.value.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 && currentStep && cardStyle" class="fixed inset-0 z-50">
<div class="absolute inset-0 bg-black/40" />
<div
:style="cardStyle"
class="rounded-agent border-agent-border bg-agent-surface-raised text-agent-fg absolute w-64 border p-3 shadow-xl"
>
<p class="text-sm font-semibold">{{ currentStep.title }}</p>
<p class="text-agent-fg-muted mt-1 text-xs">{{ currentStep.body }}</p>
<div class="mt-3 flex justify-end gap-2">
<Button variant="ghost" size="sm" @click="skip">{{
t('agent.skip')
}}</Button>
<Button size="sm" @click="next">
{{ isLast ? t('agent.gotIt') : t('agent.next') }}
</Button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Button from '../ui/Button.vue'
// Header per the design reference: title + neutral ALPHA badge (no logo mark), then new
// chat, width toggle (420 <-> 960 dock), close. History is reached from the session bar
// (B4), not the header.
const { isMaximized = false } = defineProps<{ isMaximized?: boolean }>()
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">
{{ t('agent.title') }}
</h1>
<span
class="border-agent-border-strong text-agent-fg-muted rounded-full border px-2 py-0.5 text-xs"
>
{{ t('agent.alpha') }}
</span>
<div class="ml-auto flex items-center gap-1">
<Button
v-tooltip.bottom="{ value: t('agent.newChat'), showDelay: 300 }"
variant="ghost"
size="icon"
: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="ghost"
size="icon"
:aria-label="sizeToggleLabel"
@click="emit('toggleSize')"
>
<span :class="cn(sizeToggleIcon, 'size-4')" />
</Button>
<Button
v-tooltip.bottom="{ value: t('agent.close'), showDelay: 300 }"
variant="ghost"
size="icon"
:aria-label="t('agent.close')"
@click="emit('close')"
>
<span class="icon-[lucide--x] size-4" />
</Button>
</div>
</header>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
// Session / title bar (Figma B4): the row under the header showing the current chat title
// (or "Untitled" for a fresh session). The compact pill is a button that opens Chat
// History; its tooltip reads "Show chat history" to name the action.
const { title } = defineProps<{ title?: string }>()
const emit = defineEmits<{ openHistory: [] }>()
const { t } = useI18n()
</script>
<template>
<div class="flex shrink-0 items-center px-2 py-1.5">
<button
v-tooltip.bottom="{ value: t('agent.showChatHistory'), showDelay: 500 }"
type="button"
class="text-agent-fg-muted hover:bg-agent-surface-hover flex h-6 cursor-pointer items-center gap-1 rounded-sm px-2 text-xs transition-colors"
@click="emit('openHistory')"
>
<span class="icon-[lucide--align-justify] size-3.5 shrink-0" />
<span class="max-w-56 truncate">{{
title || t('agent.newChatTitle')
}}</span>
</button>
</div>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
// Staged for FE-1187 host wiring (attachments are a V0 tech-design surface); built and tested, not yet wired into the shipped panel.
import type { CloudAsset } from '../../../composables/agent/useCloudAssets'
// The user's recent cloud assets; clicking one @-tags it into the message.
const { assets } = defineProps<{ assets: CloudAsset[] }>()
const emit = defineEmits<{ select: [asset: CloudAsset] }>()
</script>
<template>
<div class="grid grid-cols-4 gap-1.5">
<button
v-for="asset in assets"
:key="asset.id"
type="button"
:aria-label="asset.name"
class="rounded-agent border-agent-border focus-visible:ring-agent-accent aspect-square overflow-hidden border focus-visible:ring-2 focus-visible:outline-none"
@click="emit('select', asset)"
>
<img :src="asset.url" :alt="asset.name" class="size-full object-cover" />
</button>
</div>
</template>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { name, previewUrl } = defineProps<{
name: string
previewUrl?: string
}>()
const emit = defineEmits<{ remove: [] }>()
const { t } = useI18n()
</script>
<template>
<span
class="rounded-agent bg-agent-pill text-agent-fg inline-flex items-center gap-1.5 py-1 pr-2 pl-1 text-xs"
>
<img
v-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-subtle hover:bg-agent-surface-hover hover:text-agent-fg flex size-4 items-center justify-center rounded-full transition-colors"
@click="emit('remove')"
>
<span class="icon-[lucide--x] size-3" />
</button>
</span>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
// Staged for FE-1187 host wiring (canvas selection is a V0 tech-design surface); built and tested, not yet wired into the shipped panel.
// Quick actions for a single selected node (e.g. Explain / Improve). Each inserts its
// prompt into the composer; live-only, so the parent hides these while disconnected.
interface SelectionAction {
label: string
prompt: string
}
const { actions } = defineProps<{ actions: SelectionAction[] }>()
const emit = defineEmits<{ action: [prompt: string] }>()
</script>
<template>
<div class="flex flex-wrap gap-1.5">
<button
v-for="action in actions"
:key="action.label"
type="button"
class="rounded-agent border-agent-border text-agent-fg-muted hover:border-agent-border-strong hover:text-agent-fg border px-2 py-1 text-xs transition-colors"
@click="emit('action', action.prompt)"
>
{{ action.label }}
</button>
</div>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
// Click INSERTS the prompt into the composer (never sends) per the locked Figma decision.
// Per the design reference: a rounded-full pill (leading icon + label) that stacks
// full-width in narrow panels and wraps centered from 460px container width up.
const { text, icon } = defineProps<{ text: string; icon: string }>()
const emit = defineEmits<{ insert: [text: string] }>()
</script>
<template>
<button
type="button"
class="bg-agent-surface-raised text-agent-fg hover:bg-agent-surface-hover focus-visible:ring-agent-accent flex h-8 w-full cursor-pointer items-center justify-start gap-2 rounded-full px-3 text-sm whitespace-nowrap transition-colors focus-visible:ring-2 focus-visible:outline-none @min-[460px]:w-auto"
@click="emit('insert', text)"
>
<span
:class="cn('text-agent-fg-muted size-3 shrink-0', icon)"
aria-hidden="true"
/>
<span class="truncate">{{ text }}</span>
</button>
</template>

View File

@@ -0,0 +1,75 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { useCanvasContext } from '../../composables/agent/useCanvasContext'
import type { CloudAsset } from '../../composables/agent/useCloudAssets'
import { useCloudAssets } from '../../composables/agent/useCloudAssets'
import ActiveTabStrip from './ActiveTabStrip.vue'
import AssetTray from './composer/AssetTray.vue'
import SelectionActionChips from './composer/SelectionActionChips.vue'
describe('SelectionActionChips', () => {
it('emits the prompt of the clicked action', async () => {
const { emitted } = render(SelectionActionChips, {
props: { actions: [{ label: 'Explain', prompt: 'Explain this node' }] }
})
await userEvent.click(screen.getByRole('button', { name: 'Explain' }))
expect(emitted().action[0]).toEqual(['Explain this node'])
})
})
describe('AssetTray', () => {
it('emits the clicked asset', async () => {
const asset: CloudAsset = { id: 'a1', name: 'cat.png', url: 'blob:cat' }
const { emitted } = render(AssetTray, { props: { assets: [asset] } })
await userEvent.click(screen.getByRole('button', { name: 'cat.png' }))
expect(emitted().select[0]).toEqual([asset])
})
})
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()
})
})
describe('useCloudAssets', () => {
it('dedupes by id and caps to max', async () => {
const raw: CloudAsset[] = [
{ id: '1', name: 'a', url: 'u1' },
{ id: '1', name: 'a-dup', url: 'u1' },
{ id: '2', name: 'b', url: 'u2' },
{ id: '3', name: 'c', url: 'u3' }
]
const { assets, load } = useCloudAssets({
fetchAssets: () => Promise.resolve(raw),
max: 2
})
await load()
expect(assets.value.map((a) => a.id)).toEqual(['1', '2'])
})
})
describe('useCanvasContext', () => {
it('pushes a tab switch when the active tab changes', async () => {
const activeTab = ref<{ name: string; id?: string | null } | null>({
name: 'first',
id: 't1'
})
const onTabChange = vi.fn()
useCanvasContext({ activeTab, onTabChange })
activeTab.value = { name: 'second', id: 't2' }
await Promise.resolve()
expect(onTabChange).toHaveBeenCalledWith({ name: 'second', id: 't2' })
})
})

View File

@@ -0,0 +1,128 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
AssistantMessage,
FilePart,
NoticePart,
ReasoningPart,
TextPart,
ToolPart
} from '../../../services/agent/agentMessageParts'
import { cn } from '@comfyorg/tailwind-utils'
import AssetGrid from './AssetGrid.vue'
import MarkdownStream from './MarkdownStream.vue'
import MessageFeedback from './MessageFeedback.vue'
import ReasoningDisclosure from './ReasoningDisclosure.vue'
import ThinkingStatus from './ThinkingStatus.vue'
import ToolCallGroup from './ToolCallGroup.vue'
const { message } = defineProps<{ message: AssistantMessage }>()
const emit = defineEmits<{ feedback: [vote: 'up' | 'down' | null] }>()
const { t } = useI18n()
// Adjacent tool / asset parts render as one grouped card; text, reasoning and notices
// stay as their own rows, preserving the transport's interleaved order.
type Group =
| { kind: 'text'; part: TextPart }
| { kind: 'reasoning'; part: ReasoningPart }
| { kind: 'notice'; part: NoticePart }
| { kind: 'tools'; parts: ToolPart[] }
| { kind: 'assets'; parts: FilePart[] }
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 === 'file') {
if (prev?.kind === 'assets') prev.parts.push(part)
else out.push({ kind: 'assets', parts: [part] })
} else if (part.type === 'text') {
out.push({ kind: 'text', part })
} else if (part.type === 'reasoning') {
out.push({ kind: 'reasoning', 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="group space-y-1.5">
<ThinkingStatus
v-if="message.thinking || (message.streaming && !message.parts.length)"
:tokens="message.tokens"
/>
<template v-for="(group, index) in groups" :key="index">
<MarkdownStream
v-if="group.kind === 'text'"
:text="group.part.text"
:raw="raw"
/>
<ReasoningDisclosure
v-else-if="group.kind === 'reasoning'"
:text="group.part.text"
:streaming="group.part.state === 'streaming'"
/>
<ToolCallGroup v-else-if="group.kind === 'tools'" :tools="group.parts" />
<AssetGrid v-else-if="group.kind === 'assets'" :assets="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 opacity-0 transition-opacity group-hover:opacity-100"
>
<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,33 @@
<script setup lang="ts">
import { computed } from 'vue'
const {
url,
filename,
mediaType = 'image'
} = defineProps<{
url: string
filename: string
mediaType?: string
}>()
const isVideo = computed(() => mediaType.startsWith('video'))
</script>
<template>
<button
type="button"
class="rounded-agent border-agent-border bg-agent-surface focus-visible:ring-agent-accent relative block aspect-square overflow-hidden border focus-visible:ring-2 focus-visible:outline-none"
>
<video
v-if="isVideo"
:src="url"
class="size-full object-cover"
autoplay
muted
loop
playsinline
/>
<img v-else :src="url" :alt="filename" class="size-full object-cover" />
</button>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { FilePart } from '../../../services/agent/agentMessageParts'
import AssetCard from './AssetCard.vue'
import Lightbox from './Lightbox.vue'
const { assets } = defineProps<{ assets: FilePart[] }>()
const { t } = useI18n()
const open = ref(false)
const active = ref<FilePart | null>(null)
function show(asset: FilePart): void {
active.value = asset
open.value = true
}
const activeIsVideo = computed(
() => active.value?.mediaType.startsWith('video') ?? false
)
</script>
<template>
<div>
<div class="grid grid-cols-2 gap-2">
<AssetCard
v-for="(asset, index) in assets"
:key="index"
:url="asset.url"
:filename="asset.filename"
:media-type="asset.mediaType"
@click="show(asset)"
/>
</div>
<p class="text-agent-fg-subtle mt-1.5 text-xs">
{{ t('agent.savedToAssets') }}
</p>
<Lightbox
v-model:open="open"
:url="active?.url"
:filename="active?.filename"
:is-video="activeIsVideo"
/>
</div>
</template>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { default as DOMPurify } from 'dompurify'
import { ref, watch } from 'vue'
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 })
// shiki highlights asynchronously and its bundle is lazy-loaded, so the block first
// renders as plain escaped code and swaps to the highlighted markup once shiki resolves.
// An unknown language (or a shiki failure) degrades to the plain fallback rather than
// throwing. shiki emits its own trusted <span> markup, safe to inject.
const highlighted = ref<string | null>(null)
// watch (not watchEffect) so the code/lang deps are tracked even though the highlight body
// awaits the lazy shiki import — after the first await a watchEffect tracks nothing.
watch(
() => [code, lang] as const,
async ([currentCode, currentLang], _prev, onCleanup) => {
// Streaming re-runs this per token; a superseded run must not land its stale
// highlight after a newer one (async resolutions are not ordered).
let cancelled = false
onCleanup(() => {
cancelled = true
})
highlighted.value = null
try {
const { codeToHtml } = await import('shiki')
const html = await codeToHtml(currentCode, {
lang: currentLang,
theme: 'github-dark',
colorReplacements: { '#24292e': 'transparent' }
})
if (!cancelled) highlighted.value = DOMPurify.sanitize(html)
} catch {
if (!cancelled) highlighted.value = null
}
},
{ immediate: 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>
<div
v-if="highlighted"
class="overflow-x-auto p-4 font-mono text-sm [&_pre]:bg-transparent"
v-html="highlighted"
/>
<pre
v-else
class="text-agent-fg overflow-x-auto p-4 font-mono text-sm"
><code>{{ code }}</code></pre>
</div>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { DialogRoot, DialogTitle } from 'reka-ui'
import DialogContent from '../../ui/DialogContent.vue'
const {
url,
filename,
isVideo = false
} = defineProps<{
url?: string
filename?: string
isVideo?: boolean
}>()
const open = defineModel<boolean>('open', { default: false })
</script>
<template>
<DialogRoot v-model:open="open">
<DialogContent class="max-w-3xl border-0 bg-transparent p-0 shadow-none">
<DialogTitle class="sr-only">{{ filename }}</DialogTitle>
<video
v-if="isVideo && url"
:src="url"
class="rounded-agent max-h-[80vh] w-full object-contain"
controls
autoplay
muted
playsinline
loop
/>
<img
v-else-if="url"
:src="url"
:alt="filename"
class="rounded-agent max-h-[80vh] w-full object-contain"
/>
</DialogContent>
</DialogRoot>
</template>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { renderMarkdownToHtml } from '../../../utils/agent/renderMarkdownToHtml'
import CodeBlock from './CodeBlock.vue'
const { text, raw = false } = defineProps<{
text: string
raw?: boolean
}>()
interface ProseSegment {
type: 'prose'
html: string
}
interface CodeSegment {
type: 'code'
code: string
lang: string
}
type Segment = ProseSegment | CodeSegment
// Fenced code blocks are pulled out so they render as shiki-highlighted CodeBlocks with
// copy chrome; the prose between them goes through the sanitizing markdown renderer. The
// opening fence must start a line (lookbehind for start-or-newline) so an inline
// triple-backtick span mid-sentence is left to the markdown renderer, not misparsed as a
// block. NOTE(migration): 4+ backtick fences still need a marked.lexer-based split.
const FENCE = /(?<=^|\n)```([\w-]*)\n([\s\S]*?)```/g
const segments = computed<Segment[]>(() => {
const out: Segment[] = []
let last = 0
for (const match of text.matchAll(FENCE)) {
const idx = match.index ?? 0
if (idx > last) {
out.push({
type: 'prose',
html: renderMarkdownToHtml(text.slice(last, idx))
})
}
out.push({
type: 'code',
code: match[2].replace(/\n$/, ''),
lang: match[1] || 'text'
})
last = idx + match[0].length
}
if (last < text.length) {
out.push({ type: 'prose', html: renderMarkdownToHtml(text.slice(last)) })
}
return out
})
// Prose styles read from the deployed prototype's .agent-markdown rules.
const proseClass = cn(
'text-agent-fg text-sm/relaxed',
'[&_a]:text-agent-accent [&_a]:cursor-pointer [&_a]:underline',
'[&_p]:my-0 [&_p]:pt-1 [&_strong]:font-semibold',
'[&_h1]:mt-0 [&_h1]:pt-4 [&_h1]:pb-2 [&_h1]:text-2xl [&_h1]:font-semibold',
'[&_h2]:pt-3.5 [&_h2]:pb-1.5 [&_h2]:text-base [&_h2]:font-semibold [&_h3]:pt-2 [&_h3]:font-semibold',
'[&_ol]:my-0 [&_ol]:list-decimal [&_ol]:pt-1 [&_ol]:pb-2 [&_ol]:pl-5',
'[&_ul]:my-0 [&_ul]:list-disc [&_ul]:pt-1 [&_ul]:pb-2 [&_ul]:pl-5',
'[&_:not(pre)>code]:bg-agent-surface-hover [&_:not(pre)>code]:border-agent-border-strong [&_:not(pre)>code]:rounded-sm [&_:not(pre)>code]:border [&_:not(pre)>code]:px-1.5 [&_:not(pre)>code]:py-0.5 [&_:not(pre)>code]:text-[0.875em]',
'[&_blockquote]:border-agent-border-strong [&_blockquote]:text-agent-fg-muted [&_blockquote]:my-2 [&_blockquote]:border-l-[3px] [&_blockquote]:py-1.5 [&_blockquote]:pl-3.5',
'[&_table]:bg-agent-surface-raised [&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_table]:overflow-hidden [&_table]:rounded-lg',
'[&_th]:border-agent-border-strong [&_th]:bg-agent-surface-hover [&_th]:border-b [&_th]:px-4 [&_th]:py-2.5 [&_th]:text-left [&_th]:font-semibold',
'[&_td]:border-agent-border-strong [&_td]:border-b [&_td]:px-4 [&_td]:py-2.5'
)
</script>
<template>
<pre
v-if="raw"
class="rounded-agent bg-agent-surface text-agent-fg overflow-x-auto p-3 text-xs whitespace-pre-wrap"
>{{ text }}</pre
>
<div v-else>
<template v-for="(segment, index) in segments" :key="index">
<CodeBlock
v-if="segment.type === 'code'"
:code="segment.code"
:lang="segment.lang"
/>
<div v-else :class="proseClass" v-html="segment.html" />
</template>
</div>
</template>

View File

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

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import CollapsibleContent from '../../ui/CollapsibleContent.vue'
// Open while the reasoning is still streaming so the user sees it live; collapses to a
// one-line summary once the turn moves on. Controlled (not defaultOpen, which reka reads
// only at mount) so it actually collapses when streaming ends; a manual re-open sticks.
const { text, streaming = false } = defineProps<{
text: string
streaming?: boolean
}>()
const { t } = useI18n()
const open = ref(streaming)
watch(
() => streaming,
(isStreaming) => {
if (!isStreaming) open.value = false
}
)
</script>
<template>
<CollapsibleRoot
v-model:open="open"
class="rounded-agent border-agent-border bg-agent-surface border"
>
<CollapsibleTrigger
class="group text-agent-fg-muted hover:text-agent-fg flex w-full items-center gap-1.5 px-3 py-1.5 text-xs transition-colors"
>
<span
class="icon-[lucide--chevron-right] size-3.5 transition-transform group-data-[state=open]:rotate-90"
/>
{{ t('agent.reasoning') }}
</CollapsibleTrigger>
<CollapsibleContent>
<div
class="text-agent-fg-muted px-3 pb-2 text-xs/relaxed whitespace-pre-wrap"
>
{{ text }}
</div>
</CollapsibleContent>
</CollapsibleRoot>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
// Plain "Thinking..." per the locked Figma decision (the monolith's rotating status
// words are dropped). The running think-token count shows only when the server has sent
// one (tokens > 0).
const { tokens = 0 } = defineProps<{ tokens?: number }>()
const { t } = useI18n()
</script>
<template>
<div class="text-agent-fg-muted flex items-center gap-1.5 py-1 text-sm">
<span class="icon-[lucide--brain] size-3.5 shrink-0" />
<span class="agent-shimmer-text">{{ t('agent.thinking') }}</span>
<span v-if="tokens > 0" class="text-agent-fg-subtle text-xs">{{
tokens
}}</span>
</div>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { PartState } from '../../../services/agent/agentMessageParts'
import { cn } from '@comfyorg/tailwind-utils'
// One tool row (or a run of identical calls collapsed with a count). `ok` is undefined
// while the call is still running.
const {
name,
state,
ok,
count = 1
} = defineProps<{
name: string
state: PartState
ok?: boolean
count?: number
}>()
const glyph = computed(() => {
if (state === 'streaming') return 'animate-spin icon-[lucide--loader-circle]'
return ok === false
? 'icon-[lucide--circle-x]'
: 'icon-[lucide--circle-check]'
})
const glyphColor = computed(() => {
if (state === 'streaming') return 'text-agent-fg-subtle'
return ok === false ? 'text-agent-danger' : 'text-agent-success'
})
</script>
<template>
<div class="text-agent-fg flex items-center gap-2 px-3 py-1.5 text-sm">
<span :class="cn('size-4 shrink-0', glyph, glyphColor)" />
<span class="truncate font-mono text-xs">{{ name }}</span>
<span v-if="count > 1" class="text-agent-fg-subtle text-xs"
>×{{ count }}</span
>
</div>
</template>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import CollapsibleContent from '../../ui/CollapsibleContent.vue'
import type { ToolPart } from '../../../services/agent/agentMessageParts'
import { cn } from '@comfyorg/tailwind-utils'
import ToolCallCard from './ToolCallCard.vue'
const { tools } = defineProps<{ tools: ToolPart[] }>()
const { t } = useI18n()
// Consecutive identical tool names collapse into one row with a count (same-tool xN).
interface Row {
name: string
state: ToolPart['state']
ok?: boolean
count: number
}
const rows = computed<Row[]>(() => {
const out: Row[] = []
for (const tool of tools) {
const prev = out.at(-1)
if (prev && prev.name === tool.name) {
prev.count += 1
if (tool.state === 'streaming') prev.state = 'streaming'
if (tool.ok === false) prev.ok = false
} else {
out.push({
name: tool.name,
state: tool.state,
ok: tool.ok,
count: 1
})
}
}
return out
})
const running = computed(() => tools.some((tool) => tool.state === 'streaming'))
const failed = computed(() =>
tools.some((tool) => tool.state === 'done' && tool.ok === false)
)
// Live while running or if anything failed; collapse once every call settled clean. Drive
// open BOTH ways off the source: a later tool re-entering the group (running again) or a
// tool failing must re-open it, not just the first clean settle. Manual re-open of a
// settled clean group still sticks because the source does not change then.
const open = ref(true)
watch(
() => running.value || failed.value,
(stayOpen) => {
open.value = stayOpen
},
{ immediate: true }
)
</script>
<template>
<CollapsibleRoot v-model:open="open">
<CollapsibleTrigger
class="group text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg flex h-8 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-sm transition-colors"
>
<span
v-if="running"
class="text-agent-fg-subtle icon-[lucide--loader-circle] size-4 shrink-0 animate-spin"
/>
<span
v-else-if="failed"
class="text-agent-danger icon-[lucide--circle-x] size-4 shrink-0"
/>
<span v-else class="icon-[lucide--wrench] size-4 shrink-0" />
<span class="flex-1 text-left">{{
t('agent.ranToolCalls', tools.length)
}}</span>
<span
class="icon-[lucide--chevron-down] size-4 shrink-0 transition-transform group-data-[state=open]:rotate-180"
/>
</CollapsibleTrigger>
<CollapsibleContent>
<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,16 @@
<script setup lang="ts">
// User message per the design reference: a right-aligned pill, no avatar.
const { text } = defineProps<{
text: string
}>()
</script>
<template>
<div class="flex justify-end">
<div
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,42 @@
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 { ApprovalCard as ApprovalCardModel } from './safetyTypes'
import ApprovalCard from './ApprovalCard.vue'
const base = {
approvalId: 'a1',
turnId: 't1',
tool: 'spend_credits',
summary: 'Generate 4 images (8 credits)',
outcome: null
}
function mount(card: ApprovalCardModel) {
return render(ApprovalCard, { props: { card }, global: { plugins: [i18n] } })
}
describe('ApprovalCard (non-optimistic)', () => {
it('emits answer on Approve but does not itself flip to approved', async () => {
const { emitted } = mount({ ...base, status: 'open' })
await userEvent.click(screen.getByRole('button', { name: 'Approve' }))
expect(emitted().answer[0]).toEqual(['a1', true])
// Still shows the live buttons — the card only resolves on the server's approval_closed.
expect(screen.getByRole('button', { name: 'Approve' })).toBeInTheDocument()
expect(screen.queryByText('approved')).toBeNull()
})
it('shows a waiting state with no action buttons once answered', () => {
mount({ ...base, status: 'waiting' })
expect(screen.getByText('Waiting for approval...')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull()
})
it('shows the terminal outcome once resolved', () => {
mount({ ...base, status: 'resolved', outcome: 'approved' })
expect(screen.getByText('approved')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '../../ui/Button.vue'
import type { ApprovalCard } from './safetyTypes'
import { cn } from '@comfyorg/tailwind-utils'
// NON-optimistic: clicking Approve/Deny emits the answer and moves the card to 'waiting'
// (parent calls answerApproval); it flips to 'resolved' ONLY when the server's
// approval_closed lands. The card never shows "approved" on click alone.
const { card } = defineProps<{ card: ApprovalCard }>()
const emit = defineEmits<{ answer: [approvalId: string, approved: boolean] }>()
const { t } = useI18n()
// A rapid double-click must not emit two answers before the parent flips the card off
// 'open'; an approval is a spend decision.
const answered = ref(false)
function answer(approved: boolean): void {
if (answered.value) return
answered.value = true
emit('answer', card.approvalId, approved)
}
</script>
<template>
<div
class="rounded-agent border-agent-border bg-agent-surface-raised space-y-2 border p-3"
>
<div class="flex items-center gap-2 text-sm">
<span class="text-agent-accent icon-[lucide--shield-alert] size-4" />
<span class="text-agent-fg font-medium">{{
t('agent.approvalTitle')
}}</span>
</div>
<p class="text-agent-fg-muted text-sm">{{ card.summary }}</p>
<p class="text-agent-fg-subtle font-mono text-xs">{{ card.tool }}</p>
<div v-if="card.status === 'open'" class="flex gap-2">
<Button size="sm" :disabled="answered" @click="answer(true)">
{{ t('agent.approve') }}
</Button>
<Button
size="sm"
variant="outline"
:disabled="answered"
@click="answer(false)"
>
{{ t('agent.deny') }}
</Button>
</div>
<p
v-else-if="card.status === 'waiting'"
class="text-agent-fg-muted flex items-center gap-1.5 text-xs"
>
<span class="icon-[lucide--loader-circle] size-3.5 animate-spin" />
{{ t('agent.waiting') }}
</p>
<p
v-else
:class="
cn(
'text-xs',
card.outcome === 'approved'
? 'text-agent-success'
: 'text-agent-fg-subtle'
)
"
>
{{ card.outcome }}
</p>
</div>
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { DialogDescription, DialogRoot, DialogTitle } from 'reka-ui'
import { useI18n } from 'vue-i18n'
import Button from '../../ui/Button.vue'
import DialogContent from '../../ui/DialogContent.vue'
import type { ConflictChoice } from './safetyTypes'
// Controlled: visibility is driven by the lock's conflictOpen state, and resolving flips
// that state upstream. Not self-dismissible (no update:open handler) — the user must pick.
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">
<DialogContent :show-close="false" class="space-y-3">
<DialogTitle class="text-agent-fg text-base font-semibold">
{{ t('agent.conflictTitle') }}
</DialogTitle>
<DialogDescription class="text-agent-fg-muted text-sm">
{{ t('agent.conflictBody') }}
</DialogDescription>
<div class="flex flex-col gap-2">
<Button @click="choose('mine')">{{ t('agent.keepMine') }}</Button>
<Button variant="outline" @click="choose('agent')">
{{ t('agent.letAgentContinue') }}
</Button>
<Button variant="ghost" @click="choose('newtab')">
{{ t('agent.openNewTab') }}
</Button>
</div>
</DialogContent>
</DialogRoot>
</template>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '../../ui/Button.vue'
import type { LockState } from './safetyTypes'
// Shows while the agent holds (or is claiming) the canvas lock. Take control is always
// offered here — the always-visible reclaim button is one of the five lock failsafes.
const { state } = defineProps<{ state: LockState }>()
const emit = defineEmits<{ takeControl: [] }>()
const { t } = useI18n()
const show = computed(() => state === 'LOCKED' || state === 'LOCK_PENDING')
</script>
<template>
<div
v-if="show"
class="border-agent-border bg-agent-surface-raised flex items-center gap-2 border-b px-4 py-2 text-sm"
>
<span class="text-agent-accent icon-[lucide--lock] size-4" />
<span class="text-agent-fg-muted flex-1">{{
t('agent.agentEditing')
}}</span>
<Button size="sm" variant="outline" @click="emit('takeControl')">
{{ t('agent.takeControl') }}
</Button>
</div>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '../../ui/Button.vue'
const { canRevert } = defineProps<{ canRevert: boolean }>()
const emit = defineEmits<{ revert: [] }>()
const { t } = useI18n()
</script>
<template>
<Button
size="sm"
variant="outline"
:disabled="!canRevert"
@click="emit('revert')"
>
<span class="icon-[lucide--undo-2] size-4" />
{{ t('agent.revert') }}
</Button>
</template>

View File

@@ -0,0 +1,27 @@
type ApprovalOutcome =
| 'approved'
| 'denied'
| 'timeout'
| 'cancelled'
| 'disconnected'
// waiting = user answered but the server's terminal outcome has not landed yet.
type ApprovalCardStatus = 'open' | 'waiting' | 'resolved'
export interface ApprovalCard {
approvalId: string
turnId: string
tool: string
summary: string
status: ApprovalCardStatus
outcome: ApprovalOutcome | null
}
export type LockState =
| 'UNLOCKED'
| 'LOCK_PENDING'
| 'LOCKED'
| 'CONFLICT'
| 'UNLOCKING'
export type ConflictChoice = 'agent' | 'mine' | 'newtab'

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import { cva } from 'cva'
import { Primitive } from 'reka-ui'
import type { VariantProps } from 'cva'
import { cn } from '@comfyorg/tailwind-utils'
const buttonVariants = cva({
base: 'rounded-agent focus-visible:ring-agent-accent inline-flex items-center justify-center gap-2 text-sm font-medium whitespace-nowrap transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
variants: {
variant: {
default: 'bg-agent-accent text-agent-accent-fg hover:bg-agent-accent/90',
ghost:
'text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg',
outline:
'border-agent-border text-agent-fg hover:bg-agent-surface-hover border bg-transparent',
danger: 'bg-agent-danger text-agent-accent-fg hover:bg-agent-danger/90'
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 px-3',
icon: 'size-8'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
})
type ButtonVariants = VariantProps<typeof buttonVariants>
const {
variant,
size,
as = 'button',
asChild = false,
class: cls
} = defineProps<{
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
as?: string
asChild?: boolean
class?: string
}>()
</script>
<template>
<Primitive
:as
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), cls)"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { CollapsibleContent } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
const { class: cls } = defineProps<{ class?: string }>()
</script>
<template>
<CollapsibleContent
:class="
cn(
'overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down',
cls
)
"
>
<slot />
</CollapsibleContent>
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
const { class: cls, showClose = true } = defineProps<{
class?: string
showClose?: boolean
}>()
const { t } = useI18n()
</script>
<template>
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-50 bg-black/60" />
<DialogContent
:class="
cn(
'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 border p-5 shadow-xl focus:outline-none',
cls
)
"
>
<slot />
<DialogClose
v-if="showClose"
class="rounded-agent text-agent-fg-subtle hover:bg-agent-surface-hover hover:text-agent-fg focus-visible:ring-agent-accent absolute top-3 right-3 flex size-7 items-center justify-center transition-colors focus-visible:ring-2 focus-visible:outline-none"
:aria-label="t('agent.close')"
>
<span class="icon-[lucide--x] size-4" />
</DialogClose>
</DialogContent>
</DialogPortal>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import {
ScrollAreaRoot,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport
} from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
const { class: cls, viewportClass } = defineProps<{
class?: string
viewportClass?: string
}>()
</script>
<template>
<ScrollAreaRoot :class="cn('relative overflow-hidden', cls)" type="hover">
<ScrollAreaViewport :class="cn('size-full', viewportClass)">
<slot />
</ScrollAreaViewport>
<ScrollAreaScrollbar
orientation="vertical"
class="flex w-2 touch-none p-0.5 transition-colors select-none"
>
<ScrollAreaThumb
class="bg-agent-border-strong relative flex-1 rounded-full"
/>
</ScrollAreaScrollbar>
</ScrollAreaRoot>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
const { class: cls } = defineProps<{ class?: string }>()
const model = defineModel<string>()
</script>
<template>
<textarea
v-model="model"
:class="
cn(
'rounded-agent text-agent-fg placeholder:text-agent-fg-subtle flex w-full resize-none bg-transparent px-3 py-2 text-sm focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
cls
)
"
/>
</template>

View File

@@ -0,0 +1,44 @@
// @vitest-environment jsdom
// Reka's Dialog teleports its content to document.body; jsdom serializes the portalled
// tree reliably, so this integration smoke test (that the primitive wiring mounts and
// opens) runs against jsdom rather than happy-dom.
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { DialogRoot, DialogTrigger } from 'reka-ui'
import { defineComponent, h } from 'vue'
import { describe, expect, it } from 'vitest'
import { i18n } from '@/i18n'
import Button from './Button.vue'
import DialogContent from './DialogContent.vue'
describe('ui primitives wiring', () => {
it('Button renders its slot and reacts to clicks', async () => {
let clicks = 0
render(
defineComponent(
() => () => h(Button, { onClick: () => (clicks += 1) }, () => 'Send')
)
)
await userEvent.click(screen.getByRole('button', { name: 'Send' }))
expect(clicks).toBe(1)
})
it('Dialog opens from its trigger and shows portalled content + close', async () => {
render(
defineComponent(
() => () =>
h(DialogRoot, null, () => [
h(DialogTrigger, null, () => 'Open'),
h(DialogContent, null, () => 'Body copy')
])
),
{ global: { plugins: [i18n] } }
)
expect(screen.queryByText('Body copy')).toBeNull()
await userEvent.click(screen.getByText('Open'))
expect(screen.getByText('Body copy')).toBeTruthy()
expect(screen.getByLabelText('Close')).toBeTruthy()
})
})

View File

@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from 'vitest'
import {
AGENT_PANEL_FLAG,
createPostHogFlagSource,
useAgentFeatureGate
} from './useAgentFeatureGate'
import type { AgentFlagSource, 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('useAgentFeatureGate', () => {
it('fails closed when the source reports disabled', () => {
const source: AgentFlagSource = { isEnabled: () => false }
const { enabled } = useAgentFeatureGate(source)
expect(enabled.value).toBe(false)
})
it('maps an unloaded posthog flag (undefined) to false', () => {
const { posthog } = fakePostHog(undefined)
const { enabled } = useAgentFeatureGate(createPostHogFlagSource(posthog))
expect(enabled.value).toBe(false)
})
it('flips on when flags load and the listener fires, then off on a later change', () => {
const { posthog, setFlag } = fakePostHog(undefined)
const { enabled } = useAgentFeatureGate(createPostHogFlagSource(posthog))
expect(enabled.value).toBe(false)
setFlag(true)
expect(enabled.value).toBe(true)
setFlag(false)
expect(enabled.value).toBe(false)
})
it('dispose stops further updates', () => {
const { posthog, setFlag } = fakePostHog(undefined)
const { enabled, dispose } = useAgentFeatureGate(
createPostHogFlagSource(posthog)
)
dispose()
setFlag(true)
expect(enabled.value).toBe(false)
})
it('tolerates a void-returning onFeatureFlags', () => {
const posthog: PostHogLike = {
isFeatureEnabled: () => true,
onFeatureFlags: vi.fn()
}
const { enabled, dispose } = useAgentFeatureGate(
createPostHogFlagSource(posthog)
)
expect(enabled.value).toBe(true)
expect(() => dispose()).not.toThrow()
})
it('createPostHogFlagSource 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)
})
})

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