Compare commits

..

89 Commits

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

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

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

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

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

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

Coverage hardening from review: entry-click resulting_state both ways,
close-button click attribution through the real store, message_sent
payloads (attachments and node tags), mention-pick positive plus
stages-nothing negative, null open_duration_ms, flag_disabled pin.
2026-07-10 11:01:22 -07:00
Nathaniel Parson Koroso
2c66b22392 test(agent): close the senior-qa coverage gaps
- ToolCallGroup: streaming auto-open, same-name merge, failure reopen,
  and the AgentMessage adjacent-merge branch with pluralized header.
- WorkflowTabs: the Ask Comfy Agent CTA renders when enabled and
  toggles the panel (mock rewired ref-backed for storeToRefs).
- useAgentSession: non-object and foreign host frames drop silently
  mid-turn.
- MarkdownStream: fence label picks the first info-string word.
2026-07-09 21:57:01 -07:00
Nathaniel Parson Koroso
7655e58d20 test(agent): pin one error per oversize attachment alongside the message 2026-07-09 21:26:16 -07:00
Nathaniel Parson Koroso
91e0ebeba0 fix(agent): restore namespaced collapsible animation; close review gaps
The trim deleted the collapsible keyframes as duplicates of
tw-animate-css, but that package defines no collapsible-* keys, so the
tool-disclosure animation silently no-opd. Restored under agent-*
names: the globally-imported sheet can no longer retarget the host's
animate-collapsible-* utilities (undefined on main) in sharing dialogs.
Adds the three review-flagged test gaps: indented code stays prose,
bare fences label as text, and the oversize-attachment message pins
its i18n interpolation.
2026-07-09 21:19:01 -07:00
Nathaniel Parson Koroso
f62d7af7c5 feat(agent): PostHog engagement metrics for the agent panel
Adds the V0 FE metric set through the existing telemetry layer (typed
events, optional provider methods, cloud PostHog provider):

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

The panel store owns open/close state, so it emits the state events with
an idempotency guard (the flag gate re-syncs close on every evaluation).
The entry/close click events are kept alongside the state events
deliberately: the V0 metrics checklist names them as PM-queryable
trackers, though the state events' sources make them derivable.

Also hardens autosaveAppliedDraft: the tracker re-baselines with the RAW
canvas serialization. A schema-validated baseline would diff against raw
captures under strict graphEqual and re-raise the conflict dialog on an
unedited canvas; a test pins validateComfyWorkflow to its single
loadDraft call so the baseline can never route through the schema.
2026-07-09 21:09:49 -07:00
Nathaniel Parson Koroso
8c8b78def4 refactor(agent): sweep orphaned locale keys, i18n attachment strings
- 17 locale keys with zero usages removed from en (other locales are
  lobe-i18n generated and never carried them); error-catalog and
  design-locked keys kept.
- useAttachment strings go through i18n; the 20MB label derives from
  the named constant (max_upload_size flag exists but has no production
  consumer, so the constant stays).
- A failed thread-list fetch now raises the shared error modal instead
  of silently showing empty history, with a regression test.
2026-07-09 21:02:04 -07:00
Nathaniel Parson Koroso
bddb1a22cb refactor(agent): delete dormant and dead code
- Safety UI that nothing drives (ApprovalCard, LockBanner, RevertButton
  and their AgentPanel plumbing); ConflictDialog stays.
- Dead message-part clusters: file/asset rendering and reasoning
  disclosure that no transport path constructs; the parts union and
  AgentMessage grouping shrink to text/tool/notice.
- Unwired composer pieces (AssetTray, SelectionActionChips,
  useCloudAssets) with zero non-test importers.
- Dead exports (createThread, history.upsert, useAgentFeatureGate fn,
  hasDraft, onboarding multi-step machinery) - each re-verified for
  zero production consumers on the current head; parseAgentWsEvent /
  isAgentEvent kept, they are live imports since the event rework.
- Write-only token pipeline, info/warning notice machinery, and the
  transport's no-op finalize loop removed; finalize/abort merged into
  one settle().
2026-07-09 20:37:53 -07:00
Nathaniel Parson Koroso
ac572f3937 refactor(agent): shared UI kit, api.fetchApi transport, drop shiki
- The agent REST client rides api.fetchApi (Firebase init-wait, auth
  headers, 401 remint, Comfy-User) instead of a hand-rolled fetch with
  workspace-token-only auth; zod parsing and AgentApiError stay.
- Agent-local Button/Textarea forks replaced by the shared UI kit with
  variant mappings verified pixel-equivalent through tailwind-merge;
  dialogs inline reka primitives (the shared kit's entrance animations
  and sizing cannot be neutralized). ScrollArea/CollapsibleContent
  wrappers inlined per the sibling idiom.
- WorkflowTabs CTA uses the shared Button.
- agentTheme.css no longer redefines tw-animate-css collapsible
  keyframes (they globally retargeted three sharing-dialog consumers).
- CodeBlock keeps the framed header + copy but renders plain monospace;
  the shiki dependency and catalog entry are removed. MarkdownStream
  tokenizes fences via marked.lexer, fixing the 4-backtick gap.
2026-07-09 20:14:45 -07:00
Nathaniel Parson Koroso
9e74c435a0 refactor(agent): reuse platform markdown + typed WS dispatch; fix host regressions
- MarkdownStream renders through the shared markdownRendererUtil; the
  agent copy (and its module-load DOMPurify hook that gave every host
  anchor target=_blank) is deleted.
- The agent event source is now a thin adapter over the api singleton's
  typed custom-event dispatch: no second socket follower, no re-parse of
  every /ws frame, no "Unknown message type" throw noise. Zod payload
  validation and the malformed-event notice stay.
- AgentPanelRoot loads via defineAsyncComponent, keeping the flag-gated
  subtree out of the GraphCanvas chunk.
- Prompt-error cards show details only for the agent's own error types;
  host cards restored to pre-PR behavior, both pinned by tests.
2026-07-09 19:38:12 -07:00
Nathaniel Parson Koroso
fe35a35d02 feat(agent): PostHog engagement metrics for the agent panel
Adds the V0 FE metric set through the existing telemetry layer (typed
events, optional provider methods, cloud PostHog provider):

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

@@ -73,8 +73,8 @@ jobs:
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
shardTotal: [16]
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8]
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -93,7 +93,7 @@ jobs:
# Run sharded tests (browsers pre-installed in container)
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
id: playwright
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
COLLECT_COVERAGE: 'true'
@@ -150,7 +150,7 @@ jobs:
# Run tests (browsers pre-installed in container)
- name: Run Playwright tests (${{ matrix.browser }})
id: playwright
run: pnpm exec playwright test --project=${{ matrix.browser }}
run: pnpm exec playwright test --project=${{ matrix.browser }} --reporter=blob
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report

View File

@@ -23,10 +23,6 @@ on:
required: false
default: 'Comfy-Org/ComfyUI'
type: string
target_branch:
description: 'Optional: force a specific release branch, e.g. core/1.47 or core/2.0. Overrides the pin-derived target — use to skip a dead minor or do an out-of-cadence / major release.'
required: false
type: string
jobs:
check-release-week:
@@ -53,15 +49,12 @@ jobs:
fi
- name: Summary
env:
TARGET_BRANCH_OVERRIDE: ${{ inputs.target_branch }}
run: |
echo "## Release Check" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- Is release week: ${{ steps.check.outputs.is_release_week }}" >> $GITHUB_STEP_SUMMARY
echo "- Manual trigger: ${{ github.event_name == 'workflow_dispatch' }}" >> $GITHUB_STEP_SUMMARY
echo "- Release type: ${{ inputs.release_type || 'minor (scheduled)' }}" >> $GITHUB_STEP_SUMMARY
echo "- Target branch override: ${TARGET_BRANCH_OVERRIDE:-(none — pin-derived)}" >> $GITHUB_STEP_SUMMARY
resolve-version:
needs: check-release-week
@@ -110,7 +103,6 @@ jobs:
working-directory: frontend
env:
RELEASE_TYPE: ${{ inputs.release_type || 'minor' }}
TARGET_BRANCH: ${{ inputs.target_branch }}
run: |
set -euo pipefail

View File

@@ -1,51 +0,0 @@
import { expect } from '@playwright/test'
import { BRAND_ASSETS_ZIP, BRAND_GUIDELINES_PDF } from '../src/data/brandAssets'
import { test } from './fixtures/blockExternalMedia'
test.describe('Brand portal @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/brand')
})
test('renders each brand guideline section', async ({ page }) => {
await expect(
page.getByRole('heading', { level: 1, name: 'Create with ComfyUI' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'One mark, many dimensions.' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Every color earns its place.' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Precise, never cute.' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Trademark guidelines.' })
).toBeVisible()
})
test('shows all four marks', async ({ page }) => {
const logos = page.locator('#logos')
for (const name of [
'Core Logo',
'Logomark',
'Icon',
'Amplified Logomark'
]) {
await expect(logos.getByText(name, { exact: true })).toBeVisible()
}
})
test('the hero ctas open the gated guidelines and the logo bundle', async ({
page
}) => {
await expect(
page.getByRole('link', { name: 'View brand guidelines' })
).toHaveAttribute('href', BRAND_GUIDELINES_PDF)
await expect(
page.getByRole('link', { name: 'Download logos' })
).toHaveAttribute('href', BRAND_ASSETS_ZIP)
})
})

View File

@@ -1,112 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const MCP_ENDPOINT = 'https://cloud.comfy.org/mcp'
test.describe('MCP page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/mcp')
})
test('hero and how-it-works INSTALL MCP CTAs anchor to setup', async ({
page
}) => {
const installLinks = page.getByRole('link', { name: 'INSTALL MCP' })
await expect(installLinks).toHaveCount(2)
for (const link of await installLinks.all()) {
await expect(link).toHaveAttribute('href', '#setup')
}
})
test('setup section shows both install options', async ({ page }) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(
setup.getByRole('heading', {
name: 'Ask your agent to install Comfy MCP'
})
).toBeVisible()
await expect(
setup.getByRole('heading', { name: 'Install manually' })
).toBeVisible()
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
})
test('client tabs swap install instructions', async ({ page }) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
const activePanel = setup.locator('[role="tabpanel"][data-state="active"]')
// Claude Code is the default tab and carries the CLI command
await expect(
setup.getByRole('tab', { name: 'Claude Code' })
).toHaveAttribute('data-state', 'active')
await expect(activePanel).toContainText(
`claude mcp add --transport http comfy-cloud ${MCP_ENDPOINT}`
)
await setup.getByRole('tab', { name: 'Claude Desktop' }).click()
await expect(activePanel).toContainText('Add custom connector')
await setup.getByRole('tab', { name: 'Cursor' }).click()
await expect(activePanel).toContainText('X-API-Key')
await expect(
activePanel.getByRole('link', { name: 'platform.comfy.org' })
).toHaveAttribute('href', 'https://platform.comfy.org/profile/api-keys')
await setup.getByRole('tab', { name: 'Codex' }).click()
await expect(activePanel).toContainText(
`codex mcp add comfy-cloud --url ${MCP_ENDPOINT}`
)
})
test('skills plugin link lives in the agent option card', async ({
page
}) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(
setup.getByRole('link', { name: 'View on GitHub' })
).toHaveAttribute('href', 'https://github.com/Comfy-Org/comfy-skills')
})
test('capabilities section shows all six tool cards', async ({ page }) => {
for (const title of [
'Generate anything',
'Search the ecosystem',
'Run real workflows',
'Direct any model',
'Generate in batches',
'Ship it as an app'
]) {
await expect(
page.getByRole('heading', { name: title, exact: true })
).toBeVisible()
}
})
test('FAQ lists nine questions and autolinks the server URL', async ({
page
}) => {
const triggers = page.locator('[id^="faq-trigger-"]')
await triggers.first().scrollIntoViewIfNeeded()
await expect(triggers).toHaveCount(9)
await page.getByRole('button', { name: "What's the server URL?" }).click()
await expect(
page.getByRole('link', { name: MCP_ENDPOINT, exact: true })
).toHaveAttribute('href', MCP_ENDPOINT)
})
})
test.describe('MCP page zh-CN @smoke', () => {
test('setup section renders localized options', async ({ page }) => {
await page.goto('/zh-CN/mcp')
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(setup.getByText('方式一')).toBeVisible()
await expect(setup.getByRole('heading', { name: '手动安装' })).toBeVisible()
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
})
})

View File

@@ -1,4 +0,0 @@
<svg width="275" height="275" viewBox="0 0 275 275" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="274.66" height="274.66" rx="63.5555" fill="#211927"/>
<path d="M177.456 174.409C177.713 173.538 177.854 172.621 177.854 171.656C177.854 166.313 173.546 161.983 168.232 161.983H125.108C122.791 162.006 120.894 160.124 120.894 157.794C120.894 157.37 120.965 156.97 121.058 156.594L132.67 115.926C133.162 114.137 134.801 112.819 136.72 112.819L180.008 112.772C189.138 112.772 196.84 106.582 199.158 98.1335L205.666 75.4696C205.877 74.6695 205.994 73.7987 205.994 72.9279C205.994 67.6091 201.71 63.3022 196.419 63.3022H144.048C134.965 63.3022 127.286 69.4448 124.921 77.7996L120.52 93.2618C120.005 95.0269 118.389 96.3213 116.47 96.3213H103.898C94.8846 96.3213 87.276 102.346 84.8412 110.607L69.0152 166.172C68.7811 166.996 68.6641 167.89 68.6641 168.784C68.6641 174.127 72.9717 178.457 78.2861 178.457H90.6472C92.9649 178.457 94.8612 180.34 94.8612 182.693C94.8612 183.093 94.8144 183.494 94.6973 183.87L90.3194 199.191C90.1087 200.015 89.9683 200.862 89.9683 201.733C89.9683 207.052 94.2525 211.359 99.5434 211.359L151.938 211.312C161.045 211.312 168.724 205.145 171.065 196.744L177.432 174.433L177.456 174.409Z" fill="#F2FF59"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed, reactive, watch } from 'vue'
import { reactive, watch } from 'vue'
type Faq = { id: string; question: string; answer: string }
@@ -9,31 +9,6 @@ const { faqs } = defineProps<{
faqs: readonly Faq[]
}>()
type AnswerPart = { type: 'text' | 'link'; value: string }
function parseAnswer(answer: string): AnswerPart[] {
const urlPattern = /https?:\/\/[\w\-./?=&#%~:@+,;]+/g
const parts: AnswerPart[] = []
let lastIndex = 0
for (const match of answer.matchAll(urlPattern)) {
const start = match.index ?? 0
const url = match[0].replace(/[.,;:]+$/, '')
if (start > lastIndex) {
parts.push({ type: 'text', value: answer.slice(lastIndex, start) })
}
parts.push({ type: 'link', value: url })
lastIndex = start + url.length
}
if (lastIndex < answer.length) {
parts.push({ type: 'text', value: answer.slice(lastIndex) })
}
return parts
}
const parsedFaqs = computed(() =>
faqs.map((faq) => ({ ...faq, answerParts: parseAnswer(faq.answer) }))
)
const expanded = reactive<boolean[]>(faqs.map(() => false))
watch(
@@ -65,7 +40,7 @@ function toggle(index: number) {
<!-- Right FAQ list -->
<div class="flex-1">
<div
v-for="(faq, index) in parsedFaqs"
v-for="(faq, index) in faqs"
:key="faq.id"
class="border-b border-primary-comfy-canvas/20"
>
@@ -108,23 +83,8 @@ function toggle(index: number) {
:aria-labelledby="`faq-trigger-${faq.id}`"
class="pb-6"
>
<p
class="text-sm wrap-break-word whitespace-pre-line text-primary-comfy-canvas/70"
>
<template
v-for="(part, partIndex) in faq.answerParts"
:key="partIndex"
>
<a
v-if="part.type === 'link'"
:href="part.value"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 rounded-sm underline underline-offset-2 transition-opacity hover:opacity-70 focus-visible:ring-2 focus-visible:outline-none"
>{{ part.value }}</a
>
<template v-else>{{ part.value }}</template>
</template>
<p class="text-sm whitespace-pre-line text-primary-comfy-canvas/70">
{{ faq.answer }}
</p>
</section>
</div>

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { Component } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import CopyableField from '@/components/ui/copyable-field/CopyableField.vue'
import SectionHeader from '../common/SectionHeader.vue'
type CardAction =
| {
type: 'link'
label: string
href: string
target?: '_blank'
icon?: Component
variant?: 'default' | 'outline'
}
| { type: 'code'; value: string }
export interface FeatureCard {
id: string
label?: string
title: string
description: string
action?: CardAction
}
type ColumnCount = 2 | 3 | 4
const {
cards,
columns = 3,
copiedLabel,
copyLabel,
eyebrow,
heading,
subtitle
} = defineProps<{
cards: readonly FeatureCard[]
columns?: ColumnCount
copiedLabel?: string
copyLabel?: string
eyebrow?: string
heading: string
subtitle?: string
}>()
const columnClass: Record<ColumnCount, string> = {
2: 'lg:grid-cols-2',
3: 'lg:grid-cols-3',
4: 'lg:grid-cols-4'
}
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader max-width="xl" :label="eyebrow" align="start">
{{ heading }}
<template v-if="subtitle" #subtitle>
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
{{ subtitle }}
</p>
</template>
</SectionHeader>
<div :class="cn('mt-16 grid grid-cols-1 gap-6', columnClass[columns])">
<div
v-for="card in cards"
:key="card.id"
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
>
<p
v-if="card.label"
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ card.label }}
</p>
<h3
:class="
cn(
'text-xl font-light text-primary-comfy-canvas lg:text-2xl',
card.label && 'mt-3'
)
"
>
{{ card.title }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ card.description }}
</p>
<div v-if="card.action" class="mt-6">
<Button
v-if="card.action.type === 'link'"
as="a"
:href="card.action.href"
:target="card.action.target"
:rel="
card.action.target === '_blank'
? 'noopener noreferrer'
: undefined
"
:variant="card.action.variant ?? 'outline'"
:append-icon="card.action.icon"
>
{{ card.action.label }}
</Button>
<CopyableField
v-else
:value="card.action.value"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
</div>
</div>
</section>
</template>

View File

@@ -8,7 +8,7 @@ import VideoPlayer from '../common/VideoPlayer.vue'
import type { VideoTrack } from '../common/VideoPlayer.vue'
type RowMedia =
| { type: 'image'; src: string; alt?: string; fit?: 'cover' | 'contain' }
| { type: 'image'; src: string; alt?: string }
| {
type: 'video'
src: string
@@ -20,7 +20,6 @@ type RowMedia =
loop?: boolean
minimal?: boolean
hideControls?: boolean
fit?: 'cover' | 'contain'
}
export interface FeatureRow {
@@ -59,7 +58,7 @@ const {
<div
:class="
cn(
'order-2 flex flex-col justify-center gap-4 p-6 lg:flex-1 lg:p-12',
'order-2 flex flex-col justify-center gap-4 p-6 lg:w-1/2 lg:p-12',
i % 2 === 0 ? 'lg:order-1' : 'lg:order-2'
)
"
@@ -73,11 +72,10 @@ const {
</div>
<!-- Media: image or video -->
<!-- 620/364 and w-155 (620px) match the card media asset dimensions -->
<div
:class="
cn(
'relative order-1 aspect-620/364 w-full lg:w-155 lg:shrink-0',
'order-1 flex lg:w-1/2',
i % 2 === 0 ? 'lg:order-2' : 'lg:order-1'
)
"
@@ -88,12 +86,7 @@ const {
:alt="row.media.alt ?? row.title"
loading="lazy"
decoding="async"
:class="
cn(
'absolute inset-0 size-full rounded-4xl',
row.media.fit === 'contain' ? 'object-contain' : 'object-cover'
)
"
class="aspect-4/3 w-full rounded-4xl object-cover"
/>
<VideoPlayer
v-else
@@ -106,13 +99,7 @@ const {
:loop="row.media.loop"
:minimal="row.media.minimal"
:hide-controls="row.media.hideControls"
:fit="row.media.fit"
:class="
cn(
'absolute inset-0 size-full',
row.media.fit === 'contain' && 'bg-transparent'
)
"
class="w-full"
/>
</div>
</GlassCard>

View File

@@ -85,7 +85,6 @@ const companyColumn: { title: string; links: FooterLink[] } = {
links: [
{ label: t('footer.about', locale), href: routes.about },
{ label: t('nav.careers', locale), href: routes.careers },
{ label: t('nav.brand', locale), href: routes.brand },
{ label: t('footer.termsOfService', locale), href: routes.termsOfService },
{ label: t('footer.enterpriseMsa', locale), href: routes.enterpriseMsa },
{ label: t('footer.privacyPolicy', locale), href: routes.privacyPolicy }
@@ -176,7 +175,10 @@ const contactColumn: { title: string; links: FooterLink[] } = {
</div>
<!-- Logo -->
<canvas ref="canvasRef" class="pointer-events-none size-52 lg:mt-28" />
<canvas
ref="canvasRef"
class="pointer-events-none size-52 opacity-80 lg:mt-28"
/>
</div>
</footer>
</template>

View File

@@ -10,7 +10,6 @@ import {
whenever
} from '@vueuse/core'
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
import type { HTMLAttributes } from 'vue'
import { t } from '../../i18n/translations'
import type { Locale } from '../../i18n/translations'
@@ -31,9 +30,7 @@ const {
autoplay = false,
loop = false,
minimal = false,
hideControls = false,
fit = 'cover',
class: className
hideControls = false
} = defineProps<{
locale?: Locale
src?: string
@@ -43,8 +40,6 @@ const {
loop?: boolean
minimal?: boolean
hideControls?: boolean
fit?: 'cover' | 'contain'
class?: HTMLAttributes['class']
}>()
const playerEl = useTemplateRef<HTMLDivElement>('playerEl')
@@ -194,12 +189,7 @@ function toggleFullscreen() {
<template>
<div
ref="playerEl"
:class="
cn(
'relative aspect-video overflow-hidden rounded-4xl border border-white/10 bg-black',
className
)
"
class="relative aspect-video overflow-hidden rounded-4xl border border-white/10 bg-black"
@pointermove="showControls"
@pointerdown="showControls"
@focusin="showControls"
@@ -207,9 +197,7 @@ function toggleFullscreen() {
<video
v-if="src"
ref="videoEl"
:class="
cn('size-full', fit === 'contain' ? 'object-contain' : 'object-cover')
"
class="size-full object-cover"
:src
:poster
:preload="autoplay ? 'auto' : 'metadata'"

View File

@@ -20,8 +20,6 @@ export const buttonVariants = cva(
link: "text-primary-comfy-yellow h-auto justify-start px-0 py-1 text-base uppercase hover:opacity-90 [&_svg:not([class*='size-'])]:size-6",
underlineLink:
"text-primary-comfy-yellow relative h-auto justify-start px-0 py-1 uppercase after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-current after:transition-transform after:duration-200 hover:opacity-90 hover:after:scale-x-100 [&_svg:not([class*='size-'])]:size-6",
inline:
'text-primary-comfy-yellow inline h-auto rounded-none p-0 align-baseline text-sm font-normal tracking-normal whitespace-normal hover:opacity-90 [&>span]:top-0 [&>span]:underline',
nav: 'text-primary-warm-white hover:text-primary-comfy-yellow h-auto justify-between px-0 py-1 text-start text-2xl font-medium',
navMuted:
'hover:text-primary-comfy-yellow h-auto w-full justify-between px-0 py-1 text-start text-2xl font-medium text-primary-comfy-canvas uppercase'

View File

@@ -21,8 +21,7 @@ const baseRoutes = {
affiliateTerms: '/affiliates/terms',
contact: '/contact',
models: '/p/supported-models',
mcp: '/mcp',
brand: '/brand'
mcp: '/mcp'
} as const
type Routes = typeof baseRoutes
@@ -88,7 +87,6 @@ export const externalLinks = {
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
instagram: 'https://www.instagram.com/comfyui/',
linkedin: 'https://www.linkedin.com/company/comfyui',
mcpEndpoint: 'https://cloud.comfy.org/mcp',
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',

View File

@@ -1,7 +1,5 @@
import type { LocalizedText } from '../i18n/translations'
import { BRAND_ASSETS_ZIP } from './brandAssets'
interface AffiliateBrandAsset {
id: string
title: LocalizedText
@@ -9,6 +7,9 @@ interface AffiliateBrandAsset {
preview: string
}
const BRAND_ASSETS_ZIP =
'https://media.comfy.org/website/comfy-org-brand-assets.zip'
export const affiliateBrandAssets: readonly AffiliateBrandAsset[] = [
{
id: 'core-logo',

View File

@@ -1,9 +0,0 @@
// Shared brand download URLs served from the media bucket, used by both the
// affiliate page and the brand portal.
export const BRAND_ASSETS_ZIP =
'https://media.comfy.org/website/comfy-org-brand-assets.zip'
// Brand guidelines live in Google Drive, shared to Comfy Org only, so Google
// enforces the comfy.org sign-in. Opened in a new tab rather than downloaded.
export const BRAND_GUIDELINES_PDF =
'https://drive.google.com/file/d/1EDt03JTfF_nbbY_H2n67aaUj6k11v3bS/view'

View File

@@ -1,91 +0,0 @@
interface BrandColor {
name: string
hex: string
rgb: string
hsl: string
cmyk: string
swatchClass: string
textClass: string
wide?: boolean
border?: boolean
}
export const brandColors: readonly BrandColor[] = [
{
name: 'Comfy Yellow',
hex: '#F2FF59',
rgb: '242, 255, 89',
hsl: '65, 100, 67',
cmyk: '5, 0, 65, 0',
swatchClass: 'bg-primary-comfy-yellow',
textClass: 'text-primary-comfy-ink',
wide: true
},
{
name: 'Comfy Ink',
hex: '#211927',
rgb: '33, 25, 39',
hsl: '274, 22, 13',
cmyk: '15, 36, 0, 85',
swatchClass: 'bg-primary-comfy-ink',
textClass: 'text-primary-warm-white',
border: true
},
{
name: 'Comfy Canvas',
hex: '#C2BFB9',
rgb: '194, 191, 185',
hsl: '40, 7, 74',
cmyk: '0, 2, 5, 24',
swatchClass: 'bg-primary-comfy-canvas',
textClass: 'text-primary-comfy-ink'
},
{
name: 'Comfy Plum',
hex: '#49378B',
rgb: '73, 55, 139',
hsl: '253, 43, 38',
cmyk: '47, 60, 0, 45',
swatchClass: 'bg-primary-comfy-plum',
textClass: 'text-primary-comfy-canvas'
},
{
name: 'Warm White',
hex: '#F0EFED',
rgb: '240, 239, 237',
hsl: '40, 9, 94',
cmyk: '0, 0, 1, 6',
swatchClass: 'bg-primary-warm-white',
textClass: 'text-primary-comfy-ink',
wide: true
},
{
name: 'Warm Gray',
hex: '#7E7C78',
rgb: '126, 124, 120',
hsl: '40, 2, 48',
cmyk: '0, 2, 5, 51',
swatchClass: 'bg-primary-warm-gray',
textClass: 'text-primary-warm-white',
border: true
},
{
name: 'Cool Gray',
hex: '#3C3C3C',
rgb: '60, 60, 60',
hsl: '0, 0, 24',
cmyk: '0, 0, 0, 76',
swatchClass: 'bg-secondary-cool-gray',
textClass: 'text-primary-warm-white',
border: true
},
{
name: 'Mauve',
hex: '#4D3762',
rgb: '77, 55, 98',
hsl: '271, 28, 30',
cmyk: '21, 44, 0, 62',
swatchClass: 'bg-secondary-mauve',
textClass: 'text-primary-warm-white'
}
] as const

View File

@@ -1864,26 +1864,10 @@ const translations = {
'zh-CN':
'Comfy MCP 通过模型上下文协议暴露完整的 ComfyUI 引擎——让你的助手能够接入生态系统、构建工作流,并生成图像、视频、音频或 3D 内容。'
},
'mcp.hero.demoPromptMoodboard': {
en: 'turn the brief in this email into a 6-up moodboard',
'zh-CN': '把这封邮件里的需求做成六宫格情绪板'
},
'mcp.hero.demoPromptConcepts': {
en: 'sketch three concept frames for the launch page',
'zh-CN': '为发布页画三张概念稿'
},
'mcp.hero.demoPromptKeyart': {
'mcp.hero.demoPrompt': {
en: "match this frame's palette, make the hero key art",
'zh-CN': '匹配这一帧的配色,生成主视觉关键画面'
},
'mcp.hero.demoPromptPbr': {
en: 'make a tileable asphalt PBR material, all 5 maps',
'zh-CN': '生成可平铺的沥青 PBR 材质,共 5 张贴图'
},
'mcp.hero.demoPromptUpscale': {
en: 'upscale the neon kaiju shot to 4K',
'zh-CN': '把霓虹怪兽画面放大到 4K'
},
'mcp.hero.viewDocs': {
en: 'VIEW DOCS',
'zh-CN': '查看文档'
@@ -1892,6 +1876,10 @@ const translations = {
en: 'INSTALL MCP',
'zh-CN': '安装 MCP'
},
'mcp.hero.runWorkflow': {
en: 'RUN A WORKFLOW',
'zh-CN': '运行工作流'
},
'mcp.hero.demoGenerate': {
en: 'GENERATE',
'zh-CN': '生成'
@@ -1909,90 +1897,60 @@ const translations = {
'zh-CN': '放大图像'
},
// MCP SetupSection
// MCP SetupStepsSection
'mcp.setup.label': {
en: 'GET STARTED',
'zh-CN': '快速开始'
},
'mcp.setup.heading': {
en: 'Set up Comfy MCP',
'zh-CN': '配置 Comfy MCP'
en: 'Set up Comfy MCP in three steps',
'zh-CN': '三步完成 Comfy MCP 配置'
},
'mcp.setup.subtitle': {
en: 'Two ways to connect: ask your agent to install it, or add the server yourself. Sign in once, and the full ComfyUI toolset is available right in your chat.',
en: 'Add Comfy Cloud as a custom connector in Claude, Cursor, Codex, or any MCP-compatible client. Sign in once, and the full ComfyUI toolset is available right in your chat.',
'zh-CN':
'两种接入方式:让你的智能体自动安装,或自行添加服务器。登录一次ComfyUI 全套工具即可直接在对话中使用。'
'将 Comfy Cloud 添加为 Claude、Cursor、Codex 或任意兼容 MCP 客户端的自定义连接器。登录一次ComfyUI 全套工具即可直接在对话中使用。'
},
'mcp.setup.option1.label': { en: 'OPTION 1', 'zh-CN': '方式一' },
'mcp.setup.option1.title': {
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
'mcp.setup.step1.title': {
en: 'Ask your agent to install Comfy MCP',
'zh-CN': '让你的智能体安装 Comfy MCP'
},
'mcp.setup.option1.command': {
'mcp.setup.step1.command': {
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
},
'mcp.setup.option1.description': {
'mcp.setup.step1.description': {
en: 'Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
'zh-CN':
'将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
},
'mcp.setup.option2.label': { en: 'OPTION 2', 'zh-CN': '方式二' },
'mcp.setup.option2.title': {
en: 'Install manually',
'zh-CN': '手动安装'
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
'mcp.setup.step2.title': {
en: 'Or add it by hand',
'zh-CN': '手动添加'
},
'mcp.setup.option2.description': {
en: 'Prefer manual setup? Add this URL as a custom connector or remote MCP server in your client, then sign in when prompted.',
'mcp.setup.step2.description': {
en: 'Prefer manual setup? Add Comfy Cloud as a custom connector with the MCP URL. The docs cover every client.',
'zh-CN':
'想手动配置?将此 URL 添加为客户端的自定义连接器或远程 MCP 服务器,然后按提示登录。'
'想手动配置?用 MCP URL 将 Comfy Cloud 添加为自定义连接器。文档涵盖各类客户端。'
},
'mcp.setup.option2.tabsLabel': {
en: 'Pick your client',
'zh-CN': '选择你的客户端'
'mcp.setup.step2.cta': {
en: 'COMFY CLOUD MCP DOCS',
'zh-CN': 'COMFY CLOUD MCP 文档'
},
'mcp.setup.clients.claudeCode.step': {
en: 'Run this in your terminal, then use /mcp to pick comfy-cloud and authenticate.',
'zh-CN': '在终端运行以下命令,然后通过 /mcp 选择 comfy-cloud 并完成认证。'
'mcp.setup.step3.label': { en: 'STEP 3', 'zh-CN': '第 3 步' },
'mcp.setup.step3.title': {
en: 'Connect and sign in',
'zh-CN': '连接并登录'
},
'mcp.setup.clients.claudeDesktop.step': {
en: 'Click Customize in the sidebar, open Connectors, choose Add custom connector, paste the URL above, and sign in.',
'zh-CN':
'点击侧边栏的 Customize进入 Connectors选择添加自定义连接器粘贴上方 URL 并登录。'
'mcp.setup.step3.description': {
en: 'Click Connect, sign in, and every Comfy Cloud skill is ready in your client.',
'zh-CN': '点击"连接"并登录,所有 Comfy Cloud 技能即可在你的客户端中使用。'
},
'mcp.setup.clients.cursor.step': {
en: 'Add the URL above to ~/.cursor/mcp.json with an X-API-Key header. Create your key at ',
'zh-CN':
'将上方 URL 添加到 ~/.cursor/mcp.json并附带 X-API-Key 请求头。在此创建密钥:'
},
'mcp.setup.clients.cursor.linkLabel': {
en: 'platform.comfy.org',
'zh-CN': 'platform.comfy.org'
},
'mcp.setup.clients.codex.step': {
en: 'Run this in your terminal, then codex mcp login comfy-cloud to sign in.',
'zh-CN': '在终端运行以下命令,然后执行 codex mcp login comfy-cloud 登录。'
},
'mcp.setup.clients.other.name': {
en: 'Other clients',
'zh-CN': '其他客户端'
},
'mcp.setup.clients.other.step': {
en: 'Add the URL above as a remote MCP server. No OAuth in your client? Use an X-API-Key header instead. Full walkthroughs live in the ',
'zh-CN':
'将上方 URL 添加为远程 MCP 服务器。客户端不支持 OAuth改用 X-API-Key 请求头。完整教程见'
},
'mcp.setup.clients.other.linkLabel': {
en: 'setup docs',
'zh-CN': '设置文档'
},
'mcp.setup.skillsNote': {
en: 'Using Claude Code? The Comfy skills plugin adds ready-made slash commands. ',
'zh-CN': '在用 Claude CodeComfy 技能插件提供现成的斜杠命令。'
},
'mcp.setup.skillsLink': {
en: 'View on GitHub',
'zh-CN': '在 GitHub 上查看'
'mcp.setup.step3.cta': {
en: 'COMFY CLOUD SKILLS',
'zh-CN': 'COMFY CLOUD 技能'
},
// MCP WhyBuildSection
@@ -2013,9 +1971,9 @@ const translations = {
'zh-CN': '开放协议,\n任意客户端。'
},
'mcp.why.1.description': {
en: 'MCP is an open standard, so any MCP-compatible client can connect. Claude Code, Claude Desktop, and Codex sign in with OAuth; every other agent connects with an API key.',
en: 'MCP is an open standard, so any MCP-compatible client can connect. Today Comfy supports Claude Code and Claude Desktop, with more clients coming.',
'zh-CN':
'MCP 是开放标准,因此任何兼容 MCP 的客户端都能接入。Claude CodeClaude Desktop 和 Codex 通过 OAuth 登录,其他智能体使用 API 密钥连接。'
'MCP 是开放标准,因此任何兼容 MCP 的客户端都能接入。目前 Comfy 支持 Claude CodeClaude Desktop,更多客户端即将推出。'
},
'mcp.why.2.title': {
en: 'The full engine,\nnot a sandbox.',
@@ -2079,53 +2037,14 @@ const translations = {
'zh-CN': '运行真实工作流'
},
'mcp.tools.3.description': {
en: 'Submit graphs, track jobs, and pull outputs back. Save and share workflows, reuse a saved one, or open any run on the ComfyUI canvas — the full engine, driven by tool calls.',
en: 'Turn any ComfyUI workflow into a callable tool. The full power of the engine, driven by your agent.',
'zh-CN':
'提交计算图、跟踪任务并取回输出。保存和分享工作流,复用已保存的工作流,或在 ComfyUI 画布上打开任意运行——完整的引擎,由工具调用驱动。'
'将任何 ComfyUI 工作流转换为可调用的工具。由你的智能体驱动完整的引擎能力。'
},
'mcp.tools.3.alt': {
en: 'Comfy MCP running a ComfyUI workflow as a callable tool from a chat',
'zh-CN': 'Comfy MCP 在对话中将 ComfyUI 工作流作为可调用工具运行'
},
'mcp.tools.4.title': {
en: 'Direct any model',
'zh-CN': '直接调用任意模型'
},
'mcp.tools.4.description': {
en: 'Kling, Veo, Seedance, Flux, GPT-Image, Nano Banana, and ElevenLabs. Closed partner APIs and open-source models, reached through one set of tools.',
'zh-CN':
'Kling、Veo、Seedance、Flux、GPT-Image、Nano Banana 和 ElevenLabs。封闭的合作伙伴 API 与开源模型,通过同一套工具即可调用。'
},
'mcp.tools.4.alt': {
en: 'Comfy MCP directing closed partner APIs and open-source models through one set of tools',
'zh-CN': 'Comfy MCP 通过同一套工具调用封闭合作伙伴 API 和开源模型'
},
'mcp.tools.5.title': {
en: 'Generate in batches',
'zh-CN': '批量生成'
},
'mcp.tools.5.description': {
en: 'Stack a batch on the Queue, track it, and pull back every output. Dozens of runs from a single call.',
'zh-CN':
'将一批任务加入队列,跟踪进度,并取回每一个输出。一次调用即可完成数十次运行。'
},
'mcp.tools.5.alt': {
en: 'Comfy MCP stacking a batch on the Queue and pulling back every output',
'zh-CN': 'Comfy MCP 将一批任务加入队列并取回每个输出'
},
'mcp.tools.6.title': {
en: 'Ship it as an app',
'zh-CN': '作为应用发布'
},
'mcp.tools.6.description': {
en: 'Turn any workflow into an app with a shareable URL. Collaborators run it in the browser — only the inputs you expose, nothing to install.',
'zh-CN':
'将任意工作流变成带可分享链接的应用。协作者在浏览器中运行——只暴露你开放的输入,无需安装任何东西。'
},
'mcp.tools.6.alt': {
en: 'Comfy MCP turning a workflow into a shareable browser app',
'zh-CN': 'Comfy MCP 将工作流变成可在浏览器中分享的应用'
},
// MCP HowItWorksSection
'mcp.howItWorks.heading': {
@@ -2172,81 +2091,71 @@ const translations = {
'zh-CN': '支持哪些客户端?'
},
'mcp.faq.1.a': {
en: "For Claude Code, Claude Desktop, or Codex, add https://cloud.comfy.org/mcp as a custom connector or remote MCP server in any client, then sign in when prompted.\nFor clients that don't support OAuth, connect with a Comfy API key. Send the docs https://docs.comfy.org/agent-tools/cloud to your agent and it will figure out the installation for you.",
en: 'Claude Code and Claude Desktop today, both signing in with OAuth. Support for more clients is coming.',
'zh-CN':
'对于 Claude CodeClaude Desktop 或 Codex在任意客户端中将 https://cloud.comfy.org/mcp 添加为自定义连接器或远程 MCP 服务器,然后在提示时登录。\n对于不支持 OAuth 的客户端,请使用 Comfy API 密钥连接。将文档 https://docs.comfy.org/agent-tools/cloud 发送给你的智能体,它会为你完成安装。'
'目前支持 Claude CodeClaude Desktop,均通过 OAuth 登录。更多客户端的支持即将推出。'
},
'mcp.faq.2.q': {
en: "What's the server URL?",
'zh-CN': '服务器 URL 是什么?'
},
'mcp.faq.2.a': {
en: 'https://cloud.comfy.org/mcp — add it as a custom connector or remote MCP server in any client, then sign in when prompted.',
'zh-CN':
'https://cloud.comfy.org/mcp——在任意客户端中将它添加为自定义连接器或远程 MCP 服务器,然后在提示时登录。'
},
'mcp.faq.3.q': {
en: 'Do I need an API key?',
'zh-CN': '我需要 API 密钥吗?'
},
'mcp.faq.3.a': {
en: 'Not for Claude Code, Claude Desktop, or Codex. You need a Comfy API key for Cursor, Hermes, and OpenClaw for now. Just copy https://docs.comfy.org/agent-tools/cloud and your agent will figure out the installation for you.',
'mcp.faq.2.a': {
en: 'Not for Claude Code or Claude Desktop. They use OAuth. An API key is only needed for headless or CI setups with no browser.',
'zh-CN':
'Claude CodeClaude Desktop 和 Codex 不需要。Cursor、Hermes 和 OpenClaw 目前需要 Comfy API 密钥。只需复制 https://docs.comfy.org/agent-tools/cloud你的智能体就会为你完成安装。'
'Claude CodeClaude Desktop 不需要,它们使用 OAuth。仅在没有浏览器的无头或 CI 环境中才需要 API 密钥。'
},
'mcp.faq.3.q': {
en: 'Do the slash commands work in Claude Desktop?',
'zh-CN': '斜杠命令在 Claude Desktop 中可以使用吗?'
},
'mcp.faq.3.a': {
en: 'No. They ship in the Claude Code plugin. Desktop connects to the same MCP server, so the tools work; just ask in plain language.',
'zh-CN':
'不可以。斜杠命令包含在 Claude Code 插件中。Claude Desktop 连接的是同一个 MCP 服务器,因此工具可以正常使用;直接用自然语言提问即可。'
},
'mcp.faq.4.q': {
en: 'Does it cost anything?',
'zh-CN': '需要付费吗?'
en: "The sign-in didn't open a browser.",
'zh-CN': '登录时没有打开浏览器。'
},
'mcp.faq.4.a': {
en: "Connecting is free with a Comfy account, and searching models, nodes, and templates doesn't cost credits. Running a generation uses Comfy Cloud credits and needs a subscription or credit balance. Your agent confirms with you before it spends.",
en: 'In Claude Code, run /mcp, select comfy-cloud, and choose Authenticate. In Claude Desktop, reopen the connector from Customize → Connectors.',
'zh-CN':
'使用 Comfy 账户连接是免费的,搜索模型、节点和模板也不消耗积分。运行生成会使用 Comfy Cloud 积分,需要订阅或积分余额。智能体在消费前会先与你确认。'
'在 Claude Code 中,运行 /mcp选择 comfy-cloud然后选择 Authenticate授权。在 Claude Desktop 中,从“自定义 → 连接器”重新打开该连接器。'
},
'mcp.faq.5.q': {
en: 'Can I use it with my local ComfyUI?',
'zh-CN': '可以配合我的本地 ComfyUI 使用吗'
en: 'How do I connect in Claude Code?',
'zh-CN': '如何在 Claude Code 中连接'
},
'mcp.faq.5.a': {
en: 'Coming soon. Today, to drive a local ComfyUI, you can use comfy-cli: https://github.com/Comfy-Org/comfy-cli',
en: 'Add the marketplace and install the comfy-cloud plugin, then run /mcp → comfy-cloud → Authenticate. It adds the connection and slash commands in one step.',
'zh-CN':
'即将推出。目前,若要操作本地 ComfyUI你可以使用 comfy-clihttps://github.com/Comfy-Org/comfy-cli'
'添加插件市场并安装 comfy-cloud 插件,然后运行 /mcp → comfy-cloud → Authenticate授权。一步即可添加连接和斜杠命令。'
},
'mcp.faq.6.q': {
en: "What's the server URL for Claude Desktop?",
'zh-CN': 'Claude Desktop 的服务器 URL 是什么?'
},
'mcp.faq.6.a': {
en: 'Add a custom connector in Customize → Connectors pointing to https://cloud.comfy.org/mcp, then sign in when prompted.',
'zh-CN':
'在“自定义 → 连接器”中添加一个指向 https://cloud.comfy.org/mcp 的自定义连接器,然后在提示时登录。'
},
'mcp.faq.7.q': {
en: 'What can my agent do once connected?',
'zh-CN': '连接后我的智能体能做什么?'
},
'mcp.faq.6.a': {
en: "• Generate images, video, audio, and 3D — including all open-source workflows and partner models like Seedance, GPT-Image, Nano Banana, and Kling\n• Build, edit, and run workflows; save and re-run workflows\n• Run and read in large batches\n• Search models, nodes, and template workflows\n• Read and execute shared workflow URLs\n• Upload and download assets for you\n\nEverything is now in natural language. No nodes, no downloads, no GPU, no node graphs if you don't want them.",
'zh-CN':
'• 生成图像、视频、音频和 3D——包括所有开源工作流以及 Seedance、GPT-Image、Nano Banana 和 Kling 等合作伙伴模型\n• 构建、编辑和运行工作流;保存并重新运行工作流\n• 大批量运行和读取\n• 搜索模型、节点和模板工作流\n• 读取并执行分享的工作流链接\n• 为你上传和下载资产\n\n现在一切都用自然语言完成。如果你愿意无需节点、无需下载、无需 GPU、无需节点图。'
},
'mcp.faq.7.q': {
en: 'Where do my outputs go?',
'zh-CN': '我的输出会保存到哪里?'
},
'mcp.faq.7.a': {
en: 'Into your Comfy Cloud asset library, so you can reuse, remix, and share them — and open any run on the canvas to keep editing. You can also ask your agent to download the assets locally for you.',
en: 'Generate images, video, audio, and 3D; search models, nodes, and templates; and run ComfyUI workflows, all from a chat.',
'zh-CN':
'保存到你的 Comfy Cloud 资产库,你可以复用、二次创作和分享——还能在画布上打开任意运行继续编辑。你也可以让智能体把资产下载到本地。'
'生成图像、视频、音频和 3D搜索模型、节点和模板并运行 ComfyUI 工作流——全部在对话中完成。'
},
'mcp.faq.8.q': {
en: 'Do slash commands work in Claude Desktop?',
'zh-CN': '斜杠命令在 Claude Desktop 中可以使用吗?'
},
'mcp.faq.8.a': {
en: 'No. They ship with the Claude Code comfy-cloud plugin. Desktop connects to the same MCP server, so every tool works; just ask in plain language.',
'zh-CN':
'不可以。斜杠命令随 Claude Code 的 comfy-cloud 插件一起提供。Claude Desktop 连接的是同一个 MCP 服务器,因此所有工具都能使用;直接用自然语言提问即可。'
},
'mcp.faq.9.q': {
en: 'Is it generally available?',
'zh-CN': '现已正式发布了吗?'
},
'mcp.faq.9.a': {
en: 'Yes. Comfy Cloud MCP is in open beta and available to everyone with a Comfy account.',
'zh-CN':
'是的。Comfy Cloud MCP 目前处于公开测试阶段,任何拥有 Comfy 账户的人都可以使用。'
'mcp.faq.8.a': {
en: 'Comfy Cloud MCP is in open beta and available to everyone.',
'zh-CN': 'Comfy Cloud MCP 目前处于公开测试阶段,所有人均可使用。'
},
// SiteNav
@@ -2272,7 +2181,6 @@ const translations = {
'nav.youtube': { en: 'YouTube', 'zh-CN': 'YouTube' },
'nav.aboutUs': { en: 'About Us', 'zh-CN': '关于我们' },
'nav.careers': { en: 'Careers', 'zh-CN': '招聘' },
'nav.brand': { en: 'Brand', 'zh-CN': '品牌' },
'nav.customerStories': { en: 'Customer Stories', 'zh-CN': '客户故事' },
'nav.launches': { en: 'Launches', 'zh-CN': '发布' },
'nav.downloadLocal': { en: 'DOWNLOAD DESKTOP', 'zh-CN': '下载桌面版' },
@@ -4538,161 +4446,6 @@ const translations = {
'launches.section.title': {
en: 'Latest Launches',
'zh-CN': '最新发布'
},
// Brand Portal page (/brand)
'brand.page.title': {
en: 'Brand — Comfy',
'zh-CN': '品牌 — Comfy'
},
'brand.page.description': {
en: 'The Comfy brand portal: logos, color, typography, and voice. Everything you need to build something that looks and sounds like Comfy.',
'zh-CN':
'Comfy 品牌门户:标志、色彩、字体与语调。打造与 Comfy 观感一致、表达一致所需的一切。'
},
'brand.hero.label': {
en: 'Brand Portal',
'zh-CN': '品牌门户'
},
'brand.hero.heading': {
en: 'Create with ComfyUI',
'zh-CN': '用 ComfyUI 创作'
},
'brand.hero.subheading': {
en: 'Logo, color, type, and voice. Everything you need to build something that looks and sounds like us.',
'zh-CN': '标志、色彩、字体与语调。打造与我们观感一致、表达一致所需的一切。'
},
'brand.hero.viewGuidelines': {
en: 'View brand guidelines',
'zh-CN': '查看品牌规范'
},
'brand.hero.downloadLogos': {
en: 'Download logos',
'zh-CN': '下载标志'
},
'brand.logos.heading': {
en: 'One mark, many dimensions.',
'zh-CN': '一个标志,多种维度。'
},
'brand.logos.subheading': {
en: 'Logos come in light and dark options. Use as provided. Do not distort, recolor, or outline. Make sure the logo is legible against its background.',
'zh-CN':
'标志提供浅色和深色两种版本。请按原样使用,不要变形、改色或描边。确保标志在其背景上清晰可辨。'
},
'brand.colors.heading': {
en: 'Every color earns its place.',
'zh-CN': '每种颜色都各得其所。'
},
'brand.colors.subheading': {
en: 'Our color palette helps build brand recognition. When people think of Comfy, we want them to associate it with the following colors.',
'zh-CN':
'我们的调色板有助于建立品牌辨识度。当人们想到 Comfy 时,我们希望他们联想到以下这些颜色。'
},
'brand.colors.copy': {
en: 'Copy',
'zh-CN': '复制'
},
'brand.colors.copied': {
en: 'Copied',
'zh-CN': '已复制'
},
'brand.voice.heading': {
en: 'Precise, never cute.',
'zh-CN': '精准,绝不卖弄。'
},
'brand.voice.direct.title': {
en: 'Direct',
'zh-CN': '直接'
},
'brand.voice.direct.body': {
en: 'We state things. We dont hedge, qualify, or suggest. Short sentences. Active voice. One idea at a time.',
'zh-CN':
'我们直陈其事。不含糊、不设限、不暗示。短句。主动语态。一次只讲一个观点。'
},
'brand.voice.precise.title': {
en: 'Precise',
'zh-CN': '精准'
},
'brand.voice.precise.body': {
en: 'We use the real names for things. Nodes, samplers, seeds, checkpoints. We dont talk around the product or reach for metaphor when the technical term is already good.',
'zh-CN':
'我们直呼其名nodes、samplers、seeds、checkpoints。当技术术语已经足够贴切时我们不绕弯子也不借用比喻。'
},
'brand.voice.human.title': {
en: 'Human-first',
'zh-CN': '以人为先'
},
'brand.voice.human.body': {
en: 'The human creates. Comfy makes every step visible. We never write as though the AI is doing the work.',
'zh-CN':
'创作的是人。Comfy 让每一步都清晰可见。我们绝不把功劳写成是 AI 完成的。'
},
'brand.voice.antihype.title': {
en: 'Anti-hype',
'zh-CN': '拒绝浮夸'
},
'brand.voice.antihype.body': {
en: 'We dont write “stunning,” “revolutionary,” or “effortless.” We dont promise magic. Our tagline says exactly what we mean: Method, not magic.',
'zh-CN':
'我们不写“惊艳”“革命性”或“毫不费力”。我们不承诺魔法。我们的口号恰如其分:方法,而非魔法。'
},
'brand.voice.doLabel': {
en: 'Do',
'zh-CN': '推荐'
},
'brand.voice.dontLabel': {
en: 'Dont',
'zh-CN': '避免'
},
'brand.voice.do.0': {
en: 'Route your prompt through a ControlNet. Wire the output to the VAE decode.',
'zh-CN': '让你的 prompt 经过 ControlNet再将输出连接到 VAE decode。'
},
'brand.voice.do.1': {
en: 'Comfy runs on your hardware. Nothing leaves your machine.',
'zh-CN': 'Comfy 在你自己的硬件上运行。任何数据都不会离开你的机器。'
},
'brand.voice.dont.0': {
en: 'Simply connect your AI blocks and watch the magic happen!',
'zh-CN': '只需连接你的 AI 模块,见证奇迹的发生!'
},
'brand.voice.dont.1': {
en: 'Oops! Something went wrong. Please try again later.',
'zh-CN': '哎呀!出了点问题,请稍后再试。'
},
'brand.trademark.heading': {
en: 'Trademark guidelines.',
'zh-CN': '商标使用规范。'
},
'brand.trademark.body1': {
en: 'Comfy and ComfyUI are trademarks of Comfy Org. Youre welcome to reference them in content that accurately describes your work with our platform. Tutorials, reviews, integrations, and affiliate content all qualify.',
'zh-CN':
'Comfy 和 ComfyUI 是 Comfy Org 的商标。欢迎在准确描述你与我们平台相关工作的内容中引用它们。教程、评测、集成以及联盟内容均可。'
},
'brand.trademark.body2': {
en: 'A few rules: dont modify the logo, dont use the Comfy name in your own product or company name, and dont present your content in a way that implies official endorsement or partnership beyond whats been agreed.',
'zh-CN':
'几条规则:不要修改标志,不要在你自己的产品或公司名称中使用 Comfy 这一名称,也不要以暗示官方认可或合作关系(超出双方已达成的约定)的方式呈现你的内容。'
},
'brand.trademark.body3': {
en: 'For permissions outside these guidelines,',
'zh-CN': '如需本规范之外的授权,请'
},
'brand.trademark.contact': {
en: 'Contact Us',
'zh-CN': '联系我们'
},
'brand.questions.heading': {
en: 'Questions?',
'zh-CN': '有疑问?'
},
'brand.questions.body': {
en: 'For press, partnerships, or anything outside these guidelines,',
'zh-CN': '如涉及媒体、合作,或本规范未涵盖的任何事宜,请'
},
'brand.questions.contact': {
en: 'Contact Us',
'zh-CN': '联系我们'
}
} as const satisfies Record<string, Record<Locale, string>>

View File

@@ -1,28 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import BrandBackground from '../templates/brand/BrandBackground.vue'
import BrandHeroSection from '../templates/brand/BrandHeroSection.vue'
import BrandLogosSection from '../templates/brand/BrandLogosSection.vue'
import BrandColorSection from '../templates/brand/BrandColorSection.vue'
import BrandVoiceSection from '../templates/brand/BrandVoiceSection.vue'
import BrandTrademarkSection from '../templates/brand/BrandTrademarkSection.vue'
import BrandQuestionsSection from '../templates/brand/BrandQuestionsSection.vue'
import { t } from '../i18n/translations'
const locale = 'en' as const
---
<BaseLayout
title={t('brand.page.title', locale)}
description={t('brand.page.description', locale)}
>
<div class="relative">
<BrandBackground client:idle />
<BrandHeroSection />
<BrandLogosSection />
<BrandColorSection client:visible />
<BrandVoiceSection />
<BrandTrademarkSection />
<BrandQuestionsSection />
</div>
</BaseLayout>

View File

@@ -12,7 +12,6 @@ import {
absoluteUrl,
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
pageContext,
} from '../utils/jsonLd'
@@ -31,7 +30,7 @@ const { siteUrl, locale } = pageContext(
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.download', locale) },
]}
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
>
<CloudBannerSection />

View File

@@ -1,26 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import BrandBackground from '../../templates/brand/BrandBackground.vue'
import BrandHeroSection from '../../templates/brand/BrandHeroSection.vue'
import BrandLogosSection from '../../templates/brand/BrandLogosSection.vue'
import BrandColorSection from '../../templates/brand/BrandColorSection.vue'
import BrandVoiceSection from '../../templates/brand/BrandVoiceSection.vue'
import BrandTrademarkSection from '../../templates/brand/BrandTrademarkSection.vue'
import BrandQuestionsSection from '../../templates/brand/BrandQuestionsSection.vue'
import { t } from '../../i18n/translations'
---
<BaseLayout
title={t('brand.page.title', 'zh-CN')}
description={t('brand.page.description', 'zh-CN')}
>
<div class="relative">
<BrandBackground client:idle />
<BrandHeroSection locale="zh-CN" />
<BrandLogosSection locale="zh-CN" />
<BrandColorSection client:visible locale="zh-CN" />
<BrandVoiceSection locale="zh-CN" />
<BrandTrademarkSection locale="zh-CN" />
<BrandQuestionsSection locale="zh-CN" />
</div>
</BaseLayout>

View File

@@ -12,7 +12,6 @@ import {
absoluteUrl,
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
pageContext,
} from '../../utils/jsonLd'
@@ -34,7 +33,7 @@ const { siteUrl, locale } = pageContext(
},
{ name: t('breadcrumb.download', locale) },
]}
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
>
<CloudBannerSection locale="zh-CN" />

View File

@@ -1,112 +0,0 @@
<script setup lang="ts">
import {
useEventListener,
useIntersectionObserver,
useRafFn
} from '@vueuse/core'
import { onMounted, ref } from 'vue'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
interface Node {
x: number
y: number
vx: number
vy: number
}
const canvasEl = ref<HTMLCanvasElement | null>(null)
let dpr =
typeof window !== 'undefined' ? Math.min(window.devicePixelRatio || 1, 2) : 1
const nodes: Node[] = Array.from({ length: 14 }, () => ({
x: Math.random(),
y: Math.random(),
vx: (Math.random() - 0.5) * 0.0005,
vy: (Math.random() - 0.5) * 0.0005
}))
let ctx: CanvasRenderingContext2D | null = null
function draw() {
const el = canvasEl.value
if (!el || !ctx) return
const w = el.width
const h = el.height
ctx.clearRect(0, 0, w, h)
for (const n of nodes) {
n.x += n.vx
n.y += n.vy
if (n.x < 0 || n.x > 1) n.vx *= -1
if (n.y < 0 || n.y > 1) n.vy *= -1
}
ctx.strokeStyle = 'rgba(242, 255, 89, 0.18)'
ctx.lineWidth = dpr
const max = Math.min(w, h) * 0.22
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i]
const b = nodes[j]
const dx = (a.x - b.x) * w
const dy = (a.y - b.y) * h
const d = Math.hypot(dx, dy)
if (d < max) {
ctx.globalAlpha = 1 - d / max
ctx.beginPath()
ctx.moveTo(a.x * w, a.y * h)
const mx = ((a.x + b.x) / 2) * w
ctx.bezierCurveTo(mx, a.y * h, mx, b.y * h, b.x * w, b.y * h)
ctx.stroke()
}
}
}
ctx.globalAlpha = 1
ctx.fillStyle = 'rgba(242, 255, 89, 0.9)'
for (const n of nodes) {
ctx.beginPath()
ctx.arc(n.x * w, n.y * h, 2.5 * dpr, 0, Math.PI * 2)
ctx.fill()
}
}
// Resizing clears the canvas bitmap, so repaint immediately afterwards to keep
// the field visible even when the RAF loop is paused (off screen or
// reduced-motion). Also refresh dpr in case the window moved to another display.
function resize() {
const el = canvasEl.value
if (!el) return
dpr = Math.min(window.devicePixelRatio || 1, 2)
el.width = el.offsetWidth * dpr
el.height = el.offsetHeight * dpr
draw()
}
const { pause, resume } = useRafFn(draw, { immediate: false })
// Only animate while the field is on screen, and honour reduced-motion by
// painting a single static frame instead of looping.
useIntersectionObserver(canvasEl, ([entry]) => {
if (prefersReducedMotion()) return
if (entry?.isIntersecting) resume()
else pause()
})
useEventListener('resize', resize)
onMounted(() => {
ctx = canvasEl.value?.getContext('2d') ?? null
resize()
})
</script>
<template>
<canvas
ref="canvasEl"
aria-hidden="true"
class="pointer-events-none absolute inset-x-0 top-0 -z-10 h-screen w-full"
/>
</template>

View File

@@ -1,93 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { cn } from '@comfyorg/tailwind-utils'
import { useClipboard } from '@vueuse/core'
import { computed, ref } from 'vue'
import SectionHeader from '../../components/common/SectionHeader.vue'
import { brandColors } from '../../data/brandColors'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const specRows = ['hex', 'rgb', 'hsl', 'cmyk'] as const
const { copy, copied } = useClipboard({ copiedDuring: 1500 })
const copiedHex = ref<string | null>(null)
const copiedValue = ref('')
function copyValue(hex: string, value: string) {
copiedHex.value = hex
copiedValue.value = value
void copy(value)
}
function isCardCopied(hex: string) {
return copied.value && copiedHex.value === hex
}
const liveMessage = computed(() =>
copied.value ? `${t('brand.colors.copied', locale)} ${copiedValue.value}` : ''
)
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
<SectionHeader align="start" max-width="xl">
{{ t('brand.colors.heading', locale) }}
<template #subtitle>
<p class="text-primary-warm-gray mt-4 max-w-2xl text-sm leading-[1.45]">
{{ t('brand.colors.subheading', locale) }}
</p>
</template>
</SectionHeader>
<span class="sr-only" aria-live="polite">{{ liveMessage }}</span>
<ul class="mt-10 grid grid-cols-2 gap-4 lg:grid-cols-5">
<li
v-for="color in brandColors"
:key="color.hex"
:class="
cn(
'flex min-h-[123px] cursor-pointer flex-col rounded-[30px] p-6',
color.swatchClass,
color.textClass,
color.wide && 'lg:col-span-2',
color.border && 'border-primary-warm-gray border-[0.783px]'
)
"
@click="copyValue(color.hex, color.hex)"
>
<div
v-if="isCardCopied(color.hex)"
class="flex flex-1 items-center justify-center text-center text-sm font-semibold"
aria-hidden="true"
>
{{ t('brand.colors.copied', locale) }} {{ copiedValue }}
</div>
<template v-else>
<span class="text-xs font-semibold">{{ color.name }}</span>
<dl
class="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 text-xs leading-[1.4]"
>
<template v-for="row in specRows" :key="row">
<dt class="uppercase opacity-50">{{ row }}</dt>
<dd>
<button
type="button"
:aria-label="`${t('brand.colors.copy', locale)} ${row} ${color[row]}`"
class="cursor-pointer text-left hover:underline"
@click.stop="copyValue(color.hex, color[row])"
>
{{ color[row] }}
</button>
</dd>
</template>
</dl>
</template>
</li>
</ul>
</section>
</template>

View File

@@ -1,55 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import Button from '../../components/ui/button/Button.vue'
import { BRAND_ASSETS_ZIP, BRAND_GUIDELINES_PDF } from '../../data/brandAssets'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="max-w-9xl mx-auto px-6 pt-4 pb-10 text-center lg:px-20 lg:pb-12"
>
<p
class="text-primary-comfy-yellow text-sm font-extrabold tracking-[0.7px] uppercase"
>
{{ t('brand.hero.label', locale) }}
</p>
<h1
class="lg:text-6.5xl mx-auto mt-6 max-w-4xl text-4xl leading-[1.3] font-light tracking-[-0.03em] text-primary-comfy-canvas md:text-5xl"
>
{{ t('brand.hero.heading', locale) }}
</h1>
<p
class="mx-auto mt-6 max-w-2xl text-[17px] leading-[1.6] font-light text-primary-comfy-canvas/80"
>
{{ t('brand.hero.subheading', locale) }}
</p>
<div
class="mt-10 flex flex-col items-center justify-center gap-3 sm:flex-row"
>
<Button
as="a"
:href="BRAND_GUIDELINES_PDF"
target="_blank"
rel="noopener noreferrer"
variant="default"
class="h-12 w-full px-5 text-sm font-extrabold sm:w-auto"
>
{{ t('brand.hero.viewGuidelines', locale) }}
</Button>
<Button
as="a"
:href="BRAND_ASSETS_ZIP"
download
variant="outline"
class="h-12 w-full px-5 text-sm font-extrabold sm:w-auto"
>
{{ t('brand.hero.downloadLogos', locale) }}
</Button>
</div>
</section>
</template>

View File

@@ -1,58 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { cn } from '@comfyorg/tailwind-utils'
import SectionHeader from '../../components/common/SectionHeader.vue'
import { affiliateBrandAssets } from '../../data/affiliateBrandAssets'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const assets = affiliateBrandAssets.map((asset) =>
asset.id === 'icon' ? { ...asset, preview: '/icons/comfyicon.svg' } : asset
)
</script>
<template>
<section id="logos" class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
<SectionHeader align="start" max-width="xl">
{{ t('brand.logos.heading', locale) }}
<template #subtitle>
<p class="text-primary-warm-gray mt-4 max-w-2xl text-sm leading-[1.45]">
{{ t('brand.logos.subheading', locale) }}
</p>
</template>
</SectionHeader>
<ul class="mt-10 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<li
v-for="asset in assets"
:key="asset.id"
class="flex min-h-60 flex-col rounded-[30px] border-[1.5px] border-white/8 lg:min-h-[285px]"
>
<div class="flex flex-1 items-center justify-center p-8">
<img
:src="asset.preview"
:alt="asset.title[locale]"
:class="
cn(
'object-contain',
asset.id === 'icon'
? 'size-24 rounded-[23%] border border-white/10'
: 'max-h-24 max-w-[75%]'
)
"
loading="lazy"
decoding="async"
/>
</div>
<p
class="pb-8 text-center text-[21px] font-medium tracking-[1.05px] text-primary-comfy-canvas"
>
{{ asset.title[locale] }}
</p>
</li>
</ul>
</section>
</template>

View File

@@ -1,33 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import SectionHeader from '../../components/common/SectionHeader.vue'
import Button from '../../components/ui/button/Button.vue'
import { externalLinks } from '../../config/routes.ts'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="max-w-9xl mx-auto px-6 pt-10 pb-24 lg:px-20 lg:pt-12 lg:pb-32"
>
<SectionHeader align="start" max-width="xl">
{{ t('brand.questions.heading', locale) }}
</SectionHeader>
<p class="text-primary-warm-gray mt-6 max-w-2xl text-sm leading-[1.6]">
{{ t('brand.questions.body', locale) }}
<Button
as="a"
variant="inline"
:href="externalLinks.support"
target="_blank"
rel="noopener noreferrer"
>
{{ t('brand.questions.contact', locale) }}
</Button>
</p>
</section>
</template>

View File

@@ -1,37 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import SectionHeader from '../../components/common/SectionHeader.vue'
import Button from '../../components/ui/button/Button.vue'
import { externalLinks } from '../../config/routes.ts'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
<SectionHeader align="start" max-width="xl">
{{ t('brand.trademark.heading', locale) }}
</SectionHeader>
<div
class="text-primary-warm-gray mt-6 flex max-w-4xl flex-col gap-4 text-sm leading-[1.6]"
>
<p>{{ t('brand.trademark.body1', locale) }}</p>
<p>{{ t('brand.trademark.body2', locale) }}</p>
<p>
{{ t('brand.trademark.body3', locale) }}
<Button
as="a"
variant="inline"
:href="externalLinks.support"
target="_blank"
rel="noopener noreferrer"
>
{{ t('brand.trademark.contact', locale) }}
</Button>
</p>
</div>
</section>
</template>

View File

@@ -1,100 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import SectionHeader from '../../components/common/SectionHeader.vue'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const principles = [
{
title: t('brand.voice.direct.title', locale),
body: t('brand.voice.direct.body', locale)
},
{
title: t('brand.voice.precise.title', locale),
body: t('brand.voice.precise.body', locale)
},
{
title: t('brand.voice.human.title', locale),
body: t('brand.voice.human.body', locale)
},
{
title: t('brand.voice.antihype.title', locale),
body: t('brand.voice.antihype.body', locale)
}
]
const doExamples = [
t('brand.voice.do.0', locale),
t('brand.voice.do.1', locale)
]
const dontExamples = [
t('brand.voice.dont.0', locale),
t('brand.voice.dont.1', locale)
]
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
<SectionHeader align="start" max-width="xl">
{{ t('brand.voice.heading', locale) }}
</SectionHeader>
<dl class="mt-10 flex max-w-4xl flex-col gap-3.5 text-sm leading-[1.6]">
<div v-for="principle in principles" :key="principle.title">
<dt class="text-primary-comfy-yellow">{{ principle.title }}</dt>
<dd class="text-primary-warm-gray">{{ principle.body }}</dd>
</div>
</dl>
<div class="mt-12 grid gap-4 md:grid-cols-2">
<div class="bg-transparency-white-t4 flex flex-col gap-4 rounded-4xl p-8">
<div class="flex items-center gap-2">
<span
class="bg-primary-comfy-yellow size-2.5 rounded-full"
aria-hidden="true"
/>
<span
class="text-sm font-bold tracking-wider text-primary-comfy-canvas uppercase"
>
{{ t('brand.voice.doLabel', locale) }}
</span>
</div>
<div
v-for="example in doExamples"
:key="example"
class="bg-transparency-ink-t80 rounded-2xl p-5"
>
<p class="text-base/[1.45] text-primary-comfy-canvas">
{{ example }}
</p>
</div>
</div>
<div class="bg-transparency-white-t4 flex flex-col gap-4 rounded-4xl p-8">
<div class="flex items-center gap-2">
<span
class="bg-primary-warm-gray size-2.5 rounded-full"
aria-hidden="true"
/>
<span
class="text-primary-warm-gray text-sm font-bold tracking-wider uppercase"
>
{{ t('brand.voice.dontLabel', locale) }}
</span>
</div>
<div
v-for="example in dontExamples"
:key="example"
class="bg-transparency-ink-t80 rounded-2xl p-5"
>
<p class="text-primary-warm-gray text-base/[1.45] line-through">
{{ example }}
</p>
</div>
</div>
</div>
</section>
</template>

View File

@@ -7,40 +7,35 @@ import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const PROMPT = t('mcp.hero.demoPrompt', locale)
const generateLabel = t('mcp.hero.demoGenerate', locale)
// Each cycle types the prompt that produces the card about to slide in.
const cards = [
{
promptKey: 'mcp.hero.demoPromptMoodboard',
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'moodboard_v1.png · 6-up',
tag: 'Gmail',
thumb: '/images/mcp/mcp-thumb-moodboard.webp'
},
{
promptKey: 'mcp.hero.demoPromptConcepts',
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'concepts_0103.png',
tag: 'Notion',
thumb: '/images/mcp/mcp-thumb-concepts.webp'
},
{
promptKey: 'mcp.hero.demoPromptKeyart',
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'hero_keyart.png',
tag: 'Figma',
thumb: '/images/mcp/mcp-thumb-keyart.webp'
},
{
promptKey: 'mcp.hero.demoPromptPbr',
actionKey: 'mcp.hero.demoActionGenerate3d',
file: 'asphalt_pbr/ · 5 maps',
tag: 'Blender',
thumb: '/images/mcp/mcp-thumb-asphalt.webp'
},
{
promptKey: 'mcp.hero.demoPromptUpscale',
actionKey: 'mcp.hero.demoActionUpscale',
file: 'kaiju_neon_4k.png · 4096',
tag: null,
@@ -70,15 +65,15 @@ function schedule(fn: () => void, ms: number) {
}, ms)
}
function typePrompt(prompt: string, onDone: () => void) {
function typePrompt(onDone: () => void) {
displayedPrompt.value = ''
promptDone.value = false
let i = 0
function step() {
i++
displayedPrompt.value = prompt.slice(0, i)
if (i < prompt.length) {
displayedPrompt.value = PROMPT.slice(0, i)
if (i < PROMPT.length) {
schedule(step, 35)
} else {
promptDone.value = true
@@ -99,8 +94,8 @@ function revealNextCard() {
return
}
// Type the next card's prompt, then slide that card in
typePrompt(t(cards[visibleCount.value].promptKey, locale), () => {
// Type the prompt, then slide in the next card
typePrompt(() => {
visibleCount.value++
schedule(revealNextCard, 400)
})

View File

@@ -5,7 +5,7 @@ import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const faqNumbers = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const
const faqNumbers = [1, 2, 3, 4, 5, 6, 7, 8] as const
const faqs = faqNumbers.map((n) => ({
id: String(n),

View File

@@ -11,10 +11,9 @@ const ctas = mcpCtas(locale)
</script>
<template>
<!-- 5rem/6.75rem = HeaderMain's rendered height (py-5 / lg:py-8) so the hero fills the viewport below the sticky nav -->
<HeroSplit01
:locale="locale"
class="min-h-[calc(100svh-5rem)] lg:min-h-[calc(100svh-6.75rem)]"
class="min-h-screen"
badge-text="MCP"
:title="t('mcp.hero.heading', locale)"
:subtitle="t('mcp.hero.subtitle', locale)"

View File

@@ -23,7 +23,7 @@ const steps: FeatureStep[] = stepNumbers.map((n) => ({
<FeatureGrid02
:heading="t('mcp.howItWorks.heading', locale)"
:steps="steps"
:primary-cta="ctas.installMcp"
:primary-cta="ctas.runWorkflow"
:secondary-cta="ctas.docs"
/>
</template>

View File

@@ -1,180 +1,69 @@
<script setup lang="ts">
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { ArrowUpRight } from '@lucide/vue'
import SectionHeader from '../../components/common/SectionHeader.vue'
import SectionLabel from '../../components/common/SectionLabel.vue'
import CopyableField from '../../components/ui/copyable-field/CopyableField.vue'
import FeatureGrid01 from '../../components/blocks/FeatureGrid01.vue'
import type { FeatureCard } from '../../components/blocks/FeatureGrid01.vue'
import { externalLinks } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const agentCommand = t('mcp.setup.option1.command', locale).replace(
'{url}',
externalLinks.docsMcp
)
interface McpClient {
id: string
name: string
step: string
command?: string
link?: { label: string; href: string }
}
const clients: McpClient[] = [
const cards: FeatureCard[] = [
{
id: 'claude-code',
name: 'Claude Code',
step: t('mcp.setup.clients.claudeCode.step', locale),
command: `claude mcp add --transport http comfy-cloud ${externalLinks.mcpEndpoint}`
},
{
id: 'claude-desktop',
name: 'Claude Desktop',
step: t('mcp.setup.clients.claudeDesktop.step', locale)
},
{
id: 'cursor',
name: 'Cursor',
step: t('mcp.setup.clients.cursor.step', locale),
link: {
label: t('mcp.setup.clients.cursor.linkLabel', locale),
href: externalLinks.apiKeys
id: 'step1',
label: t('mcp.setup.step1.label', locale),
title: t('mcp.setup.step1.title', locale),
description: t('mcp.setup.step1.description', locale),
action: {
type: 'code',
value: t('mcp.setup.step1.command', locale).replace(
'{url}',
externalLinks.docsMcp
)
}
},
{
id: 'codex',
name: 'Codex',
step: t('mcp.setup.clients.codex.step', locale),
command: `codex mcp add comfy-cloud --url ${externalLinks.mcpEndpoint}`
id: 'step2',
label: t('mcp.setup.step2.label', locale),
title: t('mcp.setup.step2.title', locale),
description: t('mcp.setup.step2.description', locale),
action: {
type: 'link',
label: t('mcp.setup.step2.cta', locale),
href: externalLinks.docsMcp,
target: '_blank',
icon: ArrowUpRight,
variant: 'default'
}
},
{
id: 'other',
name: t('mcp.setup.clients.other.name', locale),
step: t('mcp.setup.clients.other.step', locale),
link: {
label: t('mcp.setup.clients.other.linkLabel', locale),
href: externalLinks.docsMcp
id: 'step3',
label: t('mcp.setup.step3.label', locale),
title: t('mcp.setup.step3.title', locale),
description: t('mcp.setup.step3.description', locale),
action: {
type: 'link',
label: t('mcp.setup.step3.cta', locale),
href: externalLinks.mcpSkills,
target: '_blank',
icon: ArrowUpRight,
variant: 'default'
}
}
]
const copyLabel = t('ui.copy', locale)
const copiedLabel = t('ui.copied', locale)
</script>
<template>
<section
<FeatureGrid01
id="setup"
class="max-w-9xl mx-auto scroll-mt-24 px-6 py-16 lg:scroll-mt-36 lg:py-24"
>
<SectionHeader
max-width="xl"
:label="t('mcp.setup.label', locale)"
align="start"
>
{{ t('mcp.setup.heading', locale) }}
<template #subtitle>
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
{{ t('mcp.setup.subtitle', locale) }}
</p>
</template>
</SectionHeader>
<div class="mt-16 grid grid-cols-1 gap-6 lg:grid-cols-2">
<div
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
>
<SectionLabel>{{ t('mcp.setup.option1.label', locale) }}</SectionLabel>
<h3
class="mt-3 text-xl font-light text-primary-comfy-canvas lg:text-2xl"
>
{{ t('mcp.setup.option1.title', locale) }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ t('mcp.setup.option1.description', locale) }}
</p>
<div class="mt-6">
<CopyableField
:value="agentCommand"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
<p class="mt-auto pt-6 text-sm text-smoke-700">
{{ t('mcp.setup.skillsNote', locale)
}}<a
:href="externalLinks.mcpSkills"
target="_blank"
rel="noopener noreferrer"
class="focus-visible:ring-primary-comfy-yellow/50 rounded-sm text-primary-comfy-canvas underline underline-offset-4 focus-visible:ring-2 focus-visible:outline-none"
>{{ t('mcp.setup.skillsLink', locale) }}</a
>
</p>
</div>
<div
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
>
<SectionLabel>{{ t('mcp.setup.option2.label', locale) }}</SectionLabel>
<h3
class="mt-3 text-xl font-light text-primary-comfy-canvas lg:text-2xl"
>
{{ t('mcp.setup.option2.title', locale) }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ t('mcp.setup.option2.description', locale) }}
</p>
<div class="mt-6">
<CopyableField
:value="externalLinks.mcpEndpoint"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
<TabsRoot default-value="claude-code" class="mt-6">
<TabsList
:aria-label="t('mcp.setup.option2.tabsLabel', locale)"
class="flex flex-wrap gap-2"
>
<TabsTrigger
v-for="client in clients"
:key="client.id"
:value="client.id"
class="bg-transparency-white-t4 focus-visible:ring-primary-comfy-yellow/50 data-[state=active]:bg-primary-comfy-yellow cursor-pointer rounded-full px-4 py-2 text-xs font-bold tracking-wider text-smoke-700 uppercase transition-colors hover:text-primary-comfy-canvas focus-visible:ring-2 focus-visible:outline-none data-[state=active]:text-primary-comfy-ink"
>
{{ client.name }}
</TabsTrigger>
</TabsList>
<TabsContent
v-for="client in clients"
:key="client.id"
:value="client.id"
class="mt-4 flex min-h-24 flex-col gap-3"
>
<p class="text-sm text-smoke-700">
{{ client.step
}}<a
v-if="client.link"
:href="client.link.href"
target="_blank"
rel="noopener noreferrer"
class="focus-visible:ring-primary-comfy-yellow/50 rounded-sm text-primary-comfy-canvas underline underline-offset-4 focus-visible:ring-2 focus-visible:outline-none"
>{{ client.link.label }}</a
>
</p>
<CopyableField
v-if="client.command"
:value="client.command"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</TabsContent>
</TabsRoot>
</div>
</div>
</section>
class="scroll-mt-24 lg:scroll-mt-36"
:eyebrow="t('mcp.setup.label', locale)"
:heading="t('mcp.setup.heading', locale)"
:subtitle="t('mcp.setup.subtitle', locale)"
:columns="3"
:cards="cards"
:copy-label="t('ui.copy', locale)"
:copied-label="t('ui.copied', locale)"
/>
</template>

View File

@@ -7,21 +7,16 @@ import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
type ToolMedia =
| { type: 'image'; src: string; fit?: 'cover' | 'contain' }
| { type: 'image'; src: string }
| {
type: 'video'
src: string
autoplay?: boolean
loop?: boolean
hideControls?: boolean
fit?: 'cover' | 'contain'
}
const tools: {
n: 1 | 2 | 3 | 4 | 5 | 6
media: ToolMedia
altKey?: TranslationKey
}[] = [
const tools: { n: 1 | 2 | 3; media: ToolMedia; altKey?: TranslationKey }[] = [
{
n: 1,
media: {
@@ -45,38 +40,9 @@ const tools: {
src: 'https://media.comfy.org/website/mcp/run-real-workflows.mp4',
autoplay: true,
loop: true,
hideControls: true,
fit: 'contain'
hideControls: true
},
altKey: 'mcp.tools.3.alt'
},
{
n: 4,
media: {
type: 'image',
src: 'https://media.comfy.org/website/mcp/direct-any-model.png'
},
altKey: 'mcp.tools.4.alt'
},
{
n: 5,
media: {
type: 'image',
src: 'https://media.comfy.org/website/mcp/generate-in-batches.png'
},
altKey: 'mcp.tools.5.alt'
},
{
n: 6,
media: {
type: 'video',
src: 'https://media.comfy.org/website/homepage/showcase/ui-overview.webm',
autoplay: true,
loop: true,
hideControls: true,
fit: 'contain'
},
altKey: 'mcp.tools.6.alt'
}
]

View File

@@ -1,4 +1,4 @@
import { externalLinks } from '../../config/routes'
import { externalLinks, getRoutes } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
@@ -9,13 +9,14 @@ export interface McpCta {
}
/**
* Calls-to-action for the MCP page: view the docs or jump to the on-page
* setup options. Both the hero and the "how it works" section pair install
* with docs.
* Calls-to-action for the MCP page: view the docs, jump to the on-page setup
* steps, or run a workflow in the cloud. The hero leads with install + docs;
* the "how it works" section pairs run-a-workflow with docs.
*/
export function mcpCtas(locale: Locale): {
docs: McpCta
installMcp: McpCta
runWorkflow: McpCta
} {
return {
docs: {
@@ -26,6 +27,10 @@ export function mcpCtas(locale: Locale): {
installMcp: {
label: t('mcp.hero.installMcp', locale),
href: '#setup'
},
runWorkflow: {
label: t('mcp.hero.runWorkflow', locale),
href: getRoutes(locale).cloud
}
}
}

View File

@@ -171,31 +171,6 @@ describe('comfyUiSourceCodeNode', () => {
})
})
describe('ComfyUI entity links', () => {
it('names the home page as the canonical page for the application', () => {
const node = comfyUiApplicationNode(siteUrl)
expect(node.mainEntityOfPage).toBe(`${siteUrl}/`)
})
it('links the application back to the source code emitted alongside it', () => {
const app = comfyUiApplicationNode(siteUrl)
const source = comfyUiSourceCodeNode(siteUrl)
expect(app.isBasedOn).toEqual({ '@id': source['@id'] })
})
it('claims no canonical page or source code for third-party software', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/p/supported-models/foo/#software',
name: 'Foo Model',
url: 'https://comfy.org/p/supported-models/foo/',
applicationCategory: 'MultimediaApplication'
})
expect(node.mainEntityOfPage).toBeUndefined()
expect(node.isBasedOn).toBeUndefined()
})
})
describe('buildPageGraph', () => {
const url = 'https://comfy.org/cloud/pricing/'
const graph = buildPageGraph(

View File

@@ -194,8 +194,6 @@ export interface SoftwareAppInput {
authorName?: string
isFree?: boolean
sameAs?: string[]
mainEntityOfPage?: string
isBasedOnId?: string
}
export function softwareApplicationNode(input: SoftwareAppInput): JsonLdNode {
@@ -221,8 +219,6 @@ export function softwareApplicationNode(input: SoftwareAppInput): JsonLdNode {
author,
publisher: input.firstParty ? orgRef : undefined,
sameAs: input.sameAs,
mainEntityOfPage: input.mainEntityOfPage,
isBasedOn: input.isBasedOnId ? { '@id': input.isBasedOnId } : undefined,
offers: input.isFree
? {
'@type': 'Offer',
@@ -261,10 +257,6 @@ export function comfyUiSoftwareId(siteUrl: string): string {
return `${siteUrl}/#software`
}
function comfyUiSourceCodeId(siteUrl: string): string {
return `${siteUrl}/#sourcecode`
}
export function comfyUiApplicationNode(siteUrl: string): JsonLdNode {
return softwareApplicationNode({
siteUrl,
@@ -275,16 +267,14 @@ export function comfyUiApplicationNode(siteUrl: string): JsonLdNode {
applicationCategory: 'MultimediaApplication',
operatingSystem: 'Windows, macOS, Linux',
isFree: true,
sameAs: comfyUiSameAs,
mainEntityOfPage: `${siteUrl}/`,
isBasedOnId: comfyUiSourceCodeId(siteUrl)
sameAs: comfyUiSameAs
})
}
export function comfyUiSourceCodeNode(siteUrl: string): JsonLdNode {
return softwareSourceCodeNode({
siteUrl,
id: comfyUiSourceCodeId(siteUrl),
id: `${siteUrl}/#sourcecode`,
name: 'ComfyUI',
codeRepository: externalLinks.github,
programmingLanguage: 'Python',

View File

@@ -322,9 +322,6 @@ export class AssetsSidebarTab extends SidebarTab {
// --- Folder view ---
public readonly backToAssetsButton: Locator
// --- Panel chrome ---
public readonly panelHeader: Locator
// --- Loading ---
public readonly skeletonLoaders: Locator
@@ -361,7 +358,6 @@ export class AssetsSidebarTab extends SidebarTab {
this.deleteSelectedButton = page.getByTestId('assets-delete-selected')
this.downloadSelectedButton = page.getByTestId('assets-download-selected')
this.backToAssetsButton = page.getByText('Back to all assets')
this.panelHeader = page.locator('.comfy-vue-side-bar-header')
this.skeletonLoaders = page.locator(
'.sidebar-content-container .animate-pulse'
)

View File

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

View File

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

View File

@@ -276,255 +276,3 @@ test.describe('FE-130 assets sidebar route mocks', () => {
)
})
})
test.describe('FE-910 marquee selection and select all', () => {
test.beforeEach(async ({ jobsRoutes, page, comfyPage }) => {
await jobsRoutes.mockJobsQueue([])
await jobsRoutes.mockJobsHistory(generatedJobs)
await mockInputFiles(page, ['imported.png'])
await mockViewFiles(page, viewFiles)
await comfyPage.setup()
await comfyPage.menu.assetsTab.open()
})
test('Ctrl/Cmd+A selects every asset while the panel is hovered', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await expect(tab.assetCards).toHaveCount(2)
await tab.getAssetCardByName('alpha').hover()
await comfyPage.page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(2)
})
test('a marquee that begins in the panel header selects the cards', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
await expect(tab.selectedCards).toHaveCount(0)
const header = await tab.panelHeader.boundingBox()
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!header || !beta) {
throw new Error('panel header or asset card has no layout box')
}
// Begin the rubber-band in the header (above the grid), then drag down
// across both cards.
await page.mouse.move(header.x + 24, header.y + 20)
await page.mouse.down()
await page.mouse.move(beta.x + 8, beta.y + beta.height - 8, { steps: 14 })
await page.mouse.up()
await expect(tab.selectedCards).toHaveCount(2)
await expect(tab.selectionFooter).toBeVisible()
})
test('Ctrl/Cmd+A leaves assets unselected while the canvas is hovered', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
const viewport = page.viewportSize()
if (!viewport) throw new Error('viewport size is unavailable')
// Hover the canvas (not the panel); Ctrl/Cmd+A must yield to the canvas.
await page.mouse.move(viewport.width - 100, viewport.height / 2)
await page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(0)
})
test('a modifier-held marquee adds to the existing selection', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
await tab.getAssetCardByName('alpha').click()
await expect(tab.selectedCards).toHaveCount(1)
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!beta) throw new Error('beta card has no layout box')
// Hold a modifier so the marquee is additive, then rubber-band over beta.
await page.keyboard.down('Control')
await page.mouse.move(beta.x + 12, beta.y + 12)
await page.mouse.down()
await page.mouse.move(beta.x + beta.width - 12, beta.y + beta.height - 12, {
steps: 12
})
await page.mouse.up()
await page.keyboard.up('Control')
await expect(tab.selectedCards).toHaveCount(2)
})
test('a Ctrl/Cmd+Shift marquee removes the covered cards from the selection', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
await tab.getAssetCardByName('alpha').hover()
await page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(2)
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!beta) throw new Error('beta card has no layout box')
// Ctrl+Shift makes the marquee subtractive: rubber-band over beta only.
await page.keyboard.down('Control')
await page.keyboard.down('Shift')
await page.mouse.move(beta.x + 12, beta.y + 12)
await page.mouse.down()
await page.mouse.move(beta.x + beta.width - 12, beta.y + beta.height - 12, {
steps: 12
})
await page.mouse.up()
await page.keyboard.up('Shift')
await page.keyboard.up('Control')
await expect(tab.selectedCards).toHaveCount(1)
await expect(tab.getAssetCardByName('alpha')).toHaveAttribute(
'data-selected',
'true'
)
})
test('Ctrl/Cmd-dragging from an asset card starts a marquee selection', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
await expect(tab.selectedCards).toHaveCount(0)
const alpha = await tab.getAssetCardByName('alpha').boundingBox()
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!alpha || !beta) throw new Error('asset cards have no layout box')
// Ctrl bypasses card drag, so a press that begins on a card rubber-bands.
await page.keyboard.down('Control')
await page.mouse.move(alpha.x + alpha.width / 2, alpha.y + alpha.height / 2)
await page.mouse.down()
await page.mouse.move(beta.x + beta.width - 6, beta.y + beta.height - 6, {
steps: 12
})
await page.mouse.up()
await page.keyboard.up('Control')
await expect(tab.selectedCards).toHaveCount(2)
await expect(tab.selectionFooter).toBeVisible()
})
test('Ctrl/Cmd-dragging within a single card selects only that card', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
const alpha = tab.getAssetCardByName('alpha')
const box = await alpha.boundingBox()
if (!box) throw new Error('alpha card has no layout box')
const start = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
await page.keyboard.down('Control')
await page.mouse.move(start.x, start.y)
await page.mouse.down()
await page.mouse.move(start.x + 12, start.y + 12, { steps: 4 })
await page.mouse.up()
await page.keyboard.up('Control')
await expect(tab.selectedCards).toHaveCount(1)
await expect(alpha).toHaveAttribute('data-selected', 'true')
})
test('Ctrl/Cmd+A in the focused search input does not select assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const query = 'alpha'
await tab.searchInput.fill(query)
await expect(tab.assetCards).toHaveCount(1)
await tab.searchInput.focus()
await comfyPage.page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(0)
await expect
.poll(() =>
tab.searchInput.evaluate((el: HTMLInputElement) => {
return { start: el.selectionStart, end: el.selectionEnd }
})
)
.toEqual({ start: 0, end: query.length })
})
test('a drag starting in the search input does not marquee-select assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
const search = await tab.searchInput.boundingBox()
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!search || !beta)
throw new Error('search box or card has no layout box')
await page.mouse.move(
search.x + search.width / 2,
search.y + search.height / 2
)
await page.mouse.down()
await page.mouse.move(beta.x + beta.width / 2, beta.y + beta.height / 2, {
steps: 12
})
await page.mouse.up()
await expect(tab.selectedCards).toHaveCount(0)
})
test('Ctrl/Cmd+A does not select assets while an aria-modal dialog is open', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await expect(tab.assetCards).toHaveCount(2)
await comfyPage.page.evaluate(() => {
const dialog = document.createElement('div')
dialog.id = 'test-modal'
dialog.setAttribute('role', 'dialog')
dialog.setAttribute('aria-modal', 'true')
document.body.appendChild(dialog)
})
await tab.getAssetCardByName('alpha').hover()
await comfyPage.page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(0)
await comfyPage.page.evaluate(() => {
document.getElementById('test-modal')?.remove()
})
})
})

View File

@@ -23,7 +23,6 @@ import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const ERROR_CLASS = /ring-destructive-background/
const SLOT_ERROR_CLASS = /before:ring-error/
const UNKNOWN_NODE_ID = '1'
const INNER_EXECUTION_ID = '2:1'
const KSAMPLER_MODEL_INPUT_NAME = 'model'
@@ -70,25 +69,6 @@ async function selectLoadImageNodeForPaste(
}, localLoadImageId)
}
async function getInputSlotIndexByName(
comfyPage: ComfyPage,
nodeId: string,
inputName: string
): Promise<number> {
return comfyPage.page.evaluate(
({ inputName, nodeId }) => {
const graph = window.app!.canvas.graph ?? window.app!.graph
const node = graph.getNodeById(nodeId)
const index = node?.findInputSlot(inputName) ?? -1
if (index < 0) {
throw new Error(`Input slot "${inputName}" not found`)
}
return index
},
{ inputName, nodeId: toNodeId(nodeId) }
)
}
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
const loadImageNode = (
@@ -159,10 +139,17 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
async ({ comfyPage }) => {
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
const ksamplerNode = comfyPage.vueNodes.getNodeLocator(ksamplerId)
const modelInputIndex = await getInputSlotIndexByName(
comfyPage,
ksamplerId,
KSAMPLER_MODEL_INPUT_NAME
const modelInputIndex = await comfyPage.page.evaluate(
({ nodeId, inputName }) => {
const node = window.app!.graph.getNodeById(nodeId)
const index =
node?.inputs?.findIndex((input) => input.name === inputName) ?? -1
if (index < 0) {
throw new Error(`Input slot "${inputName}" not found`)
}
return index
},
{ nodeId: toNodeId(ksamplerId), inputName: KSAMPLER_MODEL_INPUT_NAME }
)
const modelInputSlotRow = comfyPage.vueNodes.getInputSlotRow(
ksamplerId,
@@ -188,7 +175,7 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
await expect(modelInputSlotRow).toBeVisible()
await expect(modelInputSlotRow).toBeInViewport()
await expect(modelInputSlotHighlight).toHaveClass(SLOT_ERROR_CLASS)
await expect(modelInputSlotHighlight).toHaveClass(/before:ring-error/)
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
).toHaveClass(ERROR_CLASS)
@@ -420,76 +407,5 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
test('boundary-linked validation error surfaces on the subgraph host', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphParentId =
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
const innerWrapper =
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
const hostInputIndex = await getInputSlotIndexByName(
comfyPage,
subgraphParentId,
'positive'
)
const hostInputSlotHighlight =
comfyPage.vueNodes.getInputSlotConnectionDot(
subgraphParentId,
hostInputIndex
)
await expect(
innerWrapper,
'subgraph host must mount before injecting validation errors'
).toBeVisible()
await expect(
innerWrapper,
'subgraph host should start without an error ring'
).not.toHaveClass(ERROR_CLASS)
await test.step('surface the boundary-linked error on the host', async () => {
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[INNER_EXECUTION_ID]: buildKSamplerError(
'required_input_missing',
'positive',
'Required input is missing: positive'
)
})
await comfyPage.runButton.click()
await dismissErrorOverlay(comfyPage)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
await expect(hostInputSlotHighlight).toHaveClass(SLOT_ERROR_CLASS)
})
await test.step('confirm the interior node does not show the surfaced ring', async () => {
await comfyPage.vueNodes.enterSubgraph(subgraphParentId)
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
const interiorKSamplerId =
await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
const interiorPositiveInputIndex = await getInputSlotIndexByName(
comfyPage,
interiorKSamplerId,
'positive'
)
const interiorPositiveSlotHighlight =
comfyPage.vueNodes.getInputSlotConnectionDot(
interiorKSamplerId,
interiorPositiveInputIndex
)
const interiorInnerWrapper =
comfyPage.vueNodes.getNodeInnerWrapper(interiorKSamplerId)
await expect(interiorInnerWrapper).toBeVisible()
await expect(interiorInnerWrapper).not.toHaveClass(ERROR_CLASS)
await expect(interiorPositiveSlotHighlight).toBeVisible()
await expect(interiorPositiveSlotHighlight).not.toHaveClass(
SLOT_ERROR_CLASS
)
})
})
})
})

View File

@@ -7,45 +7,6 @@ import {
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Vue Upload Widgets', { tag: '@vue-nodes' }, () => {
test.describe('media selection', { tag: '@widget' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
})
test('keeps a selected image loaded when it is selected again', async ({
comfyPage
}) => {
const loadImageNodes =
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
expect(loadImageNodes, 'Workflow has one Load Image node').toHaveLength(1)
const [loadImageNode] = loadImageNodes
const imageWidget = await loadImageNode.getWidgetByName('image')
await expect.poll(() => imageWidget.getValue()).toBe('example.png')
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
const imageLoadError = node.getByTestId(TestIds.errors.imageLoadError)
const selectedImageButton = node.getByRole('button', {
name: 'example.png',
exact: true
})
await expect(selectedImageButton).toBeVisible()
await expect(imageLoadError).toBeHidden()
await selectedImageButton.click()
const menu = comfyPage.page.getByTestId('form-dropdown-menu')
await expect(menu).toBeVisible()
await menu.getByText('example.png', { exact: true }).click()
await expect(menu).toBeHidden()
await expect(selectedImageButton).toBeFocused()
await expect(selectedImageButton).toBeVisible()
await expect.poll(() => imageWidget.getValue()).toBe('example.png')
await expect(imageLoadError).toBeHidden()
})
})
test('should hide canvas-only upload buttons', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/all_load_widgets')

View File

@@ -1,74 +0,0 @@
# 12. Cloud Release Notes Use the ComfyUI Version
Date: 2026-07-13
## Status
Accepted
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
## Context
The release-note system (`releaseStore`) decides whether to surface new-release
UI — the desktop toast/red-dot and the "what's new" popup — by comparing the
version of the most recent entry in the `/releases` feed against the version the
user is currently running.
`currentVersion` sourced that "current version" differently per platform:
- on Cloud, from `system_stats.system.cloud_version`,
- everywhere else, from `system_stats.system.comfyui_version`.
The `/releases` feed, however, is authored in the docs repo and its entries are
keyed by **ComfyUI** version for every project, including `project: 'cloud'`.
There is no separate cloud-versioned feed, and the "learn more" link on every
release note points at <https://docs.comfy.org/changelog>, which only lists
ComfyUI versions.
This mismatch broke the feature on Cloud (tracked as **FE-1237**):
- Cloud runs a much higher `cloud_version` (e.g. `0.160.1`) than the ComfyUI
version the feed entries carry (e.g. `0.27.1`).
- The comparison therefore resolved as `0.27.1 < 0.160.1` → "already ahead of
the latest release" → the popup's `isLatestVersion` gate never passed and the
popup never showed.
- Analytics confirmed the regression: `release_note` clicks fell from 13.4% of
clicks over 90 days to 0% over 30 days, and `cloud_release_note` was
effectively never clicked.
Two directions could fix the mismatch:
1. Give Cloud its own cloud-versioned release feed and a cloud changelog page.
2. Compare against the ComfyUI version on Cloud too, so the running version and
the feed entries share the same version namespace.
Option 1 requires infrastructure that does not exist: no cloud changelog page,
and in current practice a single person maintains the changelog in the docs
repo using ComfyUI versions, updated after each Cloud deploy completes. A
cloud-versioned popup would deep-link users to a changelog page that has no
matching entry, which is more confusing than the version label itself.
## Decision
`currentVersion` always uses `comfyui_version`, on Cloud as well as everywhere
else. `cloud_version` is no longer consulted for release-note version
comparisons.
The `/releases` request still sends `project: 'cloud'` on Cloud, so Cloud can
receive a curated subset of release notes; only the version used for comparison
changes.
## Consequences
- Cloud shows a release note once the running ComfyUI version matches the latest
published feed entry — consistent with the practice of updating the changelog
after a Cloud deploy lands. Publishing a note for a version Cloud has not yet
deployed correctly withholds the popup until the deploy catches up.
- The popup and its "learn more" link now reference a ComfyUI version that
actually exists on the changelog page.
- `cloud_version` remains available in `system_stats` for other consumers; this
decision scopes only to release-note version comparison.
- If Cloud later wants release notes tied to its own versioning, it would need a
cloud-versioned feed **and** a cloud changelog page, at which point this ADR
should be revisited.

View File

@@ -21,7 +21,6 @@ An Architecture Decision Record captures an important architectural decision mad
| [0009](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Subgraph Promoted Widgets Use Linked Inputs | Proposed | 2026-05-05 |
| [0010](0010-remove-nx-orchestration.md) | Remove Nx Orchestration | Accepted | 2026-05-19 |
| [0011](0011-derived-credential-lifecycle.md) | Derived Credential Lifecycle for Cloud Auth | Proposed | 2026-07-09 |
| [0012](0012-cloud-release-notes-use-comfyui-version.md) | Cloud Release Notes Use the ComfyUI Version | Accepted | 2026-07-13 |
## Creating a New ADR

View File

@@ -57,9 +57,6 @@ const config: KnipConfig = {
// Marketing media tooling — adopted by pages in a follow-up PR
'apps/website/src/components/common/SiteVideo.vue',
'apps/website/src/utils/marketingImage.ts',
// Pending integration: consumed by the useWorkspaceInvoices seam once
// #13591 (Plan & Credits tabs) lands — FE-1245
'src/composables/billing/useNextInvoice.ts',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js',
// Devtools extensions, included dynamically

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.48.3",
"version": "1.48.2",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'
import {
appendWorkflowJsonExt,
ensureWorkflowSuffix,
escapeVueI18nMessageSyntax,
formatLocalizedMediumDate,
formatLocalizedNumber,
getFilePathSeparatorVariants,
@@ -478,49 +477,4 @@ describe('formatUtil', () => {
expect(formatLocalizedMediumDate('not a date', 'en')).toBe('—')
})
})
describe('escapeVueI18nMessageSyntax', () => {
it('escapes a literal @ that would break linked-message compilation', () => {
expect(
escapeVueI18nMessageSyntax('clips (tagged @Audio1-3 in the prompt)')
).toBe("clips (tagged {'@'}Audio1-3 in the prompt)")
})
it('escapes @ in an email address', () => {
expect(escapeVueI18nMessageSyntax('support@comfy.org')).toBe(
"support{'@'}comfy.org"
)
})
it('escapes interpolation braces', () => {
expect(escapeVueI18nMessageSyntax('size {w}x{h}')).toBe(
"size {'{'}w{'}'}x{'{'}h{'}'}"
)
})
it('escapes the plural pipe separator', () => {
expect(escapeVueI18nMessageSyntax('foreground | background')).toBe(
"foreground {'|'} background"
)
})
it('escapes the modulo percent so it cannot re-form %{', () => {
expect(escapeVueI18nMessageSyntax('50%{done}')).toBe(
"50{'%'}{'{'}done{'}'}"
)
})
it('escapes every occurrence in a single pass', () => {
expect(escapeVueI18nMessageSyntax('@a @b @c')).toBe(
"{'@'}a {'@'}b {'@'}c"
)
})
it('leaves strings without syntax characters unchanged', () => {
expect(escapeVueI18nMessageSyntax('no special chars here')).toBe(
'no special chars here'
)
expect(escapeVueI18nMessageSyntax('')).toBe('')
})
})
})

View File

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

View File

@@ -16,7 +16,6 @@ const maybeLocalOptions: PlaywrightTestConfig = process.env.PLAYWRIGHT_LOCAL
}
: {
retries: process.env.CI ? 3 : 0,
workers: process.env.CI ? 2 : undefined,
use: {
trace: 'on-first-retry'
}
@@ -26,7 +25,7 @@ export default defineConfig({
testDir: './browser_tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
reporter: process.env.PLAYWRIGHT_BLOB_OUTPUT_DIR ? 'blob' : 'html',
reporter: 'html',
...maybeLocalOptions,
globalSetup: './browser_tests/globalSetup.ts',

276
pnpm-lock.yaml generated
View File

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

View File

@@ -1,120 +0,0 @@
import fs from 'fs'
import os from 'os'
import path from 'path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
computeTargetVersion,
isValidSemver,
parseRequirementsVersion,
parseTargetBranchOverride
} from './resolve-comfyui-release'
describe('parseRequirementsVersion', () => {
let dir: string
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-release-'))
})
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true })
})
function writeRequirements(content: string): string {
const filePath = path.join(dir, 'requirements.txt')
fs.writeFileSync(filePath, content)
return filePath
}
it('parses a pinned == version', () => {
const file = writeRequirements(
'torch\ncomfyui-frontend-package==1.45.20\nnumpy'
)
expect(parseRequirementsVersion(file)).toBe('1.45.20')
})
it('parses a >= constraint', () => {
const file = writeRequirements('comfyui-frontend-package>=2.0.3')
expect(parseRequirementsVersion(file)).toBe('2.0.3')
})
it('returns null when the package is absent', () => {
const file = writeRequirements('torch\nnumpy')
expect(parseRequirementsVersion(file)).toBeNull()
})
it('returns null when the file is missing', () => {
expect(parseRequirementsVersion(path.join(dir, 'nope.txt'))).toBeNull()
})
})
describe('isValidSemver', () => {
it('accepts a valid X.Y.Z', () => {
expect(isValidSemver('1.45.20')).toBe(true)
expect(isValidSemver('2.0.0')).toBe(true)
})
it('rejects non-three-part versions', () => {
expect(isValidSemver('1.45')).toBe(false)
expect(isValidSemver('1.45.20.1')).toBe(false)
})
it('rejects non-numeric or leading-zero-padded parts', () => {
expect(isValidSemver('1.x.0')).toBe(false)
expect(isValidSemver('v1.45.20')).toBe(false)
expect(isValidSemver('1.045.20')).toBe(false)
})
it('rejects empty / non-string input', () => {
expect(isValidSemver('')).toBe(false)
// @ts-expect-error exercising runtime guard
expect(isValidSemver(undefined)).toBe(false)
})
})
describe('parseTargetBranchOverride', () => {
it('parses core/1.47', () => {
expect(parseTargetBranchOverride('core/1.47')).toEqual({
major: 1,
minor: 47,
branch: 'core/1.47'
})
})
it('parses a major bump core/2.0', () => {
expect(parseTargetBranchOverride('core/2.0')).toEqual({
major: 2,
minor: 0,
branch: 'core/2.0'
})
})
it('rejects malformed overrides', () => {
expect(parseTargetBranchOverride('core/1')).toBeNull()
expect(parseTargetBranchOverride('1.47')).toBeNull()
expect(parseTargetBranchOverride('core/1.47.0')).toBeNull()
expect(parseTargetBranchOverride('release/1.47')).toBeNull()
expect(parseTargetBranchOverride('core/v1.47')).toBeNull()
expect(parseTargetBranchOverride('')).toBeNull()
})
})
describe('computeTargetVersion', () => {
it('bumps patch on a 2.x line when commits exist', () => {
expect(computeTargetVersion(2, 0, 'v2.0.3', true)).toBe('2.0.4')
})
it('keeps the tag version when no new commits exist', () => {
expect(computeTargetVersion(2, 0, 'v2.0.3', false)).toBe('2.0.3')
})
it('starts a fresh major.minor line at .0 when no tag exists', () => {
expect(computeTargetVersion(2, 0, null, true)).toBe('2.0.0')
expect(computeTargetVersion(1, 47, null, true)).toBe('1.47.0')
})
it('returns null for a malformed tag', () => {
expect(computeTargetVersion(1, 47, 'v1.47', true)).toBeNull()
})
})

View File

@@ -81,72 +81,15 @@ function isValidSemver(version: string): boolean {
}
/**
* Parse a target branch override of the form `core/<major>.<minor>`.
* Returns the parsed major/minor and normalized branch, or null if malformed.
* Get the latest patch tag for a given minor version
*/
function parseTargetBranchOverride(
branch: string
): { major: number; minor: number; branch: string } | null {
const match = branch.match(/^core\/(\d+)\.(\d+)$/)
if (!match) {
return null
}
return {
major: Number(match[1]),
minor: Number(match[2]),
branch
}
}
/**
* Compute the next release version for a target major.minor line.
*
* With no prior tag, the line starts at `.0`. With a prior tag, the patch is
* bumped when there are pending commits, otherwise the tagged version stands.
* Returns null if the tag is not valid semver.
*/
function computeTargetVersion(
targetMajor: number,
targetMinor: number,
latestPatchTag: string | null,
hasPendingCommits: boolean
): string | null {
if (!latestPatchTag) {
return `${targetMajor}.${targetMinor}.0`
}
const tagVersion = latestPatchTag.replace('v', '')
if (!isValidSemver(tagVersion)) {
return null
}
const existingPatch = Number(tagVersion.split('.')[2])
return hasPendingCommits
? `${targetMajor}.${targetMinor}.${existingPatch + 1}`
: tagVersion
}
/**
* Check whether a branch exists on origin in the given repo.
*/
function branchExists(branch: string, repoPath: string): boolean {
return Boolean(exec(`git rev-parse --verify origin/${branch}`, repoPath))
}
/**
* Get the latest patch tag for a given major.minor version
*/
function getLatestPatchTag(
repoPath: string,
major: number,
minor: number
): string | null {
function getLatestPatchTag(repoPath: string, minor: number): string | null {
// Fetch all tags
exec('git fetch --tags', repoPath)
// Use git's native version sorting to get the latest tag
const latestTag = exec(
`git tag -l 'v${major}.${minor}.*' --sort=-version:refname | head -n 1`,
`git tag -l 'v1.${minor}.*' --sort=-version:refname | head -n 1`,
repoPath
)
@@ -158,7 +101,7 @@ function getLatestPatchTag(
const validTagRegex = /^v\d+\.\d+\.\d+$/
if (!validTagRegex.test(latestTag)) {
console.error(
`Latest tag for version ${major}.${minor} is not valid semver: ${latestTag}`
`Latest tag for minor version ${minor} is not valid semver: ${latestTag}`
)
return null
}
@@ -189,106 +132,85 @@ function resolveRelease(
return null
}
const [currentMajor, currentMinor] = currentVersion.split('.').map(Number)
const [major, currentMinor, patch] = currentVersion.split('.').map(Number)
// Fetch all branches
exec('git fetch origin', frontendRepoPath)
// Target major defaults to the current pin's major, but a TARGET_BRANCH
// override (below) can retarget both major and minor.
let targetMajor = currentMajor
// Determine target branch based on release type:
// 'patch' → target current minor (hotfix for production version)
// 'minor' → try next minor, fall back to current minor (bi-weekly cadence)
const releaseTypeInput =
process.env.RELEASE_TYPE?.trim().toLowerCase() || 'minor'
if (releaseTypeInput !== 'minor' && releaseTypeInput !== 'patch') {
console.error(
`Invalid RELEASE_TYPE: "${releaseTypeInput}". Expected "minor" or "patch"`
)
return null
}
const releaseType: 'minor' | 'patch' = releaseTypeInput
let targetMinor: number
let targetBranch: string
const targetBranchOverride = process.env.TARGET_BRANCH?.trim()
if (releaseType === 'patch') {
targetMinor = currentMinor
targetBranch = `core/1.${targetMinor}`
if (targetBranchOverride) {
// Manual override takes precedence over RELEASE_TYPE / pin-derived selection.
const parsed = parseTargetBranchOverride(targetBranchOverride)
if (!parsed) {
const branchExists = exec(
`git rev-parse --verify origin/${targetBranch}`,
frontendRepoPath
)
if (!branchExists) {
console.error(
`Invalid TARGET_BRANCH: "${targetBranchOverride}". Expected format: core/<major>.<minor> (e.g. core/1.47 or core/2.0)`
)
return null
}
targetMajor = parsed.major
targetMinor = parsed.minor
targetBranch = parsed.branch
if (!branchExists(targetBranch, frontendRepoPath)) {
console.error(
`Manual override branch ${targetBranch} does not exist in frontend repo`
`Patch release requested but branch ${targetBranch} does not exist`
)
return null
}
console.error(
`Manual override: targeting ${targetBranch} (ignoring release_type)`
`Patch release: targeting current production branch ${targetBranch}`
)
} else {
// Determine target branch based on release type:
// 'patch' → target current minor (hotfix for production version)
// 'minor' → try next minor, fall back to current minor (bi-weekly cadence)
const releaseTypeInput =
process.env.RELEASE_TYPE?.trim().toLowerCase() || 'minor'
if (releaseTypeInput !== 'minor' && releaseTypeInput !== 'patch') {
console.error(
`Invalid RELEASE_TYPE: "${releaseTypeInput}". Expected "minor" or "patch"`
)
return null
}
const releaseType: 'minor' | 'patch' = releaseTypeInput
// Try next minor first, fall back to current minor if not available
targetMinor = currentMinor + 1
targetBranch = `core/1.${targetMinor}`
if (releaseType === 'patch') {
const nextMinorExists = exec(
`git rev-parse --verify origin/${targetBranch}`,
frontendRepoPath
)
if (!nextMinorExists) {
// Fall back to current minor for minor release
targetMinor = currentMinor
targetBranch = `core/${targetMajor}.${targetMinor}`
targetBranch = `core/1.${targetMinor}`
if (!branchExists(targetBranch, frontendRepoPath)) {
const currentMinorExists = exec(
`git rev-parse --verify origin/${targetBranch}`,
frontendRepoPath
)
if (!currentMinorExists) {
console.error(
`Patch release requested but branch ${targetBranch} does not exist`
`Neither core/1.${currentMinor + 1} nor core/1.${currentMinor} branches exist in frontend repo`
)
return null
}
console.error(
`Patch release: targeting current production branch ${targetBranch}`
`Next minor branch core/1.${currentMinor + 1} not found, falling back to core/1.${currentMinor} for minor release`
)
} else {
// Try next minor first, fall back to current minor if not available
targetMinor = currentMinor + 1
targetBranch = `core/${targetMajor}.${targetMinor}`
if (!branchExists(targetBranch, frontendRepoPath)) {
// Fall back to current minor for minor release
targetMinor = currentMinor
targetBranch = `core/${targetMajor}.${targetMinor}`
if (!branchExists(targetBranch, frontendRepoPath)) {
console.error(
`Neither core/${targetMajor}.${currentMinor + 1} nor core/${targetMajor}.${currentMinor} branches exist in frontend repo`
)
return null
}
console.error(
`Next minor branch core/${targetMajor}.${currentMinor + 1} not found, falling back to core/${targetMajor}.${currentMinor} for minor release`
)
}
}
}
// Get latest patch tag for target major.minor
const latestPatchTag = getLatestPatchTag(
frontendRepoPath,
targetMajor,
targetMinor
)
// Get latest patch tag for target minor
const latestPatchTag = getLatestPatchTag(frontendRepoPath, targetMinor)
let needsRelease: boolean
let branchHeadSha: string | null
let needsRelease = false
let branchHeadSha: string | null = null
let tagCommitSha: string | null = null
let targetVersion: string
let targetVersion = currentVersion
if (latestPatchTag) {
// Get commit SHA for the tag
@@ -309,23 +231,34 @@ function resolveRelease(
const commitCount = parseInt(commitsBetween, 10)
needsRelease = !isNaN(commitCount) && commitCount > 0
const nextVersion = computeTargetVersion(
targetMajor,
targetMinor,
latestPatchTag,
needsRelease
)
if (!nextVersion) {
// Parse existing patch number and increment if needed
const tagVersion = latestPatchTag.replace('v', '')
// Validate tag version format
if (!isValidSemver(tagVersion)) {
console.error(
`Invalid tag version format: ${latestPatchTag}. Expected format: vX.Y.Z`
`Invalid tag version format: ${tagVersion}. Expected format: X.Y.Z`
)
return null
}
targetVersion = nextVersion
const [, , existingPatch] = tagVersion.split('.').map(Number)
// Validate existingPatch is a valid number
if (!Number.isFinite(existingPatch) || existingPatch < 0) {
console.error(`Invalid patch number in tag: ${existingPatch}`)
return null
}
if (needsRelease) {
targetVersion = `1.${targetMinor}.${existingPatch + 1}`
} else {
targetVersion = tagVersion
}
} else {
// No tags exist for this major.minor version, need to create the .0 patch
// No tags exist for this minor version, need to create v1.{targetMinor}.0
needsRelease = true
targetVersion = `${targetMajor}.${targetMinor}.0`
targetVersion = `1.${targetMinor}.0`
branchHeadSha = exec(
`git rev-parse origin/${targetBranch}`,
frontendRepoPath
@@ -348,41 +281,26 @@ function resolveRelease(
}
}
/**
* Main execution: parse args, resolve, and print the JSON result.
*/
function main(): void {
const comfyuiRepoPath = process.argv[2]
const frontendRepoPath = process.argv[3] || process.cwd()
// Main execution
const comfyuiRepoPath = process.argv[2]
const frontendRepoPath = process.argv[3] || process.cwd()
if (!comfyuiRepoPath) {
console.error(
'Usage: resolve-comfyui-release.ts <comfyui-repo-path> [frontend-repo-path]'
)
process.exit(1)
}
const releaseInfo = resolveRelease(comfyuiRepoPath, frontendRepoPath)
if (!releaseInfo) {
console.error('Failed to resolve release information')
process.exit(1)
}
// Output as JSON for GitHub Actions
// oxlint-disable-next-line no-console -- stdout is captured by the workflow
console.log(JSON.stringify(releaseInfo, null, 2))
if (!comfyuiRepoPath) {
console.error(
'Usage: resolve-comfyui-release.ts <comfyui-repo-path> [frontend-repo-path]'
)
process.exit(1)
}
// Only run when invoked directly, not when imported by tests.
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
main()
const releaseInfo = resolveRelease(comfyuiRepoPath, frontendRepoPath)
if (!releaseInfo) {
console.error('Failed to resolve release information')
process.exit(1)
}
export {
computeTargetVersion,
isValidSemver,
parseRequirementsVersion,
parseTargetBranchOverride,
resolveRelease
}
// Output as JSON for GitHub Actions
// oxlint-disable-next-line no-console -- stdout is captured by the workflow
console.log(JSON.stringify(releaseInfo, null, 2))
export { resolveRelease }

View File

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

View File

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

View File

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

View File

@@ -126,7 +126,7 @@ function nodeToNodeData(node: LGraphNode) {
return {
...nodeData,
hasErrors: !!executionErrorStore.surfacedNodeErrors?.[node.id],
hasErrors: !!executionErrorStore.lastNodeErrors?.[node.id],
dropIndicator,
onDragDrop: node.onDragDrop,
onDragOver: node.onDragOver

View File

@@ -1,219 +0,0 @@
<template>
<div
class="relative size-full min-h-[300px]"
@pointerdown.stop
@mousedown.stop
>
<div
ref="container"
class="relative size-full"
data-capture-wheel="true"
tabindex="-1"
@pointerdown.stop="focusContainer"
@contextmenu.stop.prevent
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
/>
<div class="pointer-events-none absolute inset-x-0 top-0">
<div
ref="toolbar"
class="pointer-events-auto flex h-10 items-center gap-1 bg-interface-menu-surface px-2"
@wheel.stop
>
<button
v-tooltip.bottom="tip(gizmosLabel)"
type="button"
:disabled="lookingThrough"
:class="
cn(
actionClass(!lookingThrough && gizmosOn),
lookingThrough && 'cursor-not-allowed opacity-40'
)
"
:aria-pressed="!lookingThrough && gizmosOn"
:aria-label="compact ? gizmosLabel : undefined"
@click="toggleGizmos"
>
<i
:class="
cn(
'size-4',
gizmosOn ? 'icon-[lucide--eye]' : 'icon-[lucide--eye-off]'
)
"
/>
<span v-if="!compact">{{ gizmosLabel }}</span>
</button>
<div class="mx-1 h-5 w-px shrink-0 bg-interface-menu-stroke" />
<button
v-for="option in transformGizmoOptions"
:key="option.value"
v-tooltip.bottom="tip($t(option.labelKey))"
type="button"
:disabled="lookingThrough || !option.enabled"
:aria-pressed="!lookingThrough && transformGizmoMode === option.value"
:aria-label="compact ? $t(option.labelKey) : undefined"
:class="
cn(
actionClass(
!lookingThrough && transformGizmoMode === option.value
),
(lookingThrough || !option.enabled) &&
'cursor-not-allowed opacity-40'
)
"
@click="selectTransformGizmo(option.value)"
>
<i :class="cn('size-4', option.icon)" />
<span v-if="!compact">{{ $t(option.labelKey) }}</span>
</button>
</div>
</div>
<div class="pointer-events-none absolute inset-x-0 bottom-0">
<div
class="pointer-events-auto flex h-10 items-center justify-end gap-1 bg-interface-menu-surface px-2"
@wheel.stop
>
<button
v-tooltip.top="tip(lookThroughLabel)"
type="button"
:class="
cn(iconBtnClass, lookingThrough && 'bg-button-active-surface')
"
:aria-pressed="lookingThrough"
:aria-label="lookThroughLabel"
@click="toggleLookThrough"
>
<i class="icon-[lucide--video] size-4" />
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
actionClass,
iconBtnClass,
tip
} from '@/components/load3d/menubar/menuBarStyles'
import { useCameraInfo } from '@/composables/useCameraInfo'
import type { TransformGizmoMode } from '@/extensions/core/cameraInfo/CameraInfoViewport'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { NodeId } from '@/types/nodeId'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { resolveNode } from '@/utils/litegraphUtil'
import { cn } from '@comfyorg/tailwind-utils'
const { widget, nodeId } = defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
}>()
function isComponentWidget(
w: ComponentWidget<string[]> | SimplifiedWidget
): w is ComponentWidget<string[]> {
return 'node' in w && w.node !== undefined
}
const node = ref<LGraphNode | null>(null)
if (isComponentWidget(widget)) {
node.value = widget.node
} else if (nodeId) {
onMounted(() => {
node.value = resolveNode(nodeId) ?? null
})
}
const { t } = useI18n()
const container = ref<HTMLElement | null>(null)
const toolbar = ref<HTMLElement | null>(null)
const { width: toolbarWidth } = useElementSize(toolbar)
const compactWidthThreshold = 480
const compact = computed(
() => toolbarWidth.value > 0 && toolbarWidth.value < compactWidthThreshold
)
const gizmosOn = ref(true)
const lookingThrough = ref(false)
const transformGizmoMode = ref<TransformGizmoMode>('none')
const {
initialize,
cleanup,
handleMouseEnter,
handleMouseLeave,
setGizmosVisible,
setTransformGizmoMode,
setLookThrough,
mode
} = useCameraInfo(node as Ref<LGraphNode | null>)
const gizmosLabel = computed(() =>
gizmosOn.value ? t('load3d.hideGizmos') : t('load3d.showGizmos')
)
const lookThroughLabel = computed(() =>
lookingThrough.value ? t('load3d.exitLookThrough') : t('load3d.lookThrough')
)
const transformGizmoOptions = computed(() => [
{
value: 'none' as const,
labelKey: 'load3d.transformGizmo.none',
icon: 'icon-[lucide--ban]',
enabled: true
},
{
value: 'target' as const,
labelKey: 'load3d.transformGizmo.target',
icon: 'icon-[lucide--target]',
enabled: mode.value === 'orbit' || mode.value === 'look_at'
},
{
value: 'camera-translate' as const,
labelKey: 'load3d.transformGizmo.cameraTranslate',
icon: 'icon-[lucide--move-3d]',
enabled: mode.value === 'look_at' || mode.value === 'quaternion'
},
{
value: 'camera-rotate' as const,
labelKey: 'load3d.transformGizmo.cameraRotate',
icon: 'icon-[lucide--rotate-3d]',
enabled: mode.value === 'quaternion'
}
])
function focusContainer() {
container.value?.focus()
}
function toggleGizmos() {
gizmosOn.value = !gizmosOn.value
}
function toggleLookThrough() {
lookingThrough.value = !lookingThrough.value
}
function selectTransformGizmo(value: TransformGizmoMode) {
transformGizmoMode.value = value
}
watch(gizmosOn, (on) => setGizmosVisible(on))
watch(transformGizmoMode, (m) => setTransformGizmoMode(m))
watch(lookingThrough, (on) => setLookThrough(on))
onMounted(() => {
if (container.value) initialize(container.value)
})
onUnmounted(() => {
cleanup()
})
</script>

View File

@@ -38,11 +38,16 @@
<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>
<div
ref="canvasPanelBoundsRef"
class="pointer-events-none absolute inset-0"
/>
<GraphCanvasMenu
v-if="canvasMenuEnabled && !isBuilderMode"
class="pointer-events-auto"
@@ -93,10 +98,7 @@
/>
<!-- Selection rectangle overlay - rendered in DOM layer to appear above DOM widgets -->
<SelectionRectangle
v-if="comfyAppReady"
:panel-el="canvasPanelBoundsRef ?? undefined"
/>
<SelectionRectangle v-if="comfyAppReady" />
<NodeTooltip v-if="tooltipEnabled" />
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
@@ -118,12 +120,12 @@
import { until, useEventListener } from '@vueuse/core'
import {
computed,
defineAsyncComponent,
nextTick,
onMounted,
onUnmounted,
ref,
shallowRef,
useTemplateRef,
watch,
watchEffect
} from 'vue'
@@ -205,12 +207,15 @@ import { forEachNode } from '@/utils/graphTraversalUtil'
import SelectionRectangle from './SelectionRectangle.vue'
import { useUrlActionLoaders } from '@/composables/useUrlActionLoaders'
const AgentPanelRoot = defineAsyncComponent(
() => import('@/workbench/extensions/agent/AgentPanelRoot.vue')
)
const { t } = useI18n()
const emit = defineEmits<{
ready: []
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
const canvasPanelBoundsRef = useTemplateRef('canvasPanelBoundsRef')
const nodeSearchboxPopoverRef = shallowRef<InstanceType<
typeof NodeSearchboxPopover
> | null>(null)

View File

@@ -1,106 +0,0 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { render, screen } from '@testing-library/vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import SelectionRectangle from './SelectionRectangle.vue'
const rafCallbacks: Array<() => void> = []
vi.mock('@vueuse/core', () => ({
useRafFn: (cb: () => void) => {
rafCallbacks.push(cb)
return { pause: vi.fn(), resume: vi.fn() }
}
}))
const mockCanvas = ref<unknown>(null)
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
get canvas() {
return mockCanvas.value
}
})
}))
function createPanelEl() {
const panel = document.createElement('div')
vi.spyOn(panel, 'getBoundingClientRect').mockReturnValue(
fromPartial<DOMRect>({ left: 300, top: 0, right: 1000, bottom: 800 })
)
return panel
}
function dragRectangle(eDown: [number, number], eMove: [number, number]) {
const canvasEl = document.createElement('canvas')
vi.spyOn(canvasEl, 'getBoundingClientRect').mockReturnValue(
fromPartial<DOMRect>({ left: 0, top: 0, right: 1000, bottom: 800 })
)
mockCanvas.value = {
canvas: canvasEl,
dragging_rectangle: true,
pointer: {
eDown: { safeOffsetX: eDown[0], safeOffsetY: eDown[1] },
eMove: { safeOffsetX: eMove[0], safeOffsetY: eMove[1] }
}
}
rafCallbacks[rafCallbacks.length - 1]()
}
describe('SelectionRectangle', () => {
afterEach(() => {
rafCallbacks.length = 0
mockCanvas.value = null
document.body.replaceChildren()
vi.restoreAllMocks()
})
it('clips the rectangle to the canvas panel when dragged over the sidebar', async () => {
render(SelectionRectangle, { props: { panelEl: createPanelEl() } })
dragRectangle([100, 100], [800, 400])
await nextTick()
const rect = screen.getByTestId('selection-rectangle')
expect(rect.style.left).toBe('300px')
expect(rect.style.top).toBe('100px')
expect(rect.style.width).toBe('500px')
expect(rect.style.height).toBe('300px')
})
it('leaves a rectangle within the panel unchanged', async () => {
render(SelectionRectangle, { props: { panelEl: createPanelEl() } })
dragRectangle([400, 100], [600, 300])
await nextTick()
const rect = screen.getByTestId('selection-rectangle')
expect(rect.style.left).toBe('400px')
expect(rect.style.top).toBe('100px')
expect(rect.style.width).toBe('200px')
expect(rect.style.height).toBe('200px')
})
it('normalizes and clips a rectangle dragged up-and-left', async () => {
render(SelectionRectangle, { props: { panelEl: createPanelEl() } })
dragRectangle([800, 400], [100, 100])
await nextTick()
const rect = screen.getByTestId('selection-rectangle')
expect(rect.style.left).toBe('300px')
expect(rect.style.top).toBe('100px')
expect(rect.style.width).toBe('500px')
expect(rect.style.height).toBe('300px')
})
it('renders unclamped edges when the canvas panel is absent', async () => {
render(SelectionRectangle)
dragRectangle([100, 100], [800, 400])
await nextTick()
const rect = screen.getByTestId('selection-rectangle')
expect(rect.style.left).toBe('100px')
expect(rect.style.width).toBe('700px')
})
})

View File

@@ -1,7 +1,6 @@
<template>
<div
v-show="isVisible"
data-testid="selection-rectangle"
class="pointer-events-none absolute z-9999 border border-blue-400 bg-blue-500/20"
:style="rectangleStyle"
/>
@@ -12,13 +11,6 @@ import { useRafFn } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { clipRectToBounds } from '@/utils/mathUtil'
import type { RectEdges } from '@/utils/mathUtil'
const { panelEl } = defineProps<{
/** Clip surface owned by the caller; the rectangle renders unclipped when absent. */
panelEl?: HTMLElement
}>()
const canvasStore = useCanvasStore()
@@ -28,18 +20,17 @@ const selectionRect = ref<{
w: number
h: number
} | null>(null)
const panelBounds = ref<RectEdges>()
useRafFn(() => {
const canvas = canvasStore.canvas
if (!canvas) return
if (!canvas) {
selectionRect.value = null
return
}
const { pointer, dragging_rectangle } = canvas
if (dragging_rectangle && pointer.eDown && pointer.eMove) {
if (!selectionRect.value) {
panelBounds.value = getCanvasPanelBounds(canvas.canvas)
}
const x = pointer.eDown.safeOffsetX
const y = pointer.eDown.safeOffsetY
const w = pointer.eMove.safeOffsetX - x
@@ -48,47 +39,25 @@ useRafFn(() => {
selectionRect.value = { x, y, w, h }
} else {
selectionRect.value = null
panelBounds.value = undefined
}
})
const isVisible = computed(() => selectionRect.value !== null)
function getCanvasPanelBounds(
canvasEl: HTMLCanvasElement
): RectEdges | undefined {
if (!panelEl) return undefined
const panel = panelEl.getBoundingClientRect()
const canvas = canvasEl.getBoundingClientRect()
return {
left: panel.left - canvas.left,
top: panel.top - canvas.top,
right: panel.right - canvas.left,
bottom: panel.bottom - canvas.top
}
}
const rectangleStyle = computed(() => {
const rect = selectionRect.value
if (!rect) return {}
const edges: RectEdges = {
left: rect.w >= 0 ? rect.x : rect.x + rect.w,
top: rect.h >= 0 ? rect.y : rect.y + rect.h,
right: rect.w >= 0 ? rect.x + rect.w : rect.x,
bottom: rect.h >= 0 ? rect.y + rect.h : rect.y
}
const bounds = panelBounds.value
const { left, top, right, bottom } = bounds
? clipRectToBounds(edges, bounds)
: edges
const left = rect.w >= 0 ? rect.x : rect.x + rect.w
const top = rect.h >= 0 ? rect.y : rect.y + rect.h
const width = Math.abs(rect.w)
const height = Math.abs(rect.h)
return {
left: `${left}px`,
top: `${top}px`,
width: `${right - left}px`,
height: `${bottom - top}px`
width: `${width}px`,
height: `${height}px`
}
})
</script>

View File

@@ -72,8 +72,6 @@
v-if="showCameraControls"
v-model:camera-type="cameraConfig!.cameraType"
v-model:fov="cameraConfig!.fov"
v-model:use-custom-up="cameraConfig!.useCustomUp"
:has-custom-up="cameraConfig!.hasCustomUp ?? false"
/>
<div v-if="showLightControls" class="flex flex-col">

View File

@@ -13,33 +13,6 @@
>
<i class="pi pi-camera text-lg text-base-foreground" />
</Button>
<Button
v-if="hasCustomUp"
v-tooltip.right="{
value: useCustomUp
? $t('load3d.useNaturalUp')
: $t('load3d.useCustomUp'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="
useCustomUp ? $t('load3d.useNaturalUp') : $t('load3d.useCustomUp')
"
@click="toggleUp"
>
<i
:class="
cn(
useCustomUp
? 'icon-[lucide--compass]'
: 'icon-[lucide--rotate-ccw]',
'text-lg text-base-foreground'
)
"
/>
</Button>
<PopupSlider
v-if="showFOVButton"
v-model="fov"
@@ -54,24 +27,13 @@ import { computed } from 'vue'
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
import Button from '@/components/ui/button/Button.vue'
import type { CameraType } from '@/extensions/core/load3d/interfaces'
import { cn } from '@comfyorg/tailwind-utils'
const { hasCustomUp = false } = defineProps<{
hasCustomUp?: boolean
}>()
const cameraType = defineModel<CameraType>('cameraType')
const fov = defineModel<number>('fov')
const useCustomUp = defineModel<boolean>('useCustomUp', { default: false })
const showFOVButton = computed(() => cameraType.value === 'perspective')
const switchCamera = () => {
cameraType.value =
cameraType.value === 'perspective' ? 'orthographic' : 'perspective'
}
const toggleUp = () => {
useCustomUp.value = !useCustomUp.value
}
</script>

View File

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

View File

@@ -5,11 +5,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingNodeType } from '@/types/comfy'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type * as GraphTraversalUtil from '@/utils/graphTraversalUtil'
vi.mock('@/scripts/app', () => ({
app: {
isGraphReady: true,
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
@@ -65,6 +63,10 @@ vi.mock('@/i18n', () => {
'Prompt has no outputs',
'errorCatalog.promptErrors.prompt_no_outputs.desc':
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.',
'errorCatalog.promptErrors.agent_draft_apply_failed.title':
'An error was found',
'errorCatalog.promptErrors.agent_draft_apply_failed.desc':
"Couldn't apply the agent's draft to the canvas.",
'errorCatalog.runtimeErrors.execution_failed.title': 'Execution failed',
'errorCatalog.runtimeErrors.execution_failed.message':
'Node threw an error during execution.',
@@ -129,8 +131,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
import { createBoundaryLinkedSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import {
getExecutionIdByNode,
getNodeByExecutionId
@@ -497,47 +497,6 @@ describe('useErrorGroups', () => {
)
})
it('groups lifted boundary errors under the host node card', async () => {
const { store, groups } = createErrorGroups()
const { rootGraph, host } = createBoundaryLinkedSubgraph({
interiorType: 'InteriorClass'
})
const { getNodeByExecutionId: actualGetNodeByExecutionId } =
await vi.importActual<typeof GraphTraversalUtil>(
'@/utils/graphTraversalUtil'
)
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
return actualGetNodeByExecutionId(rootGraph, String(nodeId))
})
store.lastNodeErrors = {
'12:5': nodeError(
[
validationError(
'required_input_missing',
'seed_input',
{},
'Required input is missing'
)
],
'InteriorClass'
)
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
expect(execGroup?.type).toBe('execution')
if (execGroup?.type !== 'execution') return
const card = execGroup.cards[0]
expect(card.nodeId).toBe('12')
expect(card.title).toBe(host.title)
expect(card.errors[0].displayDetails).toBe(
`${host.title} is missing a required input: seed`
)
})
it('groups node validation errors by catalog id across node types', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
@@ -674,6 +633,25 @@ describe('useErrorGroups', () => {
expect(promptGroup).toBeDefined()
})
it('carries prompt error details onto the card item', async () => {
const { store, groups } = createErrorGroups()
store.lastPromptError = {
type: 'agent_draft_apply_failed',
message: "Couldn't apply the agent's draft to the canvas",
details: 'Validation error: Required at "version"'
}
await nextTick()
const promptGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution' && g.displayTitle === 'An error was found'
)
const details =
promptGroup && 'cards' in promptGroup
? promptGroup.cards[0]?.errors[0]?.details
: undefined
expect(details).toBe('Validation error: Required at "version"')
})
it('includes prompt error when a node is selected', async () => {
const { store, groups } = createErrorGroups()
const canvasStore = useCanvasStore()

View File

@@ -46,6 +46,11 @@ import {
const PROMPT_CARD_ID = '__prompt__'
const AGENT_PROMPT_ERROR_TYPES = new Set([
'agent_api_failed',
'agent_draft_apply_failed'
])
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
const RESOLVING = '__RESOLVING__'
@@ -372,6 +377,9 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
errors: [
{
message: error.message,
...(AGENT_PROMPT_ERROR_TYPES.has(error.type)
? { details: error.details }
: {}),
...resolvedDisplay
}
]
@@ -382,10 +390,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
groupsMap: Map<string, GroupEntry>,
filterBySelection = false
) {
if (!executionErrorStore.surfacedNodeErrors) return
if (!executionErrorStore.lastNodeErrors) return
for (const [rawNodeId, nodeError] of Object.entries(
executionErrorStore.surfacedNodeErrors
executionErrorStore.lastNodeErrors
)) {
const nodeId = tryNormalizeNodeExecutionId(rawNodeId)
if (!nodeId) continue

View File

@@ -1,6 +1,5 @@
<template>
<SidebarTabTemplate
ref="panelRef"
:title="isInFolderView ? '' : $t('sideToolbar.mediaAssets.title')"
v-bind="$attrs"
>
@@ -101,19 +100,18 @@
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
/>
<div v-else class="size-full">
<AssetsSidebarGridView
:assets="displayAssets"
:is-selected
:show-output-count
:get-output-count
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
@zoom="handleZoomClick"
@output-count-click="enterFolderView"
/>
</div>
<AssetsSidebarGridView
v-else
:assets="displayAssets"
:is-selected="isSelected"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
@zoom="handleZoomClick"
@output-count-click="enterFolderView"
/>
</div>
</template>
<template #footer>
@@ -127,13 +125,6 @@
/>
</template>
</SidebarTabTemplate>
<Teleport to="body">
<div
v-if="marqueeStyle"
class="pointer-events-none fixed z-9999 border border-primary-background bg-primary-background/20"
:style="marqueeStyle"
/>
</Teleport>
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
@@ -160,7 +151,6 @@
<script setup lang="ts">
import {
unrefElement,
useAsyncState,
useDebounceFn,
useStorage,
@@ -174,7 +164,6 @@ import {
onMounted,
onUnmounted,
ref,
useTemplateRef,
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
@@ -193,7 +182,6 @@ import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBa
import MediaAssetSelectionBar from '@/platform/assets/components/MediaAssetSelectionBar.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
import { useAssetGridSelection } from '@/platform/assets/composables/useAssetGridSelection'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
@@ -251,7 +239,7 @@ const contextMenuFileKind = computed<MediaKind>(() =>
getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '')
)
const showOutputCount = (item: AssetItem): boolean => {
const shouldShowOutputCount = (item: AssetItem): boolean => {
if (activeTab.value !== 'output' || isInFolderView.value) {
return false
}
@@ -271,10 +259,7 @@ const outputAssets = useAssetsApi('output')
// Asset selection
const {
isSelected,
selectedIds,
handleAssetClick,
selectAll,
setSelectedIds,
hasSelection,
clearSelection,
getSelectedAssets,
@@ -285,12 +270,6 @@ const {
deactivate: deactivateSelection
} = useAssetSelection()
const panelRef = useTemplateRef('panelRef')
const marqueePanelRef = computed(() => {
const el = unrefElement(panelRef)
return el instanceof HTMLElement ? el : undefined
})
const {
downloadAssets,
deleteAssets,
@@ -358,16 +337,6 @@ const visibleAssets = computed(() => {
return listViewSelectableAssets.value
})
const { marqueeStyle } = useAssetGridSelection({
marqueeContainerRef: marqueePanelRef,
hoverTargetRef: marqueePanelRef,
getAssets: () => visibleAssets.value,
getSelectedIds: () => [...selectedIds.value],
setSelectedIds,
selectAll,
isEnabled: () => !isListView.value
})
const previewableVisibleAssets = computed(() =>
visibleAssets.value.filter((asset) =>
isPreviewableMediaType(getMediaTypeFromFilename(asset.name))
@@ -606,7 +575,7 @@ const handleDeselectAll = () => {
}
const handleEmptySpaceClick = () => {
if (hasSelection.value) {
if (hasSelection) {
clearSelection()
}
}

View File

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

View File

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

View File

@@ -1,190 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { SubscriptionInfo } from '@/composables/billing/types'
import type {
Plan,
TeamCreditStops
} from '@/platform/workspace/api/workspaceApi'
import type { NextInvoiceInputs } from './useNextInvoice'
import { deriveNextInvoice } from './useNextInvoice'
function makeSubscription(
overrides: Partial<SubscriptionInfo> = {}
): SubscriptionInfo {
return {
isActive: true,
tier: 'STANDARD',
duration: 'MONTHLY',
planSlug: 'standard-monthly',
renewalDate: '2026-08-01T00:00:00Z',
endDate: null,
isCancelled: false,
hasFunds: true,
...overrides
}
}
function makePlan(overrides: Partial<Plan> = {}): Plan {
return {
slug: 'standard-monthly',
tier: 'STANDARD',
duration: 'MONTHLY',
price_cents: 2000,
credits_cents: 2000,
max_seats: 1,
availability: { available: true },
seat_summary: {
seat_count: 1,
total_cost_cents: 2000,
total_credits_cents: 2000
},
...overrides
}
}
// Both stop prices are per-month figures; price_cents is the discounted
// figure, kept distinct from list_price_cents and credits so a regression to
// either fails the amount assertions.
const teamCreditStops: TeamCreditStops = {
default_stop_index: 0,
stops: [
{
id: 'stop-320',
credits: 67520,
monthly: { list_price_cents: 32000, price_cents: 30400 },
yearly: { list_price_cents: 32000, price_cents: 28800 }
},
{
id: 'stop-640',
credits: 135040,
monthly: { list_price_cents: 64000, price_cents: 60800 },
yearly: { list_price_cents: 64000, price_cents: 57600 }
}
]
}
function makeInputs(
overrides: Partial<NextInvoiceInputs> = {}
): NextInvoiceInputs {
return {
subscription: makeSubscription(),
planSlug: 'standard-monthly',
plans: [makePlan()],
teamCreditStops: null,
currentTeamCreditStop: null,
...overrides
}
}
describe(deriveNextInvoice, () => {
it('resolves a monthly invoice from the current plan price by slug', () => {
expect(deriveNextInvoice(makeInputs())).toEqual({
amountCents: 2000,
renewalDate: '2026-08-01T00:00:00Z',
duration: 'MONTHLY'
})
})
it('prefers the subscribed team credit stop over the plan price', () => {
const inputs = makeInputs({
subscription: makeSubscription({ planSlug: 'team-pro-monthly' }),
planSlug: 'team-pro-monthly',
plans: [makePlan({ slug: 'team-pro-monthly', price_cents: 9999 })],
teamCreditStops,
currentTeamCreditStop: {
id: 'stop-320',
credits_monthly: 32000,
stop_usd: 320
}
})
expect(deriveNextInvoice(inputs)?.amountCents).toBe(30400)
})
it('multiplies the per-month yearly stop price by 12 for annual subs', () => {
const inputs = makeInputs({
subscription: makeSubscription({ duration: 'ANNUAL' }),
teamCreditStops,
currentTeamCreditStop: {
id: 'stop-640',
credits_monthly: 64000,
stop_usd: 640
}
})
expect(deriveNextInvoice(inputs)).toEqual({
amountCents: 57600 * 12,
renewalDate: '2026-08-01T00:00:00Z',
duration: 'ANNUAL'
})
})
it('uses the annual plan price_cents as the yearly total, unscaled', () => {
const inputs = makeInputs({
subscription: makeSubscription({
duration: 'ANNUAL',
planSlug: 'standard-yearly'
}),
planSlug: 'standard-yearly',
plans: [
makePlan({
slug: 'standard-yearly',
duration: 'ANNUAL',
price_cents: 21600
})
]
})
expect(deriveNextInvoice(inputs)).toEqual({
amountCents: 21600,
renewalDate: '2026-08-01T00:00:00Z',
duration: 'ANNUAL'
})
})
it('passes a null renewalDate through (scheduled-cancellation window)', () => {
const inputs = makeInputs({
subscription: makeSubscription({ renewalDate: null })
})
expect(deriveNextInvoice(inputs)?.renewalDate).toBeNull()
})
it('falls back to the plan price when the stop is not in the ladder', () => {
const inputs = makeInputs({
teamCreditStops,
currentTeamCreditStop: {
id: 'stop-unknown',
credits_monthly: 1000,
stop_usd: 10
}
})
expect(deriveNextInvoice(inputs)?.amountCents).toBe(2000)
})
it.for([
['no subscription', { subscription: null }],
[
'inactive subscription',
{ subscription: makeSubscription({ isActive: false }) }
],
[
'cancelled subscription',
{ subscription: makeSubscription({ isCancelled: true }) }
],
['unresolvable plan slug', { planSlug: 'unknown-plan' }],
[
'annual sub whose slug resolves only to a monthly plan',
{ subscription: makeSubscription({ duration: 'ANNUAL' }) }
],
['empty plan list (legacy billing)', { plans: [] }],
['zero-price plan (free tier)', { plans: [makePlan({ price_cents: 0 })] }]
] satisfies [string, Partial<NextInvoiceInputs>][])(
'returns null for %s',
([, overrides]) => {
expect(deriveNextInvoice(makeInputs(overrides))).toBeNull()
}
)
})

View File

@@ -1,96 +0,0 @@
import { computed } from 'vue'
import type { SubscriptionInfo } from '@/composables/billing/types'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import type {
CurrentTeamCreditStop,
Plan,
SubscriptionDuration,
TeamCreditStops
} from '@/platform/workspace/api/workspaceApi'
export interface NextInvoiceInputs {
subscription: SubscriptionInfo | null
planSlug: string | null
plans: Plan[]
teamCreditStops: TeamCreditStops | null
currentTeamCreditStop: CurrentTeamCreditStop | null
}
export interface NextInvoice {
amountCents: number
renewalDate: string | null
duration: SubscriptionDuration
}
/**
* Next invoice for the Settings > Invoices banner; annual subscriptions show
* their yearly total and renewal date. Unit semantics: credit-stop
* `yearly.price_cents` is a per-month figure (x12 for the invoice total)
* while an ANNUAL plan's `price_cents` is already the yearly total.
* `renewalDate` is BE-computed and passed through untouched — backends own
* period math including month-end bias — and goes null once a cancellation
* is scheduled. Cancelled/inactive return null because the cancelled Toast
* owns that state. A non-positive resolved amount also returns null (free
* tier can look like an active subscription with no real invoice).
* Intentionally limited to the subscription price: usage/overage pending
* charges are excluded until the backend exposes an authoritative
* upcoming-invoice amount.
*/
export function deriveNextInvoice({
subscription,
planSlug,
plans,
teamCreditStops,
currentTeamCreditStop
}: NextInvoiceInputs): NextInvoice | null {
if (!subscription?.isActive || subscription.isCancelled) return null
const duration = subscription.duration === 'ANNUAL' ? 'ANNUAL' : 'MONTHLY'
const stop = teamCreditStops?.stops.find(
({ id }) => id === currentTeamCreditStop?.id
)
const plan = plans.find(
(candidate) =>
candidate.slug === planSlug && candidate.duration === duration
)
const amountCents = stop
? duration === 'ANNUAL'
? stop.yearly.price_cents * 12
: stop.monthly.price_cents
: plan?.price_cents
if (!amountCents || amountCents <= 0) return null
return {
amountCents,
renewalDate: subscription.renewalDate,
duration
}
}
/**
* Callers own billing-context initialization; a null invoice hides the
* banner.
*/
export function useNextInvoice() {
const {
subscription,
currentPlanSlug,
plans,
teamCreditStops,
currentTeamCreditStop
} = useBillingContext()
const nextInvoice = computed(() =>
deriveNextInvoice({
subscription: subscription.value,
planSlug: currentPlanSlug.value,
plans: plans.value,
teamCreditStops: teamCreditStops.value,
currentTeamCreditStop: currentTeamCreditStop.value
})
)
return { nextInvoice }
}

View File

@@ -84,7 +84,7 @@ function reconcileNodeErrorFlags(
}
export function useNodeErrorFlagSync(
nodeErrors: Ref<Record<string, NodeError> | null>,
lastNodeErrors: Ref<Record<string, NodeError> | null>,
missingModelStore: ReturnType<typeof useMissingModelStore>,
missingMediaStore: ReturnType<typeof useMissingMediaStore>
): () => void {
@@ -95,7 +95,7 @@ export function useNodeErrorFlagSync(
const stop = watch(
[
nodeErrors,
lastNodeErrors,
() => missingModelStore.missingModelNodeIds,
() => missingMediaStore.missingMediaNodeIds,
showErrorsTab
@@ -108,7 +108,7 @@ export function useNodeErrorFlagSync(
// Vue nodes compute hasAnyError independently and are unaffected.
reconcileNodeErrorFlags(
app.rootGraph,
nodeErrors.value,
lastNodeErrors.value,
showErrorsTab.value
? missingModelStore.missingModelAncestorExecutionIds
: new Set(),

View File

@@ -1,209 +0,0 @@
import { ref } from 'vue'
import type { Ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
interface ViewportInstance {
ctorArgs: unknown[]
applyState: ReturnType<typeof vi.fn>
setGizmosVisible: ReturnType<typeof vi.fn>
setTransformGizmoMode: ReturnType<typeof vi.fn>
setLookThrough: ReturnType<typeof vi.fn>
remove: ReturnType<typeof vi.fn>
viewport: {
updateStatusMouseOnScene: ReturnType<typeof vi.fn>
updateStatusMouseOnNode: ReturnType<typeof vi.fn>
refreshViewport: ReturnType<typeof vi.fn>
}
}
const { ViewportMock, instances, addAlert } = vi.hoisted(() => {
const instances: ViewportInstance[] = []
const ViewportMock = vi.fn(function (...ctorArgs: unknown[]) {
const instance: ViewportInstance = {
ctorArgs,
applyState: vi.fn(),
setGizmosVisible: vi.fn(),
setTransformGizmoMode: vi.fn(),
setLookThrough: vi.fn(),
remove: vi.fn(),
viewport: {
updateStatusMouseOnScene: vi.fn(),
updateStatusMouseOnNode: vi.fn(),
refreshViewport: vi.fn()
}
}
instances.push(instance)
return instance
})
return { ViewportMock, instances, addAlert: vi.fn() }
})
vi.mock('@/extensions/core/cameraInfo/CameraInfoViewport', () => ({
CameraInfoViewport: ViewportMock
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ addAlert })
}))
import { useCameraInfo } from './useCameraInfo'
interface FakeWidget {
name: string
value: unknown
callback?: (value: unknown, ...rest: unknown[]) => void
}
interface FakeNode {
widgets: FakeWidget[]
onMouseEnter?: () => void
onMouseLeave?: () => void
}
function makeNode(values: Record<string, unknown>): FakeNode {
return {
widgets: Object.entries(values).map(([name, value]) => ({ name, value }))
}
}
function widget(node: FakeNode, name: string): FakeWidget {
const found = node.widgets.find((w) => w.name === name)
if (!found) throw new Error(`missing widget ${name}`)
return found
}
function nodeRef(node: FakeNode) {
return ref(node) as unknown as Ref<LGraphNode | null>
}
beforeEach(() => {
instances.length = 0
ViewportMock.mockClear()
addAlert.mockClear()
})
describe('useCameraInfo', () => {
it('constructs the viewport from widget state and exposes the mode', () => {
const node = makeNode({ mode: 'look_at', 'mode.distance': 7 })
const container = document.createElement('div')
const camera = useCameraInfo(nodeRef(node))
camera.initialize(container)
expect(ViewportMock).toHaveBeenCalledOnce()
const [ctorContainer, initialState] = instances[0].ctorArgs as [
HTMLElement,
{ mode: string; orbit: { distance: number } }
]
expect(ctorContainer).toBe(container)
expect(initialState.mode).toBe('look_at')
expect(initialState.orbit.distance).toBe(7)
expect(camera.mode.value).toBe('look_at')
})
it('does nothing when the node is null', () => {
const camera = useCameraInfo(ref(null))
camera.initialize(document.createElement('div'))
expect(ViewportMock).not.toHaveBeenCalled()
})
it('alerts and does not throw when the viewport fails to construct', () => {
ViewportMock.mockImplementationOnce(() => {
throw new Error('webgl unavailable')
})
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const camera = useCameraInfo(nodeRef(makeNode({ mode: 'orbit' })))
expect(() => camera.initialize(document.createElement('div'))).not.toThrow()
expect(addAlert).toHaveBeenCalledOnce()
consoleError.mockRestore()
})
it('forwards toolbar actions to the viewport', () => {
const camera = useCameraInfo(nodeRef(makeNode({ mode: 'orbit' })))
camera.initialize(document.createElement('div'))
camera.setGizmosVisible(false)
camera.setTransformGizmoMode('camera-rotate')
camera.setLookThrough(true)
expect(instances[0].setGizmosVisible).toHaveBeenCalledWith(false)
expect(instances[0].setTransformGizmoMode).toHaveBeenCalledWith(
'camera-rotate'
)
expect(instances[0].setLookThrough).toHaveBeenCalledWith(true)
})
it('ignores toolbar actions before initialization', () => {
const camera = useCameraInfo(nodeRef(makeNode({ mode: 'orbit' })))
expect(() => camera.setLookThrough(true)).not.toThrow()
expect(instances).toHaveLength(0)
})
it('re-applies state to the viewport when a widget changes, keeping the original callback', () => {
const node = makeNode({ mode: 'orbit', target_x: 0 })
const original = vi.fn()
widget(node, 'target_x').callback = original
const camera = useCameraInfo(nodeRef(node))
camera.initialize(document.createElement('div'))
const targetX = widget(node, 'target_x')
targetX.value = 3
targetX.callback!(3)
expect(original).toHaveBeenCalledWith(3)
const applied = instances[0].applyState.mock.lastCall?.[0] as {
target: { x: number }
}
expect(applied.target.x).toBe(3)
})
it('updates the mode ref when the mode widget changes', () => {
const node = makeNode({ mode: 'orbit' })
const camera = useCameraInfo(nodeRef(node))
camera.initialize(document.createElement('div'))
const modeWidget = widget(node, 'mode')
modeWidget.value = 'quaternion'
modeWidget.callback!('quaternion')
expect(camera.mode.value).toBe('quaternion')
})
it('routes node hover into the viewport status flags', () => {
const node = makeNode({ mode: 'orbit' })
const camera = useCameraInfo(nodeRef(node))
camera.initialize(document.createElement('div'))
camera.handleMouseEnter()
camera.handleMouseLeave()
node.onMouseEnter?.()
expect(instances[0].viewport.updateStatusMouseOnScene).toHaveBeenCalledWith(
true
)
expect(instances[0].viewport.updateStatusMouseOnScene).toHaveBeenCalledWith(
false
)
expect(instances[0].viewport.updateStatusMouseOnNode).toHaveBeenCalledWith(
true
)
})
it('removes the viewport and restores widget callbacks on cleanup', () => {
const node = makeNode({ mode: 'orbit', target_x: 0 })
const original = vi.fn()
widget(node, 'target_x').callback = original
const camera = useCameraInfo(nodeRef(node))
camera.initialize(document.createElement('div'))
camera.cleanup()
expect(instances[0].remove).toHaveBeenCalledOnce()
expect(widget(node, 'target_x').callback).toBe(original)
})
})

View File

@@ -1,151 +0,0 @@
import { ref, toRaw, toRef } from 'vue'
import type { MaybeRef } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { CameraInfoViewport } from '@/extensions/core/cameraInfo/CameraInfoViewport'
import type { TransformGizmoMode } from '@/extensions/core/cameraInfo/CameraInfoViewport'
import { DEFAULT_CAMERA_INFO_STATE } from '@/extensions/core/cameraInfo/types'
import type { CameraInfoMode } from '@/extensions/core/cameraInfo/types'
import {
readStateFromWidgets,
writeWidgetValue
} from '@/extensions/core/cameraInfo/widgetBridge'
import type { NodeWithWidgets } from '@/extensions/core/cameraInfo/widgetBridge'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useToastStore } from '@/platform/updates/common/toastStore'
type WidgetCallback = (value: unknown, ...rest: unknown[]) => void
interface MutableWidget {
name: string
value: unknown
callback?: WidgetCallback
}
const WIDGET_NAMES = [
'mode',
'camera_type',
'target_x',
'target_y',
'target_z',
'roll',
'fov',
'zoom',
'mode.yaw',
'mode.pitch',
'mode.distance',
'mode.position_x',
'mode.position_y',
'mode.position_z',
'mode.quat_x',
'mode.quat_y',
'mode.quat_z',
'mode.quat_w'
] as const
export function useCameraInfo(nodeRef: MaybeRef<LGraphNode | null>) {
const node = toRef(nodeRef)
let viewport: CameraInfoViewport | null = null
const mode = ref<CameraInfoMode>(DEFAULT_CAMERA_INFO_STATE.mode)
const wrappedWidgets: { widget: MutableWidget; original?: WidgetCallback }[] =
[]
const wrappedSet = new WeakSet<MutableWidget>()
const initialize = (container: HTMLElement): void => {
const raw = toRaw(node.value)
if (!raw || !container) return
try {
const initialState = readStateFromWidgets(raw as NodeWithWidgets)
mode.value = initialState.mode
viewport = new CameraInfoViewport(container, initialState, {
onHandleDrag: (fieldName, value) => {
writeWidgetValue(raw as NodeWithWidgets, fieldName, value)
}
})
wireWidgetsToOverlay(raw as NodeWithWidgets)
wireNodeMouseStatus(raw as LGraphNode)
} catch (error) {
console.error('Failed to initialize CameraInfoViewport:', error)
useToastStore().addAlert('Failed to initialize Camera Info viewport.')
}
}
const cleanup = (): void => {
unwireWidgets()
viewport?.remove()
viewport = null
}
const handleMouseEnter = (): void => {
viewport?.viewport.updateStatusMouseOnScene(true)
viewport?.viewport.refreshViewport()
}
const handleMouseLeave = (): void => {
viewport?.viewport.updateStatusMouseOnScene(false)
}
const setGizmosVisible = (on: boolean): void => {
viewport?.setGizmosVisible(on)
}
const setTransformGizmoMode = (gizmoMode: TransformGizmoMode): void => {
viewport?.setTransformGizmoMode(gizmoMode)
}
const setLookThrough = (on: boolean): void => {
viewport?.setLookThrough(on)
}
function wireNodeMouseStatus(target: LGraphNode): void {
target.onMouseEnter = useChainCallback(target.onMouseEnter, () => {
viewport?.viewport.updateStatusMouseOnNode(true)
viewport?.viewport.refreshViewport()
})
target.onMouseLeave = useChainCallback(target.onMouseLeave, () => {
viewport?.viewport.updateStatusMouseOnNode(false)
})
}
function wireWidgetsToOverlay(target: NodeWithWidgets): void {
if (!target.widgets) return
for (const name of WIDGET_NAMES) {
const widget = target.widgets.find(
(w): w is MutableWidget => w.name === name
)
if (!widget || wrappedSet.has(widget)) continue
wrappedSet.add(widget)
const original = widget.callback
const isModeWidget = widget.name === 'mode'
wrappedWidgets.push({ widget, original })
widget.callback = (value, ...rest) => {
original?.call(widget, value, ...rest)
if (isModeWidget) wireWidgetsToOverlay(target)
if (!viewport) return
const state = readStateFromWidgets(target)
mode.value = state.mode
viewport.applyState(state)
}
}
}
function unwireWidgets(): void {
for (const { widget, original } of wrappedWidgets) {
widget.callback = original
}
wrappedWidgets.length = 0
}
return {
initialize,
cleanup,
handleMouseEnter,
handleMouseLeave,
setGizmosVisible,
setTransformGizmoMode,
setLookThrough,
mode
}
}

View File

@@ -143,9 +143,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const cameraConfig = ref<CameraConfig>({
cameraType: 'perspective',
fov: 75,
hasCustomUp: false,
useCustomUp: false
fov: 75
})
const lightConfig = ref<LightConfig>({
@@ -536,9 +534,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
nodeRef.value.properties['Camera Config'] = newValue
load3d.toggleCamera(newValue.cameraType)
load3d.setFOV(newValue.fov)
if (newValue.hasCustomUp) {
load3d.setUseCustomUp(newValue.useCustomUp ?? false)
}
}
markDirty()
},
@@ -876,13 +871,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
cameraTypeChange: (value: string) => {
cameraConfig.value.cameraType = value as CameraType
},
cameraUpStateChange: (value: {
hasCustomUp: boolean
usingCustomUp: boolean
}) => {
cameraConfig.value.hasCustomUp = value.hasCustomUp
cameraConfig.value.useCustomUp = value.usingCustomUp
},
showGridChange: (value: boolean) => {
sceneConfig.value.showGrid = value
},

View File

@@ -1,299 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createBoundaryLinkedSubgraph,
createTestRootGraph,
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { toNodeId } from '@/types/nodeId'
import { liftNodeErrorsToBoundary } from './liftNodeErrorsToBoundary'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('liftNodeErrorsToBoundary', () => {
it('lifts a boundary-linked slot error to the host', () => {
const { host, rootGraph } = createBoundaryLinkedSubgraph()
const errors = {
'12:5': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
const result = liftNodeErrorsToBoundary(rootGraph, errors)
expect(result).toEqual({
'12': {
class_type: host.title,
dependent_outputs: [],
errors: [
expect.objectContaining({
type: 'required_input_missing',
extra_info: expect.objectContaining({
input_name: 'seed',
source_execution_id: '12:5',
source_input_name: 'seed_input'
})
})
]
}
})
})
it('lifts a promoted-widget value error to the host input', () => {
const rootGraph = createTestRootGraph()
const subgraph = createTestSubgraph({ rootGraph })
const host = createTestSubgraphNode(subgraph, { id: 12 })
rootGraph.add(host)
const interior = new LGraphNode('CheckpointLoaderSimple')
interior.id = toNodeId(5)
const input = interior.addInput('ckpt_name', 'COMBO')
const widget = interior.addWidget('combo', 'ckpt_name', '', () => {}, {
values: ['present.safetensors']
})
input.widget = { name: widget.name }
subgraph.add(interior)
expect(promoteValueWidgetViaSubgraphInput(host, interior, widget).ok).toBe(
true
)
const result = liftNodeErrorsToBoundary(rootGraph, {
'12:5': nodeError([
validationError('value_not_in_list', 'ckpt_name', {
received_value: 'missing.safetensors',
input_config: ['COMBO', { values: ['present.safetensors'] }]
})
])
})
expect(result['12'].errors[0].extra_info).toMatchObject({
input_name: 'ckpt_name',
source_execution_id: '12:5',
source_input_name: 'ckpt_name',
received_value: 'missing.safetensors',
input_config: ['COMBO', { values: ['present.safetensors'] }]
})
})
it('recurses through nested boundary-linked hosts', () => {
const rootGraph = createTestRootGraph()
const outerSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 })
outerHost.title = 'Outer Host'
rootGraph.add(outerHost)
const middleSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const middleHost = createTestSubgraphNode(middleSubgraph, {
id: 2,
parentGraph: outerSubgraph
})
outerSubgraph.add(middleHost)
outerSubgraph.inputNode.slots[0].connect(middleHost.inputs[0], middleHost)
const leaf = new LGraphNode('LeafNode')
leaf.id = toNodeId(3)
const leafInput = leaf.addInput('seed_input', '*')
middleSubgraph.add(leaf)
middleSubgraph.inputNode.slots[0].connect(leafInput, leaf)
const result = liftNodeErrorsToBoundary(rootGraph, {
'1:2:3': nodeError([
validationError('required_input_missing', 'seed_input')
])
})
expect(Object.keys(result)).toEqual(['1'])
expect(result['1'].class_type).toBe(outerHost.title)
expect(result['1'].errors[0].extra_info).toMatchObject({
input_name: 'seed',
source_execution_id: '1:2:3',
source_input_name: 'seed_input'
})
})
it('keeps errors on ordinary interior data-flow links', () => {
const rootGraph = createTestRootGraph()
const subgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const host = createTestSubgraphNode(subgraph, { id: 12 })
rootGraph.add(host)
const source = new LGraphNode('SourceNode')
source.id = toNodeId(4)
source.addOutput('seed', '*')
subgraph.add(source)
const target = new LGraphNode('TargetNode')
target.id = toNodeId(5)
target.addInput('seed_input', '*')
subgraph.add(target)
source.connect(0, target, 0)
const errors = {
'12:5': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
})
it('keeps errors without a liftable subject on the interior node', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
const errors = {
'12:5': nodeError([
validationError('required_input_missing'),
validationError('exception_during_validation', 'seed_input'),
validationError('dependency_cycle', 'seed_input'),
validationError(
'custom_validation_failed',
'seed_input',
{ received_value: 'image.png' },
'Invalid image file'
)
])
}
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
})
it('keeps unknown typed validation errors on the interior node', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
const errors = {
'12:5': nodeError([
validationError('future_backend_validation_type', 'seed_input')
])
}
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
})
it('splits liftable and non-liftable errors from the same node entry', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
const result = liftNodeErrorsToBoundary(rootGraph, {
'12:5': nodeError([
validationError('required_input_missing', 'seed_input'),
validationError('exception_during_validation', 'seed_input')
])
})
expect(result['12'].errors).toHaveLength(1)
expect(result['12'].errors[0].type).toBe('required_input_missing')
expect(result['12:5'].errors).toHaveLength(1)
expect(result['12:5'].errors[0].type).toBe('exception_during_validation')
})
it('merges a lifted error into an existing host entry', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
const errors = {
'12': {
class_type: 'ExistingHostClass',
dependent_outputs: ['existing-output'],
errors: [validationError('value_smaller_than_min', 'other')]
},
'12:5': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
const result = liftNodeErrorsToBoundary(rootGraph, errors)
expect(result['12']).toMatchObject({
class_type: 'ExistingHostClass',
dependent_outputs: ['existing-output']
})
expect(result['12'].errors.map((error) => error.type)).toEqual([
'value_smaller_than_min',
'required_input_missing'
])
})
it('keeps own errors before lifted errors for nested host keys', () => {
const rootGraph = createTestRootGraph()
const outerSubgraph = createTestSubgraph({ rootGraph })
const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 })
rootGraph.add(outerHost)
const middleSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const middleHost = createTestSubgraphNode(middleSubgraph, {
id: 2,
parentGraph: outerSubgraph
})
outerSubgraph.add(middleHost)
const leaf = new LGraphNode('LeafNode')
leaf.id = toNodeId(3)
const leafInput = leaf.addInput('seed_input', '*')
middleSubgraph.add(leaf)
middleSubgraph.inputNode.slots[0].connect(leafInput, leaf)
const result = liftNodeErrorsToBoundary(rootGraph, {
'1:2:3': nodeError([
validationError('required_input_missing', 'seed_input')
]),
'1:2': nodeError([validationError('value_smaller_than_min', 'seed')])
})
expect(result['1:2'].errors.map((error) => error.type)).toEqual([
'value_smaller_than_min',
'required_input_missing'
])
})
it('preserves empty error entries unchanged', () => {
const rootGraph = createTestRootGraph()
const errors = {
'12': nodeError([], 'ExtraRootNode')
}
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
})
it('fails open without mutating the input record', () => {
const rootGraph = createTestRootGraph()
const subgraph = createTestSubgraph({ rootGraph })
const host = createTestSubgraphNode(subgraph, { id: 12 })
rootGraph.add(host)
const interior = new LGraphNode('InteriorNode')
interior.id = toNodeId(5)
interior.addInput('unlinked', '*')
subgraph.add(interior)
const errors = {
'99:5': nodeError([validationError('required_input_missing', 'x')]),
'12:5': nodeError([
validationError('required_input_missing', 'missing'),
validationError('value_not_in_list', 'unlinked')
])
}
const original = structuredClone(errors)
const result = liftNodeErrorsToBoundary(rootGraph, errors)
expect(result).toEqual(original)
expect(errors).toEqual(original)
expect(result).not.toBe(errors)
})
})

View File

@@ -1,193 +0,0 @@
import { groupBy, partition } from 'es-toolkit'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type { NodeError } from '@/schemas/apiSchema'
import { tryNormalizeNodeExecutionId } from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { isNodeLevelValidationError } from '@/utils/executionErrorUtil'
import type { NodeValidationError } from '@/utils/executionErrorUtil'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { isSubgraph } from '@/utils/typeGuardUtil'
export interface LiftedErrorExtraInfo {
input_name: string
source_execution_id: string
source_input_name: string
}
export interface LiftedSurface {
hostExecId: NodeExecutionId
hostInputName: string
}
interface ErrorPlacement {
kind: 'own' | 'lifted'
targetExecId: string
error: NodeValidationError
}
export function getLiftedErrorSource(
error: NodeValidationError
): LiftedErrorExtraInfo | null {
const extraInfo = error.extra_info
if (!extraInfo) return null
const { input_name, source_execution_id, source_input_name } = extraInfo
if (
typeof input_name !== 'string' ||
typeof source_execution_id !== 'string' ||
typeof source_input_name !== 'string'
) {
return null
}
return { input_name, source_execution_id, source_input_name }
}
function getHostExecutionId(executionId: string): NodeExecutionId | null {
const separatorIndex = executionId.lastIndexOf(':')
if (separatorIndex <= 0) return null
return tryNormalizeNodeExecutionId(executionId.slice(0, separatorIndex))
}
/**
* Boundary surfaces that expose `(executionId, inputName)`, innermost first.
* Walks one host per level and stops at the last resolvable surface, so an
* unresolvable deeper host falls back to the shallower one (fail-open).
*/
export function resolveLiftChain(
rootGraph: LGraph,
executionId: string,
inputName: string
): LiftedSurface[] {
const chain: LiftedSurface[] = []
let currentExecId = executionId
let currentInputName = inputName
for (;;) {
const node = getNodeByExecutionId(rootGraph, currentExecId)
const graph = node?.graph
if (!node || !graph || !isSubgraph(graph)) break
const slot = node.inputs?.find((input) => input.name === currentInputName)
if (slot?.link == null) break
const subgraphInput = graph
.getLink(slot.link)
?.resolve(graph)?.subgraphInput
if (!subgraphInput) break
const hostExecId = getHostExecutionId(currentExecId)
if (!hostExecId || !getNodeByExecutionId(rootGraph, hostExecId)) break
chain.push({ hostExecId, hostInputName: subgraphInput.name })
currentExecId = hostExecId
currentInputName = subgraphInput.name
}
return chain
}
function createEmptyNodeError(nodeError: NodeError): NodeError {
return {
...nodeError,
errors: []
}
}
// Lifted host entries use the host title for display; SubgraphNode.type is a UUID.
function createLiftedHostEntry(
rootGraph: LGraph,
hostExecId: string
): NodeError {
return {
class_type:
getNodeByExecutionId(rootGraph, hostExecId)?.title ?? hostExecId,
dependent_outputs: [],
errors: []
}
}
function toErrorPlacement(
rootGraph: LGraph,
executionId: string,
error: NodeValidationError
): ErrorPlacement {
const inputName = error.extra_info?.input_name
const surface =
inputName && !isNodeLevelValidationError(error)
? resolveLiftChain(rootGraph, executionId, inputName).at(-1)
: undefined
if (!inputName || !surface) {
return {
kind: 'own',
targetExecId: executionId,
error
}
}
const liftedExtraInfo: LiftedErrorExtraInfo = {
input_name: surface.hostInputName,
source_execution_id: executionId,
source_input_name: inputName
}
return {
kind: 'lifted',
targetExecId: surface.hostExecId,
error: {
...error,
extra_info: {
...error.extra_info,
...liftedExtraInfo
}
}
}
}
export function liftNodeErrorsToBoundary(
rootGraph: LGraph,
nodeErrors: Record<string, NodeError>
): Record<string, NodeError> {
const output: Record<string, NodeError> = {}
const placements = Object.entries(nodeErrors).flatMap(
([executionId, nodeError]) =>
nodeError.errors.map((error) =>
toErrorPlacement(rootGraph, executionId, error)
)
)
for (const [executionId, nodeError] of Object.entries(nodeErrors)) {
if (nodeError.errors.length === 0) {
output[executionId] = createEmptyNodeError(nodeError)
}
}
const placementsByTarget = groupBy(
placements,
(placement) => placement.targetExecId
)
for (const [targetExecId, targetPlacements] of Object.entries(
placementsByTarget
)) {
const baseEntry = nodeErrors[targetExecId]
? createEmptyNodeError(nodeErrors[targetExecId])
: createLiftedHostEntry(rootGraph, targetExecId)
const [ownErrors, liftedErrors] = partition(
targetPlacements,
(placement) => placement.kind === 'own'
)
output[targetExecId] = {
...baseEntry,
errors: [...ownErrors, ...liftedErrors].map(
(placement) => placement.error
)
}
}
return output
}

View File

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

View File

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

View File

@@ -1,40 +0,0 @@
import { nextTick } from 'vue'
import CameraInfo from '@/components/cameraInfo/CameraInfo.vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
name: 'Comfy.CreateCameraInfo',
getCustomWidgets() {
return {
CAMERA_INFO_STATE(node) {
const widget = new ComponentWidgetImpl({
node,
name: 'camera_info_state',
component: CameraInfo,
inputSpec: {
name: 'camera_info_state',
type: 'CAMERA_INFO_STATE',
isPreview: false
},
options: {}
})
widget.type = 'cameraInfo'
addWidget(node, widget)
return { widget }
}
}
},
async nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== 'CreateCameraInfo') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 360), Math.max(oldHeight, 480)])
await nextTick()
}
})

View File

@@ -1,148 +0,0 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { CameraInfoOverlay } from './CameraInfoOverlay'
import { DEFAULT_CAMERA_INFO_STATE, type CameraInfoState } from './types'
function setupOverlay(initial?: Partial<CameraInfoState>) {
const overlay = new CameraInfoOverlay({
...DEFAULT_CAMERA_INFO_STATE,
...initial
})
const scene = new THREE.Scene()
overlay.attach(scene)
return { overlay, scene }
}
function findByName(scene: THREE.Scene, name: string): THREE.Object3D | null {
for (const child of scene.children) {
if (child.name === name) return child
}
return null
}
describe('CameraInfoOverlay', () => {
let ctx: ReturnType<typeof setupOverlay>
beforeEach(() => {
ctx = setupOverlay()
})
afterEach(() => {
ctx.overlay.dispose()
})
describe('attach / detach', () => {
it('adds reference + subject cameras + camera helper to the scene', () => {
expect(findByName(ctx.scene, 'CameraInfoReference')).not.toBeNull()
const subjectCameras = ctx.scene.children.filter(
(c) =>
c instanceof THREE.PerspectiveCamera ||
c instanceof THREE.OrthographicCamera
)
expect(subjectCameras).toHaveLength(2)
expect(
ctx.scene.children.some((c) => c instanceof THREE.CameraHelper)
).toBe(true)
})
it('detach removes everything the overlay added', () => {
ctx.overlay.detach()
expect(findByName(ctx.scene, 'CameraInfoReference')).toBeNull()
expect(
ctx.scene.children.some(
(c) =>
c instanceof THREE.PerspectiveCamera ||
c instanceof THREE.OrthographicCamera ||
c instanceof THREE.CameraHelper
)
).toBe(false)
})
})
describe('applyState — orbit mode', () => {
it('positions the active subject camera per yaw / pitch / distance', () => {
ctx.overlay.applyState({
...DEFAULT_CAMERA_INFO_STATE,
mode: 'orbit',
target: { x: 0, y: 0, z: 0 },
orbit: { yaw: 0, pitch: 0, distance: 5 }
})
const cam = ctx.overlay.getSubjectCamera()
expect(cam.position.x).toBeCloseTo(0)
expect(cam.position.y).toBeCloseTo(0)
expect(cam.position.z).toBeCloseTo(5)
})
it('updates fov on the perspective subject camera', () => {
ctx.overlay.applyState({
...DEFAULT_CAMERA_INFO_STATE,
fov: 70
})
const cam = ctx.overlay.getSubjectCamera() as THREE.PerspectiveCamera
expect(cam.fov).toBe(70)
})
})
describe('camera type swap', () => {
it('toggling to orthographic swaps the active subject camera and rebuilds the helper', () => {
const initialHelper = ctx.scene.children.find(
(c) => c instanceof THREE.CameraHelper
)
ctx.overlay.applyState({
...DEFAULT_CAMERA_INFO_STATE,
cameraType: 'orthographic'
})
expect(ctx.overlay.getSubjectCamera()).toBeInstanceOf(
THREE.OrthographicCamera
)
const newHelper = ctx.scene.children.find(
(c) => c instanceof THREE.CameraHelper
)
expect(newHelper).toBeDefined()
expect(newHelper).not.toBe(initialHelper)
})
})
describe('POV (onActiveCameraChange)', () => {
it('hides the camera helper when the render camera IS the subject camera', () => {
const subjectCam = ctx.overlay.getSubjectCamera()
ctx.overlay.onActiveCameraChange(subjectCam)
const helper = ctx.scene.children.find(
(c): c is THREE.CameraHelper => c instanceof THREE.CameraHelper
)!
expect(helper.visible).toBe(false)
})
it('shows the camera helper when render camera is some other camera', () => {
const subjectCam = ctx.overlay.getSubjectCamera()
ctx.overlay.onActiveCameraChange(subjectCam)
const otherCam = new THREE.PerspectiveCamera()
ctx.overlay.onActiveCameraChange(otherCam)
const helper = ctx.scene.children.find(
(c): c is THREE.CameraHelper => c instanceof THREE.CameraHelper
)!
expect(helper.visible).toBe(true)
})
})
describe('getState immutability', () => {
it('returns a deep clone the caller cannot mutate to affect overlay', () => {
const snapshot = ctx.overlay.getState()
snapshot.orbit.yaw = 999
const next = ctx.overlay.getState()
expect(next.orbit.yaw).not.toBe(999)
expect(next.target).not.toBe(snapshot.target)
})
})
})

View File

@@ -1,213 +0,0 @@
import * as THREE from 'three'
import type { SceneOverlay } from '@/extensions/core/load3d/interfaces'
import { computeSubjectTransform } from './cameraTransform'
import {
DEFAULT_CAMERA_INFO_STATE,
type CameraInfoCameraType,
type CameraInfoState
} from './types'
const REFERENCE_CUBE_SIZE = 1
const SUBJECT_CAMERA_NEAR = 0.1
const SUBJECT_CAMERA_FAR = 1000
const ORTHO_FRUSTUM_HALF = 1
export class CameraInfoOverlay implements SceneOverlay {
private scene: THREE.Scene | null = null
private state: CameraInfoState
private readonly subjectPerspective: THREE.PerspectiveCamera
private readonly subjectOrthographic: THREE.OrthographicCamera
private subjectCamera: THREE.Camera
private cameraHelper: THREE.CameraHelper | null = null
private readonly referenceGroup: THREE.Group
private readonly referenceCube: THREE.Mesh
private readonly referenceCubeEdges: THREE.LineSegments
private readonly axesHelper: THREE.AxesHelper
private renderCamera: THREE.Camera | null = null
private disposed = false
constructor(initialState: CameraInfoState = DEFAULT_CAMERA_INFO_STATE) {
this.state = cloneState(initialState)
this.subjectPerspective = new THREE.PerspectiveCamera(
this.state.fov,
1,
SUBJECT_CAMERA_NEAR,
SUBJECT_CAMERA_FAR
)
this.subjectOrthographic = new THREE.OrthographicCamera(
-ORTHO_FRUSTUM_HALF,
ORTHO_FRUSTUM_HALF,
ORTHO_FRUSTUM_HALF,
-ORTHO_FRUSTUM_HALF,
SUBJECT_CAMERA_NEAR,
SUBJECT_CAMERA_FAR
)
this.subjectCamera = this.subjectCameraFor(this.state.cameraType)
const cubeGeometry = new THREE.BoxGeometry(
REFERENCE_CUBE_SIZE,
REFERENCE_CUBE_SIZE,
REFERENCE_CUBE_SIZE
)
this.referenceCube = new THREE.Mesh(
cubeGeometry,
new THREE.MeshStandardMaterial({
color: 0xb8bcc4,
roughness: 0.55,
metalness: 0.15
})
)
this.referenceCube.position.set(0, REFERENCE_CUBE_SIZE / 2, 0)
this.referenceCubeEdges = new THREE.LineSegments(
new THREE.EdgesGeometry(cubeGeometry),
new THREE.LineBasicMaterial({
color: 0x1a1a1a,
transparent: true,
opacity: 0.4
})
)
this.referenceCube.add(this.referenceCubeEdges)
this.axesHelper = new THREE.AxesHelper(REFERENCE_CUBE_SIZE * 1.25)
this.referenceGroup = new THREE.Group()
this.referenceGroup.name = 'CameraInfoReference'
this.referenceGroup.add(this.referenceCube)
this.referenceGroup.add(this.axesHelper)
}
attach(scene: THREE.Scene): void {
this.scene = scene
scene.add(this.referenceGroup)
scene.add(this.subjectPerspective)
scene.add(this.subjectOrthographic)
this.rebuildCameraHelper()
this.applyStateToScene()
}
detach(): void {
if (!this.scene) return
this.scene.remove(this.referenceGroup)
this.scene.remove(this.subjectPerspective)
this.scene.remove(this.subjectOrthographic)
if (this.cameraHelper) this.scene.remove(this.cameraHelper)
this.scene = null
}
update(_delta: number): void {
this.cameraHelper?.update()
}
onActiveCameraChange(camera: THREE.Camera): void {
this.renderCamera = camera
this.refreshHelperVisibility()
}
dispose(): void {
if (this.disposed) return
this.disposed = true
this.disposeCameraHelper()
this.referenceCube.geometry.dispose()
;(this.referenceCube.material as THREE.Material).dispose()
this.referenceCubeEdges.geometry.dispose()
;(this.referenceCubeEdges.material as THREE.Material).dispose()
this.axesHelper.dispose()
}
getSubjectCamera(): THREE.Camera {
return this.subjectCamera
}
setHelperVisible(visible: boolean): void {
if (this.cameraHelper) this.cameraHelper.visible = visible
}
getState(): CameraInfoState {
return cloneState(this.state)
}
applyState(next: CameraInfoState): void {
const cameraTypeChanged = next.cameraType !== this.state.cameraType
const modeChanged = next.mode !== this.state.mode
this.state = cloneState(next)
if (cameraTypeChanged) {
this.subjectCamera = this.subjectCameraFor(this.state.cameraType)
this.rebuildCameraHelper()
}
this.applyStateToScene()
if (modeChanged || cameraTypeChanged) this.refreshHelperVisibility()
}
private applyStateToScene(): void {
const { position, quaternion } = computeSubjectTransform(this.state)
this.subjectCamera.position.copy(position)
this.subjectCamera.quaternion.copy(quaternion)
this.subjectCamera.updateMatrixWorld(true)
if (this.subjectCamera instanceof THREE.PerspectiveCamera) {
this.subjectCamera.fov = this.state.fov
this.subjectCamera.zoom = this.state.zoom
this.subjectCamera.updateProjectionMatrix()
} else if (this.subjectCamera instanceof THREE.OrthographicCamera) {
this.subjectCamera.zoom = this.state.zoom
this.subjectCamera.updateProjectionMatrix()
}
this.cameraHelper?.update()
}
private subjectCameraFor(type: CameraInfoCameraType): THREE.Camera {
return type === 'perspective'
? this.subjectPerspective
: this.subjectOrthographic
}
private rebuildCameraHelper(): void {
this.disposeCameraHelper()
if (!this.scene) return
this.cameraHelper = new THREE.CameraHelper(this.subjectCamera)
this.scene.add(this.cameraHelper)
this.refreshHelperVisibility()
}
private disposeCameraHelper(): void {
if (!this.cameraHelper) return
if (this.scene) this.scene.remove(this.cameraHelper)
this.cameraHelper.geometry.dispose()
const material = this.cameraHelper.material as
| THREE.Material
| THREE.Material[]
if (Array.isArray(material)) material.forEach((m) => m.dispose())
else material.dispose()
this.cameraHelper = null
}
private refreshHelperVisibility(): void {
if (!this.cameraHelper) return
this.cameraHelper.visible = this.renderCamera !== this.subjectCamera
}
}
function cloneState(state: CameraInfoState): CameraInfoState {
return {
mode: state.mode,
target: { ...state.target },
roll: state.roll,
fov: state.fov,
zoom: state.zoom,
cameraType: state.cameraType,
orbit: { ...state.orbit },
lookAt: { position: { ...state.lookAt.position } },
quaternion: {
position: { ...state.quaternion.position },
quat: { ...state.quaternion.quat }
}
}
}

View File

@@ -1,305 +0,0 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { createViewport3dMock } = vi.hoisted(() => ({
createViewport3dMock: vi.fn()
}))
vi.mock('@/extensions/core/load3d/createViewport3d', () => ({
createViewport3d: createViewport3dMock
}))
import { CameraInfoViewport } from './CameraInfoViewport'
function makeViewportStub() {
const canvas = document.createElement('canvas')
canvas.width = 800
canvas.height = 600
Object.defineProperty(canvas, 'clientWidth', {
value: 800,
configurable: true
})
Object.defineProperty(canvas, 'clientHeight', {
value: 600,
configurable: true
})
canvas.setPointerCapture = vi.fn()
canvas.releasePointerCapture = vi.fn()
canvas.hasPointerCapture = vi.fn(() => false)
let postRender: (() => void) | null = null
return {
sceneManager: { scene: new THREE.Scene() },
cameraManager: { activeCamera: new THREE.PerspectiveCamera() },
controlsManager: { controls: { enabled: true } },
viewHelperManager: { visibleViewHelper: vi.fn() },
renderer: {
setViewport: vi.fn(),
setScissor: vi.fn(),
setScissorTest: vi.fn(),
setClearColor: vi.fn(),
clear: vi.fn(),
render: vi.fn()
},
domElement: canvas,
setOverlay: vi.fn(),
addPostRenderCallback: vi.fn((cb: () => void) => {
postRender = cb
return vi.fn()
}),
setExternalActiveCamera: vi.fn(),
forceRender: vi.fn(),
remove: vi.fn(),
runPostRender: () => postRender?.()
}
}
describe('CameraInfoViewport look-through', () => {
let stub: ReturnType<typeof makeViewportStub>
let viewport: CameraInfoViewport
beforeEach(() => {
stub = makeViewportStub()
createViewport3dMock.mockReturnValue(stub)
viewport = new CameraInfoViewport(document.createElement('div'))
})
afterEach(() => {
viewport.remove()
})
it('renders the main viewport through the subject camera', () => {
viewport.setLookThrough(true)
expect(stub.setExternalActiveCamera).toHaveBeenCalledWith(
viewport.overlay.getSubjectCamera()
)
expect(viewport.orbitHandles.isVisible()).toBe(false)
})
it('restores the orbit view and keeps the gnomon hidden on exit', () => {
viewport.setLookThrough(true)
stub.viewHelperManager.visibleViewHelper.mockClear()
viewport.setLookThrough(false)
expect(stub.setExternalActiveCamera).toHaveBeenLastCalledWith(null)
expect(viewport.orbitHandles.isVisible()).toBe(true)
expect(stub.viewHelperManager.visibleViewHelper).toHaveBeenLastCalledWith(
false
)
})
it('is idempotent for repeated toggles', () => {
viewport.setLookThrough(true)
viewport.setLookThrough(true)
expect(stub.setExternalActiveCamera).toHaveBeenCalledTimes(1)
})
it('re-syncs the external camera when the camera type changes while looking through', () => {
viewport.setLookThrough(true)
stub.setExternalActiveCamera.mockClear()
const state = viewport.overlay.getState()
viewport.applyState({ ...state, cameraType: 'orthographic' })
expect(stub.setExternalActiveCamera).toHaveBeenCalledWith(
viewport.overlay.getSubjectCamera()
)
})
it('leaves the external camera untouched on state changes outside look-through', () => {
viewport.applyState(viewport.overlay.getState())
expect(stub.setExternalActiveCamera).not.toHaveBeenCalled()
})
it('keeps handles hidden while looking through even when gizmos are toggled', () => {
viewport.setLookThrough(true)
viewport.setGizmosVisible(false)
expect(viewport.orbitHandles.isVisible()).toBe(false)
})
})
describe('CameraInfoViewport gizmo visibility', () => {
let stub: ReturnType<typeof makeViewportStub>
let viewport: CameraInfoViewport
beforeEach(() => {
stub = makeViewportStub()
createViewport3dMock.mockReturnValue(stub)
viewport = new CameraInfoViewport(document.createElement('div'))
})
afterEach(() => {
viewport.remove()
})
it('hides orbit handles when gizmos are turned off', () => {
expect(viewport.orbitHandles.isVisible()).toBe(true)
viewport.setGizmosVisible(false)
expect(viewport.orbitHandles.isVisible()).toBe(false)
viewport.setGizmosVisible(true)
expect(viewport.orbitHandles.isVisible()).toBe(true)
})
it('reveals the target handle in target transform mode', () => {
viewport.setTransformGizmoMode('target')
expect(viewport.targetHandle.isVisible()).toBe(true)
})
it('enables the camera handle for camera-rotate in quaternion mode', () => {
viewport.applyState({ ...viewport.overlay.getState(), mode: 'quaternion' })
viewport.setTransformGizmoMode('camera-rotate')
expect(viewport.cameraHandle.isVisible()).toBe(true)
})
})
describe('CameraInfoViewport subject preview', () => {
let stub: ReturnType<typeof makeViewportStub>
let viewport: CameraInfoViewport
beforeEach(() => {
stub = makeViewportStub()
createViewport3dMock.mockReturnValue(stub)
viewport = new CameraInfoViewport(document.createElement('div'))
})
afterEach(() => {
viewport.remove()
})
it('renders the corner preview after each frame in the editing view', () => {
stub.runPostRender()
expect(stub.renderer.render).toHaveBeenCalledOnce()
})
it('renders the preview for an orthographic subject camera too', () => {
viewport.applyState({
...viewport.overlay.getState(),
cameraType: 'orthographic'
})
stub.renderer.render.mockClear()
stub.runPostRender()
expect(stub.renderer.render).toHaveBeenCalledOnce()
})
it('skips the corner preview while looking through', () => {
viewport.setLookThrough(true)
stub.renderer.render.mockClear()
stub.runPostRender()
expect(stub.renderer.render).not.toHaveBeenCalled()
})
})
function dispatchPointer(
target: HTMLElement,
type: string,
clientX: number,
clientY: number
): void {
const event = new MouseEvent(type, { clientX, clientY, button: 0 })
Object.defineProperty(event, 'pointerId', { value: 1 })
target.dispatchEvent(event)
}
describe('CameraInfoViewport look-through drag', () => {
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0)
return 1
})
vi.stubGlobal('cancelAnimationFrame', () => {})
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('rotates the subject on left-drag while looking through', () => {
const stub = makeViewportStub()
createViewport3dMock.mockReturnValue(stub)
const onHandleDrag = vi.fn()
const viewport = new CameraInfoViewport(
document.createElement('div'),
undefined,
{ onHandleDrag }
)
viewport.setLookThrough(true)
dispatchPointer(stub.domElement, 'pointerdown', 100, 100)
dispatchPointer(stub.domElement, 'pointermove', 140, 100)
expect(onHandleDrag).toHaveBeenCalledWith('mode.yaw', expect.any(Number))
viewport.remove()
})
it('dollies the subject on wheel while looking through', () => {
const stub = makeViewportStub()
createViewport3dMock.mockReturnValue(stub)
const onHandleDrag = vi.fn()
const viewport = new CameraInfoViewport(
document.createElement('div'),
undefined,
{ onHandleDrag }
)
viewport.setLookThrough(true)
stub.domElement.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 }))
expect(onHandleDrag).toHaveBeenCalledWith(
'mode.distance',
expect.any(Number)
)
viewport.remove()
})
it('ignores the wheel when not looking through', () => {
const stub = makeViewportStub()
createViewport3dMock.mockReturnValue(stub)
const onHandleDrag = vi.fn()
const viewport = new CameraInfoViewport(
document.createElement('div'),
undefined,
{ onHandleDrag }
)
stub.domElement.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 }))
expect(onHandleDrag).not.toHaveBeenCalled()
viewport.remove()
})
it('does not rotate on drag once look-through is exited', () => {
const stub = makeViewportStub()
createViewport3dMock.mockReturnValue(stub)
const onHandleDrag = vi.fn()
const viewport = new CameraInfoViewport(
document.createElement('div'),
undefined,
{ onHandleDrag }
)
viewport.setLookThrough(true)
viewport.setLookThrough(false)
dispatchPointer(stub.domElement, 'pointerdown', 100, 100)
dispatchPointer(stub.domElement, 'pointermove', 140, 100)
expect(onHandleDrag).not.toHaveBeenCalled()
viewport.remove()
})
})

View File

@@ -1,699 +0,0 @@
import * as THREE from 'three'
import { Viewport3d } from '@/extensions/core/load3d/Viewport3d'
import { createViewport3d } from '@/extensions/core/load3d/createViewport3d'
import type { Load3DOptions } from '@/extensions/core/load3d/interfaces'
import { CameraInfoOverlay } from './CameraInfoOverlay'
import { computeSubjectTransform } from './cameraTransform'
import {
CameraHandle,
type CameraHandleMode,
type CameraHandleTransform
} from './handles/CameraHandle'
import { OrbitHandles, type OrbitHandleType } from './handles/OrbitHandles'
import { RollHandle } from './handles/RollHandle'
import { TargetHandle } from './handles/TargetHandle'
import { pickHandleAtPointer } from './handles/handlePicking'
import {
pointToDistance,
pointToPitchAngle,
pointToYawAngle
} from './handles/orbitDragMath'
import { pointToRollAngle } from './handles/rollDragMath'
import { dollySubjectByWheel, rotateSubjectByDrag } from './lookThroughDragMath'
import type { LookThroughResult } from './lookThroughDragMath'
import {
DEFAULT_CAMERA_INFO_STATE,
type CameraInfoFieldName,
type CameraInfoMode,
type CameraInfoState
} from './types'
const PREVIEW_WIDTH = 200
const PREVIEW_HEIGHT = 150
const PREVIEW_PADDING = 8
const PREVIEW_BORDER_COLOR = 0x2a2a2a
const LOOK_THROUGH_SENSITIVITY = 0.005
type DragHandleType = OrbitHandleType | 'roll'
export type TransformGizmoMode =
| 'none'
| 'target'
| 'camera-translate'
| 'camera-rotate'
const FIELD_NAME_FOR: Record<OrbitHandleType, CameraInfoFieldName> = {
yaw: 'mode.yaw',
pitch: 'mode.pitch',
distance: 'mode.distance'
}
export interface CameraInfoViewportOptions extends Load3DOptions {
onHandleDrag?: (fieldName: CameraInfoFieldName, value: number) => void
}
interface DragState {
type: DragHandleType
pointerId: number
}
export class CameraInfoViewport {
readonly viewport: Viewport3d
readonly overlay: CameraInfoOverlay
readonly orbitHandles: OrbitHandles
readonly rollHandle: RollHandle
readonly targetHandle: TargetHandle
readonly cameraHandle: CameraHandle
private readonly disposePostRender: () => void
private readonly onHandleDrag?: CameraInfoViewportOptions['onHandleDrag']
private readonly raycaster = new THREE.Raycaster()
private readonly pointer = new THREE.Vector2()
private dragState: DragState | null = null
private lookThroughDrag: {
pointerId: number
lastX: number
lastY: number
} | null = null
private pendingRotation: { dx: number; dy: number } | null = null
private pendingDolly: number | null = null
private inputFrame: number | null = null
private hoveredHandle: DragHandleType | null = null
private gizmosOn = true
private lookingThrough = false
private transformGizmoMode: TransformGizmoMode = 'none'
constructor(
container: HTMLElement,
initialState: CameraInfoState = DEFAULT_CAMERA_INFO_STATE,
options?: CameraInfoViewportOptions
) {
this.onHandleDrag = options?.onHandleDrag
this.viewport = createViewport3d(container, options)
this.viewport.viewHelperManager.visibleViewHelper(false)
this.overlay = new CameraInfoOverlay(initialState)
this.viewport.setOverlay(this.overlay)
this.orbitHandles = new OrbitHandles()
this.orbitHandles.attach(this.viewport.sceneManager.scene)
this.orbitHandles.update(initialState)
this.rollHandle = new RollHandle()
this.rollHandle.attach(this.viewport.sceneManager.scene)
this.rollHandle.update(initialState)
this.targetHandle = new TargetHandle(
this.viewport.cameraManager.activeCamera,
this.viewport.domElement,
(dragging) => {
this.viewport.controlsManager.controls.enabled = !dragging
},
(target) => {
const next: CameraInfoState = { ...this.overlay.getState(), target }
this.applyDerivedState(next)
this.onHandleDrag?.('target_x', target.x)
this.onHandleDrag?.('target_y', target.y)
this.onHandleDrag?.('target_z', target.z)
}
)
this.targetHandle.attach(this.viewport.sceneManager.scene)
this.targetHandle.setTarget(initialState.target)
this.cameraHandle = new CameraHandle(
this.viewport.cameraManager.activeCamera,
this.viewport.domElement,
(dragging) => {
this.viewport.controlsManager.controls.enabled = !dragging
},
(transform, mode) => this.handleCameraDrag(transform, mode)
)
this.cameraHandle.attach(this.viewport.sceneManager.scene)
this.syncCameraHandleSubject(initialState)
this.attachPointerHandlers()
this.disposePostRender = this.viewport.addPostRenderCallback(() =>
this.onPostRender()
)
}
applyState(state: CameraInfoState): void {
this.overlay.applyState(state)
this.orbitHandles.update(state)
this.rollHandle.update(state)
this.targetHandle.setTarget(state.target)
this.syncCameraHandleSubject(state)
this.refreshGizmoVisibility()
if (this.lookingThrough) {
this.viewport.setExternalActiveCamera(this.overlay.getSubjectCamera())
}
this.viewport.forceRender()
}
setGizmosVisible(on: boolean): void {
if (this.gizmosOn === on) return
this.gizmosOn = on
this.refreshGizmoVisibility()
this.viewport.forceRender()
}
setTransformGizmoMode(mode: TransformGizmoMode): void {
if (this.transformGizmoMode === mode) return
this.transformGizmoMode = mode
this.refreshGizmoVisibility()
this.viewport.forceRender()
}
setLookThrough(on: boolean): void {
if (this.lookingThrough === on) return
this.lookingThrough = on
this.lookThroughDrag = null
this.cancelInputFrame()
this.canvas.style.cursor = ''
this.refreshGizmoVisibility()
if (on) this.fitSubjectAspect()
this.viewport.setExternalActiveCamera(
on ? this.overlay.getSubjectCamera() : null
)
if (!on) this.viewport.viewHelperManager.visibleViewHelper(false)
}
remove(): void {
this.detachPointerHandlers()
this.cancelInputFrame()
this.canvas.style.cursor = ''
this.disposePostRender()
this.orbitHandles.dispose()
this.rollHandle.dispose()
this.targetHandle.dispose()
this.cameraHandle.dispose()
this.viewport.remove()
}
private refreshGizmoVisibility(): void {
if (this.lookingThrough) {
this.orbitHandles.setVisible(false)
this.rollHandle.setVisible(false)
this.targetHandle.setVisible(false)
this.cameraHandle.setVisible(false)
return
}
const mode = this.overlay.getState().mode
this.orbitHandles.setVisible(this.gizmosOn && mode === 'orbit')
this.rollHandle.setVisible(this.gizmosOn && rollApplies(mode))
const wantTarget =
this.transformGizmoMode === 'target' && targetApplies(mode)
this.targetHandle.setVisible(wantTarget)
const wantTranslate =
this.transformGizmoMode === 'camera-translate' &&
cameraTranslateApplies(mode)
const wantRotate =
this.transformGizmoMode === 'camera-rotate' && cameraRotateApplies(mode)
const wantCamera = wantTranslate || wantRotate
if (wantCamera)
this.cameraHandle.setMode(wantRotate ? 'rotate' : 'translate')
this.cameraHandle.setVisible(wantCamera)
}
private syncCameraHandleSubject(state: CameraInfoState): void {
const { position, quaternion } = computeSubjectTransform(state)
this.cameraHandle.setSubject(
{ x: position.x, y: position.y, z: position.z },
{ x: quaternion.x, y: quaternion.y, z: quaternion.z, w: quaternion.w }
)
}
private applyDerivedState(next: CameraInfoState): void {
this.overlay.applyState(next)
this.orbitHandles.update(next)
this.rollHandle.update(next)
this.targetHandle.setTarget(next.target)
this.syncCameraHandleSubject(next)
this.refreshGizmoVisibility()
this.viewport.forceRender()
}
private handleCameraDrag(
transform: CameraHandleTransform,
mode: CameraHandleMode
): void {
const state = this.overlay.getState()
const next = nextStateForCameraDrag(state, transform, mode)
this.applyDerivedState(next)
if (mode === 'translate') {
this.onHandleDrag?.('mode.position_x', transform.position.x)
this.onHandleDrag?.('mode.position_y', transform.position.y)
this.onHandleDrag?.('mode.position_z', transform.position.z)
} else {
this.onHandleDrag?.('mode.quat_x', transform.quaternion.x)
this.onHandleDrag?.('mode.quat_y', transform.quaternion.y)
this.onHandleDrag?.('mode.quat_z', transform.quaternion.z)
this.onHandleDrag?.('mode.quat_w', transform.quaternion.w)
}
}
private get canvas(): HTMLCanvasElement {
return this.viewport.domElement
}
private attachPointerHandlers(): void {
const canvas = this.canvas
canvas.addEventListener('pointerdown', this.onPointerDown)
canvas.addEventListener('pointermove', this.onPointerMove)
canvas.addEventListener('pointerup', this.onPointerUp)
canvas.addEventListener('pointercancel', this.onPointerUp)
canvas.addEventListener('pointerleave', this.onPointerLeave)
canvas.addEventListener('wheel', this.onWheel, { passive: false })
}
private detachPointerHandlers(): void {
const canvas = this.canvas
canvas.removeEventListener('pointerdown', this.onPointerDown)
canvas.removeEventListener('pointermove', this.onPointerMove)
canvas.removeEventListener('pointerup', this.onPointerUp)
canvas.removeEventListener('pointercancel', this.onPointerUp)
canvas.removeEventListener('pointerleave', this.onPointerLeave)
canvas.removeEventListener('wheel', this.onWheel)
}
private readonly onPointerLeave = (): void => {
if (this.dragState || this.lookThroughDrag) return
this.setHoveredHandle(null)
}
private readonly onWheel = (event: WheelEvent): void => {
if (!this.lookingThrough) return
event.preventDefault()
event.stopPropagation()
this.queueDolly(event.deltaY)
}
private updatePointer(event: PointerEvent): void {
const rect = this.canvas.getBoundingClientRect()
this.pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
this.pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
}
private pickableTargetsFor(mode: CameraInfoMode): THREE.Object3D[] {
const targets: THREE.Object3D[] = []
if (mode === 'orbit') {
targets.push(...this.orbitHandles.pickableMeshes())
}
if (rollApplies(mode)) {
targets.push(...this.rollHandle.pickableMeshes())
}
return targets
}
private pickHandle(event: PointerEvent): DragHandleType | null {
if (!this.gizmosOn || this.lookingThrough) return null
const targets = this.pickableTargetsFor(this.overlay.getState().mode)
if (targets.length === 0) return null
this.updatePointer(event)
return pickHandleAtPointer<DragHandleType>(
this.raycaster,
this.pointer,
this.viewport.cameraManager.activeCamera,
targets,
this.canvas
)
}
private setHoveredHandle(type: DragHandleType | null): void {
if (this.hoveredHandle === type) return
this.hoveredHandle = type
this.orbitHandles.setHovered(type === 'roll' ? null : type)
this.rollHandle.setHovered(type === 'roll')
this.canvas.style.cursor = type ? 'grab' : ''
this.viewport.forceRender()
}
private readonly onPointerDown = (event: PointerEvent): void => {
if (event.button !== 0) return
if (this.lookingThrough) {
this.lookThroughDrag = {
pointerId: event.pointerId,
lastX: event.clientX,
lastY: event.clientY
}
this.canvas.setPointerCapture(event.pointerId)
this.canvas.style.cursor = 'grabbing'
event.stopPropagation()
return
}
const type = this.pickHandle(event)
if (!type) return
this.setHoveredHandle(type)
this.dragState = { type, pointerId: event.pointerId }
this.canvas.setPointerCapture(event.pointerId)
this.canvas.style.cursor = 'grabbing'
this.viewport.controlsManager.controls.enabled = false
event.stopPropagation()
}
private readonly onPointerMove = (event: PointerEvent): void => {
if (this.lookThroughDrag) {
if (event.pointerId !== this.lookThroughDrag.pointerId) return
const dx = event.clientX - this.lookThroughDrag.lastX
const dy = event.clientY - this.lookThroughDrag.lastY
this.lookThroughDrag.lastX = event.clientX
this.lookThroughDrag.lastY = event.clientY
this.queueRotation(dx, dy)
return
}
if (!this.dragState) {
this.setHoveredHandle(this.pickHandle(event))
return
}
if (event.pointerId !== this.dragState.pointerId) return
this.updatePointer(event)
this.raycaster.setFromCamera(
this.pointer,
this.viewport.cameraManager.activeCamera
)
const state = this.overlay.getState()
const plane =
this.dragState.type === 'roll'
? this.rollHandle.dragPlane(state)
: this.orbitHandles.dragPlaneFor(this.dragState.type, state)
const point = new THREE.Vector3()
if (!this.raycaster.ray.intersectPlane(plane, point)) return
const { fieldName, value, nextState } = computeNextState(
this.dragState.type,
state,
point
)
this.applyState(nextState)
this.onHandleDrag?.(fieldName, value)
}
private readonly onPointerUp = (event: PointerEvent): void => {
if (this.lookThroughDrag) {
if (event.pointerId !== this.lookThroughDrag.pointerId) return
if (this.canvas.hasPointerCapture(event.pointerId)) {
this.canvas.releasePointerCapture(event.pointerId)
}
this.lookThroughDrag = null
this.flushInput()
this.cancelInputFrame()
this.canvas.style.cursor = ''
return
}
if (!this.dragState || event.pointerId !== this.dragState.pointerId) return
if (this.canvas.hasPointerCapture(event.pointerId)) {
this.canvas.releasePointerCapture(event.pointerId)
}
this.dragState = null
this.viewport.controlsManager.controls.enabled = true
this.canvas.style.cursor = this.hoveredHandle ? 'grab' : ''
}
private queueRotation(dx: number, dy: number): void {
this.pendingRotation = {
dx: (this.pendingRotation?.dx ?? 0) + dx,
dy: (this.pendingRotation?.dy ?? 0) + dy
}
this.scheduleInputFrame()
}
private queueDolly(deltaY: number): void {
this.pendingDolly = (this.pendingDolly ?? 0) + deltaY
this.scheduleInputFrame()
}
private scheduleInputFrame(): void {
if (this.inputFrame !== null) return
this.inputFrame = requestAnimationFrame(() => {
this.inputFrame = null
this.flushInput()
})
}
private flushInput(): void {
const rotation = this.pendingRotation
const dolly = this.pendingDolly
this.pendingRotation = null
this.pendingDolly = null
if (rotation) {
this.applyResult(
rotateSubjectByDrag(
this.overlay.getState(),
-rotation.dx * LOOK_THROUGH_SENSITIVITY,
-rotation.dy * LOOK_THROUGH_SENSITIVITY
)
)
}
if (dolly !== null) {
this.applyResult(dollySubjectByWheel(this.overlay.getState(), dolly))
}
}
private applyResult(result: LookThroughResult | null): void {
if (!result) return
this.applyState(result.nextState)
for (const update of result.updates) {
this.onHandleDrag?.(update.fieldName, update.value)
}
}
private cancelInputFrame(): void {
if (this.inputFrame !== null) {
cancelAnimationFrame(this.inputFrame)
this.inputFrame = null
}
this.pendingRotation = null
this.pendingDolly = null
}
private onPostRender(): void {
if (this.lookingThrough) {
if (this.fitSubjectAspect()) this.viewport.forceRender()
return
}
this.renderSubjectCameraPreview()
}
private fitSubjectAspect(): boolean {
const canvas = this.viewport.domElement
const aspect = canvas.width / canvas.height
if (!Number.isFinite(aspect) || aspect <= 0) return false
const cam = this.overlay.getSubjectCamera()
if (cam instanceof THREE.PerspectiveCamera) {
if (Math.abs(cam.aspect - aspect) < 1e-4) return false
cam.aspect = aspect
cam.updateProjectionMatrix()
return true
}
if (cam instanceof THREE.OrthographicCamera) {
const half = (cam.top - cam.bottom) / 2 || 1
const left = -half * aspect
const right = half * aspect
if (
Math.abs(cam.left - left) < 1e-4 &&
Math.abs(cam.right - right) < 1e-4
)
return false
cam.left = left
cam.right = right
cam.updateProjectionMatrix()
return true
}
return false
}
private renderSubjectCameraPreview(): void {
const renderer = this.viewport.renderer
const canvas = this.viewport.domElement
const canvasWidth = canvas.width
const canvasHeight = canvas.height
if (
canvasWidth < PREVIEW_WIDTH + PREVIEW_PADDING * 2 ||
canvasHeight < PREVIEW_HEIGHT + PREVIEW_PADDING * 2
) {
return
}
const cam = this.overlay.getSubjectCamera()
const aspect = PREVIEW_WIDTH / PREVIEW_HEIGHT
let savedAspect: number | undefined
let savedOrtho:
| {
left: number
right: number
top: number
bottom: number
}
| undefined
if (cam instanceof THREE.PerspectiveCamera) {
savedAspect = cam.aspect
cam.aspect = aspect
cam.updateProjectionMatrix()
} else if (cam instanceof THREE.OrthographicCamera) {
savedOrtho = {
left: cam.left,
right: cam.right,
top: cam.top,
bottom: cam.bottom
}
const half = (cam.top - cam.bottom) / 2 || 1
cam.left = -half * aspect
cam.right = half * aspect
cam.top = half
cam.bottom = -half
cam.updateProjectionMatrix()
}
this.overlay.setHelperVisible(false)
const handlesWereVisible = this.orbitHandles.isVisible()
const rollWasVisible = this.rollHandle.isVisible()
const targetWasVisible = this.targetHandle.isVisible()
const cameraWasVisible = this.cameraHandle.isVisible()
this.orbitHandles.setVisible(false)
this.rollHandle.setVisible(false)
this.targetHandle.setVisible(false)
this.cameraHandle.setVisible(false)
const x = canvasWidth - PREVIEW_WIDTH - PREVIEW_PADDING
const y = PREVIEW_PADDING
renderer.setViewport(x - 1, y - 1, PREVIEW_WIDTH + 2, PREVIEW_HEIGHT + 2)
renderer.setScissor(x - 1, y - 1, PREVIEW_WIDTH + 2, PREVIEW_HEIGHT + 2)
renderer.setScissorTest(true)
renderer.setClearColor(PREVIEW_BORDER_COLOR)
renderer.clear()
renderer.setViewport(x, y, PREVIEW_WIDTH, PREVIEW_HEIGHT)
renderer.setScissor(x, y, PREVIEW_WIDTH, PREVIEW_HEIGHT)
renderer.setClearColor(0x0a0a0a)
renderer.clear()
renderer.render(this.viewport.sceneManager.scene, cam)
this.overlay.setHelperVisible(true)
if (handlesWereVisible) this.orbitHandles.setVisible(true)
if (rollWasVisible) this.rollHandle.setVisible(true)
if (targetWasVisible) this.targetHandle.setVisible(true)
if (cameraWasVisible) this.cameraHandle.setVisible(true)
if (savedAspect !== undefined && cam instanceof THREE.PerspectiveCamera) {
cam.aspect = savedAspect
cam.updateProjectionMatrix()
} else if (savedOrtho && cam instanceof THREE.OrthographicCamera) {
cam.left = savedOrtho.left
cam.right = savedOrtho.right
cam.top = savedOrtho.top
cam.bottom = savedOrtho.bottom
cam.updateProjectionMatrix()
}
}
}
interface OrbitDragResult {
fieldName: CameraInfoFieldName
value: number
nextState: CameraInfoState
}
function computeNextState(
type: DragHandleType,
state: CameraInfoState,
point: THREE.Vector3
): OrbitDragResult {
if (type === 'roll') {
const cameraPos = computeSubjectTransform(state).position
const value = pointToRollAngle(
{ x: point.x, y: point.y, z: point.z },
state.target,
{ x: cameraPos.x, y: cameraPos.y, z: cameraPos.z }
)
return {
fieldName: 'roll',
value,
nextState: { ...state, roll: value }
}
}
const fieldName = FIELD_NAME_FOR[type]
if (type === 'yaw') {
const value = pointToYawAngle(point, state.target)
return {
fieldName,
value,
nextState: { ...state, orbit: { ...state.orbit, yaw: value } }
}
}
if (type === 'pitch') {
const value = pointToPitchAngle(point, state.target, state.orbit.yaw)
return {
fieldName,
value,
nextState: { ...state, orbit: { ...state.orbit, pitch: value } }
}
}
const value = pointToDistance(
point,
state.target,
state.orbit.yaw,
state.orbit.pitch
)
return {
fieldName,
value,
nextState: { ...state, orbit: { ...state.orbit, distance: value } }
}
}
function targetApplies(mode: CameraInfoMode): boolean {
return mode === 'orbit' || mode === 'look_at'
}
function cameraTranslateApplies(mode: CameraInfoMode): boolean {
return mode === 'look_at' || mode === 'quaternion'
}
function cameraRotateApplies(mode: CameraInfoMode): boolean {
return mode === 'quaternion'
}
function rollApplies(mode: CameraInfoMode): boolean {
return mode === 'orbit' || mode === 'look_at'
}
function nextStateForCameraDrag(
state: CameraInfoState,
transform: CameraHandleTransform,
mode: CameraHandleMode
): CameraInfoState {
const { position, quaternion } = transform
if (mode === 'translate') {
if (state.mode === 'look_at') {
return { ...state, lookAt: { position: { ...position } } }
}
if (state.mode === 'quaternion') {
return {
...state,
quaternion: { ...state.quaternion, position: { ...position } }
}
}
return state
}
if (state.mode === 'quaternion') {
return {
...state,
quaternion: { ...state.quaternion, quat: { ...quaternion } }
}
}
return state
}

View File

@@ -1,145 +0,0 @@
import { describe, expect, it } from 'vitest'
import { computeSubjectTransform } from './cameraTransform'
import { DEFAULT_CAMERA_INFO_STATE, type CameraInfoState } from './types'
function withMode(
mode: CameraInfoState['mode'],
overrides: Partial<CameraInfoState> = {}
): CameraInfoState {
return { ...DEFAULT_CAMERA_INFO_STATE, mode, ...overrides }
}
describe('computeSubjectTransform', () => {
describe('orbit mode', () => {
it('positions the camera at target + distance along yaw/pitch', () => {
const state = withMode('orbit', {
target: { x: 0, y: 0, z: 0 },
orbit: { yaw: 0, pitch: 0, distance: 5 }
})
const { position } = computeSubjectTransform(state)
expect(position.x).toBeCloseTo(0)
expect(position.y).toBeCloseTo(0)
expect(position.z).toBeCloseTo(5)
})
it('honors yaw=90deg (camera goes to +X)', () => {
const state = withMode('orbit', {
target: { x: 0, y: 0, z: 0 },
orbit: { yaw: 90, pitch: 0, distance: 5 }
})
const { position } = computeSubjectTransform(state)
expect(position.x).toBeCloseTo(5)
expect(position.y).toBeCloseTo(0)
expect(position.z).toBeCloseTo(0)
})
it('honors pitch=90deg (camera goes straight up)', () => {
const state = withMode('orbit', {
target: { x: 0, y: 0, z: 0 },
orbit: { yaw: 0, pitch: 90, distance: 5 }
})
const { position } = computeSubjectTransform(state)
expect(position.x).toBeCloseTo(0)
expect(position.y).toBeCloseTo(5)
expect(position.z).toBeCloseTo(0, 5)
})
it('offsets the orbit by target', () => {
const state = withMode('orbit', {
target: { x: 10, y: 20, z: 30 },
orbit: { yaw: 0, pitch: 0, distance: 5 }
})
const { position } = computeSubjectTransform(state)
expect(position.x).toBeCloseTo(10)
expect(position.y).toBeCloseTo(20)
expect(position.z).toBeCloseTo(35)
})
})
describe('look_at mode', () => {
it('uses explicit position', () => {
const state = withMode('look_at', {
lookAt: { position: { x: 1, y: 2, z: 3 } }
})
const { position } = computeSubjectTransform(state)
expect(position.toArray()).toEqual([1, 2, 3])
})
})
describe('quaternion mode', () => {
it('uses explicit position and quaternion (target ignored)', () => {
const state = withMode('quaternion', {
target: { x: 999, y: 999, z: 999 },
quaternion: {
position: { x: 7, y: 8, z: 9 },
quat: { x: 0, y: 0, z: 0, w: 1 }
}
})
const { position, quaternion } = computeSubjectTransform(state)
expect(position.toArray()).toEqual([7, 8, 9])
expect(quaternion.x).toBeCloseTo(0)
expect(quaternion.y).toBeCloseTo(0)
expect(quaternion.z).toBeCloseTo(0)
expect(quaternion.w).toBeCloseTo(1)
})
it('normalizes a non-unit quaternion', () => {
const state = withMode('quaternion', {
quaternion: {
position: { x: 0, y: 0, z: 0 },
quat: { x: 2, y: 0, z: 0, w: 0 }
}
})
const { quaternion } = computeSubjectTransform(state)
expect(quaternion.length()).toBeCloseTo(1)
})
it('falls back to identity when given a zero quaternion', () => {
const state = withMode('quaternion', {
quaternion: {
position: { x: 0, y: 0, z: 0 },
quat: { x: 0, y: 0, z: 0, w: 0 }
}
})
const { quaternion } = computeSubjectTransform(state)
expect(quaternion.x).toBe(0)
expect(quaternion.y).toBe(0)
expect(quaternion.z).toBe(0)
expect(quaternion.w).toBe(1)
})
})
describe('roll', () => {
it('rotates around the view axis without moving the camera position', () => {
const base = withMode('look_at', {
target: { x: 0, y: 0, z: 0 },
lookAt: { position: { x: 0, y: 0, z: 5 } },
roll: 0
})
const rolled = { ...base, roll: 45 }
const a = computeSubjectTransform(base)
const b = computeSubjectTransform(rolled)
expect(a.position.toArray()).toEqual(b.position.toArray())
expect(a.quaternion.equals(b.quaternion)).toBe(false)
})
})
})

View File

@@ -1,89 +0,0 @@
import * as THREE from 'three'
import type { CameraInfoState } from './types'
const DEG2RAD = Math.PI / 180
export interface SubjectCameraTransform {
position: THREE.Vector3
quaternion: THREE.Quaternion
}
function orbitPosition(
target: THREE.Vector3Like,
yawDeg: number,
pitchDeg: number,
distance: number
): THREE.Vector3 {
const y = yawDeg * DEG2RAD
const p = pitchDeg * DEG2RAD
const cp = Math.cos(p)
return new THREE.Vector3(
target.x + distance * cp * Math.sin(y),
target.y + distance * Math.sin(p),
target.z + distance * cp * Math.cos(y)
)
}
function lookAtQuaternion(
position: THREE.Vector3,
target: THREE.Vector3Like,
rollDeg: number
): THREE.Quaternion {
const targetVec = new THREE.Vector3(target.x, target.y, target.z)
const m = new THREE.Matrix4().lookAt(
position,
targetVec,
new THREE.Vector3(0, 1, 0)
)
const q = new THREE.Quaternion().setFromRotationMatrix(m)
if (rollDeg !== 0) {
const rollQ = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 0, 1),
rollDeg * DEG2RAD
)
q.multiply(rollQ)
}
return q
}
export function normalizeQuaternion(q: THREE.Quaternion): THREE.Quaternion {
if (q.lengthSq() === 0) q.set(0, 0, 0, 1)
else q.normalize()
return q
}
export function computeSubjectTransform(
state: CameraInfoState
): SubjectCameraTransform {
if (state.mode === 'quaternion') {
const p = state.quaternion.position
const q = state.quaternion.quat
const quaternion = normalizeQuaternion(
new THREE.Quaternion(q.x, q.y, q.z, q.w)
)
return {
position: new THREE.Vector3(p.x, p.y, p.z),
quaternion
}
}
const position =
state.mode === 'orbit'
? orbitPosition(
state.target,
state.orbit.yaw,
state.orbit.pitch,
state.orbit.distance
)
: new THREE.Vector3(
state.lookAt.position.x,
state.lookAt.position.y,
state.lookAt.position.z
)
return {
position,
quaternion: lookAtQuaternion(position, state.target, state.roll)
}
}

View File

@@ -1,103 +0,0 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
CameraHandle,
type CameraHandleMode,
type CameraHandleTransform
} from './CameraHandle'
describe('CameraHandle', () => {
let scene: THREE.Scene
let camera: THREE.PerspectiveCamera
let dom: HTMLElement
let onDragging: (dragging: boolean) => void
let onChange: (
transform: CameraHandleTransform,
mode: CameraHandleMode
) => void
let onChangeMock: ReturnType<typeof vi.fn>
let handle: CameraHandle
beforeEach(() => {
scene = new THREE.Scene()
camera = new THREE.PerspectiveCamera()
dom = document.createElement('div')
onDragging = vi.fn() as unknown as (dragging: boolean) => void
onChangeMock = vi.fn()
onChange = onChangeMock as unknown as (
transform: CameraHandleTransform,
mode: CameraHandleMode
) => void
handle = new CameraHandle(camera, dom, onDragging, onChange)
handle.attach(scene)
})
afterEach(() => {
handle.dispose()
})
it('adds a proxy + a helper to the scene on attach', () => {
expect(
scene.children.find((c) => c.name === 'CameraInfoCameraProxy')
).toBeDefined()
expect(
scene.children.find((c) => c.name === 'CameraInfoCameraHandle')
).toBeDefined()
})
it('starts hidden with controls disabled', () => {
expect(handle.isVisible()).toBe(false)
})
it('defaults to translate mode', () => {
expect(handle.getMode()).toBe('translate')
})
it('setMode switches translate <-> rotate', () => {
handle.setMode('rotate')
expect(handle.getMode()).toBe('rotate')
handle.setMode('translate')
expect(handle.getMode()).toBe('translate')
})
it('setSubject moves the proxy to the given pose without firing onChange', () => {
handle.setSubject({ x: 1, y: 2, z: 3 }, { x: 0, y: 0, z: 0, w: 1 })
const proxy = scene.children.find(
(c) => c.name === 'CameraInfoCameraProxy'
)!
expect(proxy.position.x).toBe(1)
expect(proxy.position.y).toBe(2)
expect(proxy.position.z).toBe(3)
expect(onChangeMock).not.toHaveBeenCalled()
})
it('setSubject is a no-op when the pose already matches', () => {
handle.setSubject({ x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 0, w: 1 })
onChangeMock.mockClear()
handle.setSubject({ x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 0, w: 1 })
expect(onChangeMock).not.toHaveBeenCalled()
})
it('setVisible toggles helper.visible and controls.enabled together', () => {
handle.setVisible(true)
expect(handle.isVisible()).toBe(true)
handle.setVisible(false)
expect(handle.isVisible()).toBe(false)
})
it('dispose removes proxy and helper from the scene', () => {
handle.dispose()
expect(
scene.children.find((c) => c.name === 'CameraInfoCameraProxy')
).toBeUndefined()
expect(
scene.children.find((c) => c.name === 'CameraInfoCameraHandle')
).toBeUndefined()
})
})

View File

@@ -1,149 +0,0 @@
import * as THREE from 'three'
import { TransformControls } from 'three/examples/jsm/controls/TransformControls'
import type { DraggingChangeListener } from './types'
export type CameraHandleMode = 'translate' | 'rotate'
export interface CameraHandleTransform {
position: THREE.Vector3Like
quaternion: { x: number; y: number; z: number; w: number }
}
type ChangeListener = (
transform: CameraHandleTransform,
mode: CameraHandleMode
) => void
export class CameraHandle {
private readonly proxy: THREE.Object3D
private readonly controls: TransformControls
private readonly helper: THREE.Object3D
private mode: CameraHandleMode = 'translate'
private suppressEcho = false
private scene: THREE.Scene | null = null
private disposed = false
constructor(
camera: THREE.Camera,
domElement: HTMLElement,
private readonly onDraggingChange: DraggingChangeListener,
private readonly onChange: ChangeListener
) {
this.proxy = new THREE.Object3D()
this.proxy.name = 'CameraInfoCameraProxy'
this.controls = new TransformControls(camera, domElement)
this.controls.setMode(this.mode)
this.controls.setSize(0.8)
this.controls.setSpace(spaceFor(this.mode))
this.controls.attach(this.proxy)
this.helper = this.controls.getHelper()
this.helper.name = 'CameraInfoCameraHandle'
this.helper.visible = false
this.controls.enabled = false
this.controls.addEventListener('dragging-changed', this.onDragging)
this.controls.addEventListener('objectChange', this.onObjectChange)
}
attach(scene: THREE.Scene): void {
this.scene = scene
scene.add(this.proxy)
scene.add(this.helper)
}
detach(): void {
if (!this.scene) return
this.scene.remove(this.proxy)
this.scene.remove(this.helper)
this.scene = null
}
setVisible(visible: boolean): void {
this.helper.visible = visible
this.controls.enabled = visible
}
isVisible(): boolean {
return this.helper.visible
}
setMode(mode: CameraHandleMode): void {
if (this.mode === mode) return
this.mode = mode
this.controls.setMode(mode)
this.controls.setSpace(spaceFor(mode))
}
getMode(): CameraHandleMode {
return this.mode
}
setSubject(
position: THREE.Vector3Like,
quaternion: { x: number; y: number; z: number; w: number }
): void {
const samePosition =
this.proxy.position.x === position.x &&
this.proxy.position.y === position.y &&
this.proxy.position.z === position.z
const sameQuaternion =
this.proxy.quaternion.x === quaternion.x &&
this.proxy.quaternion.y === quaternion.y &&
this.proxy.quaternion.z === quaternion.z &&
this.proxy.quaternion.w === quaternion.w
if (samePosition && sameQuaternion) return
this.suppressEcho = true
try {
this.proxy.position.set(position.x, position.y, position.z)
this.proxy.quaternion.set(
quaternion.x,
quaternion.y,
quaternion.z,
quaternion.w
)
this.proxy.updateMatrixWorld(true)
} finally {
this.suppressEcho = false
}
}
dispose(): void {
if (this.disposed) return
this.disposed = true
this.controls.removeEventListener('dragging-changed', this.onDragging)
this.controls.removeEventListener('objectChange', this.onObjectChange)
this.controls.detach()
this.detach()
this.controls.dispose()
}
private readonly onDragging = (event: { value: unknown }): void => {
this.onDraggingChange(event.value === true)
}
private readonly onObjectChange = (): void => {
if (this.suppressEcho) return
this.onChange(
{
position: {
x: this.proxy.position.x,
y: this.proxy.position.y,
z: this.proxy.position.z
},
quaternion: {
x: this.proxy.quaternion.x,
y: this.proxy.quaternion.y,
z: this.proxy.quaternion.z,
w: this.proxy.quaternion.w
}
},
this.mode
)
}
}
function spaceFor(mode: CameraHandleMode): 'world' | 'local' {
return mode === 'translate' ? 'world' : 'local'
}

View File

@@ -1,108 +0,0 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { DEFAULT_CAMERA_INFO_STATE } from '../types'
import { OrbitHandles } from './OrbitHandles'
describe('OrbitHandles', () => {
let scene: THREE.Scene
let handles: OrbitHandles
beforeEach(() => {
scene = new THREE.Scene()
handles = new OrbitHandles()
handles.attach(scene)
})
afterEach(() => {
handles.dispose()
})
it('adds a single root group to the scene on attach', () => {
const root = scene.children.find((c) => c.name === 'CameraInfoOrbitHandles')
expect(root).toBeDefined()
})
it('exposes three pickable handle meshes tagged with handleType', () => {
const meshes = handles.pickableMeshes()
expect(meshes).toHaveLength(3)
const types = meshes.map((m) => m.userData.handleType).sort()
expect(types).toEqual(['distance', 'pitch', 'yaw'])
})
it('positions the yaw handle on the +Z side of the ring at yaw=0', () => {
handles.update({
...DEFAULT_CAMERA_INFO_STATE,
mode: 'orbit',
orbit: { yaw: 0, pitch: 0, distance: 5 }
})
const yawHandle = handles
.pickableMeshes()
.find((m) => m.userData.handleType === 'yaw')!
expect(yawHandle.position.x).toBeCloseTo(0)
expect(yawHandle.position.z).toBeGreaterThan(0)
})
it('places the distance handle at the camera (distance along yaw direction)', () => {
handles.update({
...DEFAULT_CAMERA_INFO_STATE,
mode: 'orbit',
target: { x: 0, y: 0, z: 0 },
orbit: { yaw: 90, pitch: 0, distance: 7 }
})
const distanceHandle = handles
.pickableMeshes()
.find((m) => m.userData.handleType === 'distance')!
const world = new THREE.Vector3()
distanceHandle.getWorldPosition(world)
expect(world.x).toBeCloseTo(7)
expect(world.y).toBeCloseTo(0)
expect(world.z).toBeCloseTo(0)
})
it('hides itself in non-orbit modes', () => {
handles.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'look_at' })
const root = scene.children.find(
(c) => c.name === 'CameraInfoOrbitHandles'
)!
expect(root.visible).toBe(false)
})
it('setVisible forces visibility independent of mode', () => {
handles.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'orbit' })
handles.setVisible(false)
const root = scene.children.find(
(c) => c.name === 'CameraInfoOrbitHandles'
)!
expect(root.visible).toBe(false)
})
it('isVisible reflects the root group, not individual handle meshes', () => {
handles.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'quaternion' })
expect(handles.isVisible()).toBe(false)
expect(handles.pickableMeshes()[0].visible).toBe(true)
})
it('yaw drag plane is horizontal through target', () => {
const plane = handles.dragPlaneFor('yaw', {
...DEFAULT_CAMERA_INFO_STATE,
target: { x: 0, y: 3, z: 0 }
})
expect(plane.normal.y).toBeCloseTo(1)
expect(plane.constant).toBeCloseTo(-3)
})
it('pitch drag plane is vertical and orthogonal to yaw direction', () => {
const plane = handles.dragPlaneFor('pitch', {
...DEFAULT_CAMERA_INFO_STATE,
orbit: { yaw: 0, pitch: 0, distance: 5 }
})
expect(Math.abs(plane.normal.x)).toBeCloseTo(1)
expect(plane.normal.y).toBeCloseTo(0)
})
})

View File

@@ -1,258 +0,0 @@
import * as THREE from 'three'
import type { CameraInfoState } from '../types'
const RING_RADIUS = 1.5
const HANDLE_RADIUS = 0.08
const GLOW_RADIUS = 0.12
const TUBE_RADIUS = 0.025
const ARC_PITCH_LIMIT = 85
const ARC_SEGMENTS = 32
const YAW_COLOR = 0x00d4ff
const PITCH_COLOR = 0xff66ff
const DISTANCE_COLOR = 0xffcc00
const BASE_GLOW_OPACITY = 0.25
const HOVER_GLOW_OPACITY = 0.6
const HOVER_SCALE = 1.35
export type OrbitHandleType = 'yaw' | 'pitch' | 'distance'
const DEG2RAD = Math.PI / 180
function buildArcCurve(): THREE.CatmullRomCurve3 {
const points: THREE.Vector3[] = []
for (let deg = -ARC_PITCH_LIMIT; deg <= ARC_PITCH_LIMIT; deg += 5) {
const r = deg * DEG2RAD
points.push(
new THREE.Vector3(0, RING_RADIUS * Math.sin(r), RING_RADIUS * Math.cos(r))
)
}
return new THREE.CatmullRomCurve3(points)
}
function makeHandle(color: number, type: OrbitHandleType): THREE.Mesh {
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(HANDLE_RADIUS, 32, 32),
new THREE.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: 0.7,
roughness: 0.3,
metalness: 0.2
})
)
mesh.userData.handleType = type
return mesh
}
function makeGlow(color: number): THREE.Mesh {
return new THREE.Mesh(
new THREE.SphereGeometry(GLOW_RADIUS, 16, 16),
new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: BASE_GLOW_OPACITY,
depthWrite: false,
blending: THREE.AdditiveBlending
})
)
}
export class OrbitHandles {
private readonly root = new THREE.Group()
private readonly yawGroup = new THREE.Group()
private readonly yawRing: THREE.Mesh
private readonly pitchArc: THREE.Mesh
private readonly distanceLine: THREE.Line
private readonly distanceLineGeometry: THREE.BufferGeometry
private readonly yawHandle: THREE.Mesh
private readonly pitchHandle: THREE.Mesh
private readonly distanceHandle: THREE.Mesh
private readonly yawHandleGlow: THREE.Mesh
private readonly pitchHandleGlow: THREE.Mesh
private readonly distanceHandleGlow: THREE.Mesh
private scene: THREE.Scene | null = null
private disposed = false
constructor() {
this.root.name = 'CameraInfoOrbitHandles'
this.root.add(this.yawGroup)
this.yawRing = new THREE.Mesh(
new THREE.TorusGeometry(RING_RADIUS, TUBE_RADIUS, 16, 96),
new THREE.MeshBasicMaterial({
color: YAW_COLOR,
transparent: true,
opacity: 0.55
})
)
this.yawRing.rotation.x = Math.PI / 2
this.root.add(this.yawRing)
this.pitchArc = new THREE.Mesh(
new THREE.TubeGeometry(
buildArcCurve(),
ARC_SEGMENTS,
TUBE_RADIUS,
8,
false
),
new THREE.MeshBasicMaterial({
color: PITCH_COLOR,
transparent: true,
opacity: 0.55
})
)
this.yawGroup.add(this.pitchArc)
this.distanceLineGeometry = new THREE.BufferGeometry()
this.distanceLineGeometry.setAttribute(
'position',
new THREE.Float32BufferAttribute([0, 0, 0, 0, 0, 1], 3)
)
this.distanceLine = new THREE.Line(
this.distanceLineGeometry,
new THREE.LineBasicMaterial({
color: DISTANCE_COLOR,
transparent: true,
opacity: 0.55
})
)
this.yawGroup.add(this.distanceLine)
this.yawHandle = makeHandle(YAW_COLOR, 'yaw')
this.yawHandleGlow = makeGlow(YAW_COLOR)
this.yawHandle.add(this.yawHandleGlow)
this.root.add(this.yawHandle)
this.pitchHandle = makeHandle(PITCH_COLOR, 'pitch')
this.pitchHandleGlow = makeGlow(PITCH_COLOR)
this.pitchHandle.add(this.pitchHandleGlow)
this.yawGroup.add(this.pitchHandle)
this.distanceHandle = makeHandle(DISTANCE_COLOR, 'distance')
this.distanceHandleGlow = makeGlow(DISTANCE_COLOR)
this.distanceHandle.add(this.distanceHandleGlow)
this.yawGroup.add(this.distanceHandle)
}
attach(scene: THREE.Scene): void {
this.scene = scene
scene.add(this.root)
}
detach(): void {
if (!this.scene) return
this.scene.remove(this.root)
this.scene = null
}
dispose(): void {
if (this.disposed) return
this.disposed = true
this.detach()
const disposables: { dispose: () => void }[] = [
this.yawRing.geometry,
this.yawRing.material as THREE.Material,
this.pitchArc.geometry,
this.pitchArc.material as THREE.Material,
this.distanceLineGeometry,
this.distanceLine.material as THREE.Material,
this.yawHandle.geometry,
this.yawHandle.material as THREE.Material,
this.pitchHandle.geometry,
this.pitchHandle.material as THREE.Material,
this.distanceHandle.geometry,
this.distanceHandle.material as THREE.Material,
this.yawHandleGlow.geometry,
this.yawHandleGlow.material as THREE.Material,
this.pitchHandleGlow.geometry,
this.pitchHandleGlow.material as THREE.Material,
this.distanceHandleGlow.geometry,
this.distanceHandleGlow.material as THREE.Material
]
for (const d of disposables) d.dispose()
}
setVisible(visible: boolean): void {
this.root.visible = visible
}
isVisible(): boolean {
return this.root.visible
}
update(state: CameraInfoState): void {
const visible = state.mode === 'orbit'
this.root.visible = visible
if (!visible) return
this.root.position.set(state.target.x, state.target.y, state.target.z)
const yawRad = state.orbit.yaw * DEG2RAD
const pitchRad = state.orbit.pitch * DEG2RAD
const cosP = Math.cos(pitchRad)
const sinP = Math.sin(pitchRad)
this.yawGroup.rotation.y = yawRad
this.yawHandle.position.set(
RING_RADIUS * Math.sin(yawRad),
0,
RING_RADIUS * Math.cos(yawRad)
)
this.pitchHandle.position.set(0, RING_RADIUS * sinP, RING_RADIUS * cosP)
const endY = state.orbit.distance * sinP
const endZ = state.orbit.distance * cosP
this.distanceHandle.position.set(0, endY, endZ)
const positions = this.distanceLineGeometry.attributes
.position as THREE.BufferAttribute
positions.setXYZ(1, 0, endY, endZ)
positions.needsUpdate = true
}
pickableMeshes(): THREE.Object3D[] {
return [this.yawHandle, this.pitchHandle, this.distanceHandle]
}
setHovered(type: OrbitHandleType | null): void {
const entries: [THREE.Mesh, THREE.Mesh, OrbitHandleType][] = [
[this.yawHandle, this.yawHandleGlow, 'yaw'],
[this.pitchHandle, this.pitchHandleGlow, 'pitch'],
[this.distanceHandle, this.distanceHandleGlow, 'distance']
]
for (const [handle, glow, handleType] of entries) {
const active = handleType === type
handle.scale.setScalar(active ? HOVER_SCALE : 1)
;(glow.material as THREE.MeshBasicMaterial).opacity = active
? HOVER_GLOW_OPACITY
: BASE_GLOW_OPACITY
}
}
dragPlaneFor(type: OrbitHandleType, state: CameraInfoState): THREE.Plane {
if (type === 'yaw') {
return new THREE.Plane(new THREE.Vector3(0, 1, 0), -state.target.y)
}
const yawRad = state.orbit.yaw * DEG2RAD
const normal = new THREE.Vector3(
-Math.cos(yawRad),
0,
Math.sin(yawRad)
).normalize()
const targetVec = vec(state.target)
return new THREE.Plane().setFromNormalAndCoplanarPoint(normal, targetVec)
}
}
const vec = (v: THREE.Vector3Like): THREE.Vector3 =>
new THREE.Vector3(v.x, v.y, v.z)

View File

@@ -1,78 +0,0 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { DEFAULT_CAMERA_INFO_STATE } from '../types'
import { RollHandle } from './RollHandle'
describe('RollHandle', () => {
let scene: THREE.Scene
let handle: RollHandle
beforeEach(() => {
scene = new THREE.Scene()
handle = new RollHandle()
handle.attach(scene)
})
afterEach(() => {
handle.dispose()
})
it('attaches a single root group to the scene', () => {
expect(
scene.children.find((c) => c.name === 'CameraInfoRollHandle')
).toBeDefined()
})
it('exposes one pickable mesh tagged handleType="roll"', () => {
const meshes = handle.pickableMeshes()
expect(meshes).toHaveLength(1)
expect(meshes[0].userData.handleType).toBe('roll')
})
it('hides itself in quaternion mode (rotate gizmo owns roll there)', () => {
handle.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'quaternion' })
expect(handle.isVisible()).toBe(false)
})
it('is visible in orbit and look_at modes', () => {
handle.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'orbit' })
expect(handle.isVisible()).toBe(true)
handle.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'look_at' })
expect(handle.isVisible()).toBe(true)
})
it('setVisible forces visibility', () => {
handle.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'orbit' })
handle.setVisible(false)
expect(handle.isVisible()).toBe(false)
})
it('positions itself at the target', () => {
handle.update({
...DEFAULT_CAMERA_INFO_STATE,
mode: 'orbit',
target: { x: 1, y: 2, z: 3 }
})
const root = scene.children.find((c) => c.name === 'CameraInfoRollHandle')!
expect(root.position.x).toBeCloseTo(1)
expect(root.position.y).toBeCloseTo(2)
expect(root.position.z).toBeCloseTo(3)
})
it('drag plane normal points along the camera→target backward direction', () => {
const plane = handle.dragPlane({
...DEFAULT_CAMERA_INFO_STATE,
mode: 'look_at',
target: { x: 0, y: 0, z: 0 },
lookAt: { position: { x: 0, y: 0, z: 5 } }
})
expect(plane.normal.x).toBeCloseTo(0)
expect(plane.normal.y).toBeCloseTo(0)
expect(Math.abs(plane.normal.z)).toBeCloseTo(1)
})
})

View File

@@ -1,160 +0,0 @@
import * as THREE from 'three'
import { computeSubjectTransform } from '../cameraTransform'
import type { CameraInfoState } from '../types'
import { rollBasis } from './rollDragMath'
const RING_RADIUS = 0.9
const HANDLE_RADIUS = 0.08
const GLOW_RADIUS = 0.12
const TUBE_RADIUS = 0.025
const ROLL_COLOR = 0xff8800
const BASE_GLOW_OPACITY = 0.25
const HOVER_GLOW_OPACITY = 0.6
const HOVER_SCALE = 1.35
const DEG2RAD = Math.PI / 180
function makeHandle(): THREE.Mesh {
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(HANDLE_RADIUS, 32, 32),
new THREE.MeshStandardMaterial({
color: ROLL_COLOR,
emissive: ROLL_COLOR,
emissiveIntensity: 0.7,
roughness: 0.3,
metalness: 0.2
})
)
mesh.userData.handleType = 'roll'
return mesh
}
export class RollHandle {
private readonly root = new THREE.Group()
private readonly ring: THREE.Mesh
private readonly handle: THREE.Mesh
private readonly handleGlow: THREE.Mesh
private scene: THREE.Scene | null = null
private disposed = false
constructor() {
this.root.name = 'CameraInfoRollHandle'
this.ring = new THREE.Mesh(
new THREE.TorusGeometry(RING_RADIUS, TUBE_RADIUS, 16, 80),
new THREE.MeshBasicMaterial({
color: ROLL_COLOR,
transparent: true,
opacity: 0.55
})
)
this.root.add(this.ring)
this.handle = makeHandle()
this.handleGlow = new THREE.Mesh(
new THREE.SphereGeometry(GLOW_RADIUS, 16, 16),
new THREE.MeshBasicMaterial({
color: ROLL_COLOR,
transparent: true,
opacity: BASE_GLOW_OPACITY,
depthWrite: false,
blending: THREE.AdditiveBlending
})
)
this.handle.add(this.handleGlow)
this.root.add(this.handle)
}
attach(scene: THREE.Scene): void {
this.scene = scene
scene.add(this.root)
}
detach(): void {
if (!this.scene) return
this.scene.remove(this.root)
this.scene = null
}
dispose(): void {
if (this.disposed) return
this.disposed = true
this.detach()
const disposables: { dispose: () => void }[] = [
this.ring.geometry,
this.ring.material as THREE.Material,
this.handle.geometry,
this.handle.material as THREE.Material,
this.handleGlow.geometry,
this.handleGlow.material as THREE.Material
]
for (const d of disposables) d.dispose()
}
setVisible(visible: boolean): void {
this.root.visible = visible
}
isVisible(): boolean {
return this.root.visible
}
update(state: CameraInfoState): void {
if (state.mode === 'quaternion') {
this.root.visible = false
return
}
this.root.visible = true
const { position: cameraPos } = computeSubjectTransform(state)
const cameraVec: THREE.Vector3Like = {
x: cameraPos.x,
y: cameraPos.y,
z: cameraPos.z
}
const { up, right, backward } = rollBasis(state.target, cameraVec)
this.root.position.set(state.target.x, state.target.y, state.target.z)
const orient = new THREE.Quaternion().setFromRotationMatrix(
new THREE.Matrix4().makeBasis(right, up, backward)
)
this.root.quaternion.copy(orient)
const theta = state.roll * DEG2RAD
this.handle.position.set(
RING_RADIUS * Math.sin(theta),
RING_RADIUS * Math.cos(theta),
0
)
}
pickableMeshes(): THREE.Object3D[] {
return [this.handle]
}
setHovered(hovered: boolean): void {
this.handle.scale.setScalar(hovered ? HOVER_SCALE : 1)
;(this.handleGlow.material as THREE.MeshBasicMaterial).opacity = hovered
? HOVER_GLOW_OPACITY
: BASE_GLOW_OPACITY
}
dragPlane(state: CameraInfoState): THREE.Plane {
const { position: cameraPos } = computeSubjectTransform(state)
const cameraVec: THREE.Vector3Like = {
x: cameraPos.x,
y: cameraPos.y,
z: cameraPos.z
}
const { backward } = rollBasis(state.target, cameraVec)
const tgt = new THREE.Vector3(
state.target.x,
state.target.y,
state.target.z
)
return new THREE.Plane().setFromNormalAndCoplanarPoint(backward, tgt)
}
}

View File

@@ -1,99 +0,0 @@
import * as THREE from 'three'
import { TransformControls } from 'three/examples/jsm/controls/TransformControls'
import type { DraggingChangeListener } from './types'
type ChangeListener = (target: THREE.Vector3Like) => void
export class TargetHandle {
private readonly proxy: THREE.Object3D
private readonly controls: TransformControls
private readonly helper: THREE.Object3D
private suppressEcho = false
private scene: THREE.Scene | null = null
private disposed = false
constructor(
camera: THREE.Camera,
domElement: HTMLElement,
private readonly onDraggingChange: DraggingChangeListener,
private readonly onChange: ChangeListener
) {
this.proxy = new THREE.Object3D()
this.proxy.name = 'CameraInfoTargetProxy'
this.controls = new TransformControls(camera, domElement)
this.controls.setMode('translate')
this.controls.setSize(0.8)
this.controls.attach(this.proxy)
this.helper = this.controls.getHelper()
this.helper.name = 'CameraInfoTargetHandle'
this.helper.visible = false
this.controls.enabled = false
this.controls.addEventListener('dragging-changed', this.onDragging)
this.controls.addEventListener('objectChange', this.onObjectChange)
}
attach(scene: THREE.Scene): void {
this.scene = scene
scene.add(this.proxy)
scene.add(this.helper)
}
detach(): void {
if (!this.scene) return
this.scene.remove(this.proxy)
this.scene.remove(this.helper)
this.scene = null
}
setVisible(visible: boolean): void {
this.helper.visible = visible
this.controls.enabled = visible
}
isVisible(): boolean {
return this.helper.visible
}
setTarget(target: THREE.Vector3Like): void {
if (
this.proxy.position.x === target.x &&
this.proxy.position.y === target.y &&
this.proxy.position.z === target.z
) {
return
}
this.suppressEcho = true
try {
this.proxy.position.set(target.x, target.y, target.z)
this.proxy.updateMatrixWorld(true)
} finally {
this.suppressEcho = false
}
}
dispose(): void {
if (this.disposed) return
this.disposed = true
this.controls.removeEventListener('dragging-changed', this.onDragging)
this.controls.removeEventListener('objectChange', this.onObjectChange)
this.controls.detach()
this.detach()
this.controls.dispose()
}
private readonly onDragging = (event: { value: unknown }): void => {
this.onDraggingChange(event.value === true)
}
private readonly onObjectChange = (): void => {
if (this.suppressEcho) return
this.onChange({
x: this.proxy.position.x,
y: this.proxy.position.y,
z: this.proxy.position.z
})
}
}

View File

@@ -1,102 +0,0 @@
import * as THREE from 'three'
import { beforeEach, describe, expect, it } from 'vitest'
import { pickHandleAtPointer } from './handlePicking'
const CANVAS = { clientWidth: 400, clientHeight: 400 }
function makeHandle(handleType: string, position: THREE.Vector3): THREE.Mesh {
const mesh = new THREE.Mesh(new THREE.SphereGeometry(0.08, 8, 8))
mesh.userData.handleType = handleType
mesh.position.copy(position)
mesh.updateMatrixWorld(true)
return mesh
}
describe('pickHandleAtPointer', () => {
let camera: THREE.PerspectiveCamera
let raycaster: THREE.Raycaster
beforeEach(() => {
camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100)
camera.position.set(0, 0, 10)
camera.lookAt(0, 0, 0)
camera.updateMatrixWorld(true)
camera.updateProjectionMatrix()
raycaster = new THREE.Raycaster()
})
it('returns the handle type on a direct raycast hit', () => {
const handle = makeHandle('yaw', new THREE.Vector3(0, 0, 0))
const picked = pickHandleAtPointer(
raycaster,
new THREE.Vector2(0, 0),
camera,
[handle],
CANVAS
)
expect(picked).toBe('yaw')
})
it('picks a handle within the screen-space tolerance on a near miss', () => {
const handle = makeHandle('pitch', new THREE.Vector3(0, 0, 0))
// ~10px off centre: outside the 0.08-radius sphere but inside tolerance.
const nearMissNdc = new THREE.Vector2(10 / (CANVAS.clientWidth / 2), 0)
const picked = pickHandleAtPointer(
raycaster,
nearMissNdc,
camera,
[handle],
CANVAS
)
expect(picked).toBe('pitch')
})
it('returns null when the pointer is far from every handle', () => {
const handle = makeHandle('distance', new THREE.Vector3(0, 0, 0))
const picked = pickHandleAtPointer(
raycaster,
new THREE.Vector2(0.5, 0.5),
camera,
[handle],
CANVAS
)
expect(picked).toBeNull()
})
it('prefers the nearest handle when several are within tolerance', () => {
const near = makeHandle('yaw', new THREE.Vector3(0.05, 0, 5))
const far = makeHandle('pitch', new THREE.Vector3(-0.3, 0, 5))
const picked = pickHandleAtPointer(
raycaster,
new THREE.Vector2(0.02, 0),
camera,
[far, near],
CANVAS
)
expect(picked).toBe('yaw')
})
it('ignores handles behind the camera', () => {
const behind = makeHandle('yaw', new THREE.Vector3(0, 0, 20))
const picked = pickHandleAtPointer(
raycaster,
new THREE.Vector2(0, 0),
camera,
[behind],
CANVAS
)
expect(picked).toBeNull()
})
it('returns null with no targets', () => {
const picked = pickHandleAtPointer(
raycaster,
new THREE.Vector2(0, 0),
camera,
[],
CANVAS
)
expect(picked).toBeNull()
})
})

View File

@@ -1,51 +0,0 @@
import * as THREE from 'three'
const PICK_PIXEL_RADIUS = 14
interface CanvasSize {
clientWidth: number
clientHeight: number
}
function toScreenPx(
ndcX: number,
ndcY: number,
canvas: CanvasSize
): THREE.Vector2 {
return new THREE.Vector2(
(ndcX + 1) * 0.5 * canvas.clientWidth,
(1 - (ndcY + 1) * 0.5) * canvas.clientHeight
)
}
export function pickHandleAtPointer<T extends string>(
raycaster: THREE.Raycaster,
pointerNdc: THREE.Vector2,
camera: THREE.Camera,
targets: THREE.Object3D[],
canvas: CanvasSize
): T | null {
if (targets.length === 0) return null
raycaster.setFromCamera(pointerNdc, camera)
const hits = raycaster.intersectObjects(targets, false)
if (hits.length > 0) return hits[0].object.userData.handleType as T
const pointerPx = toScreenPx(pointerNdc.x, pointerNdc.y, canvas)
const world = new THREE.Vector3()
let best: T | null = null
let bestDistance = PICK_PIXEL_RADIUS
for (const target of targets) {
target.getWorldPosition(world)
const view = world.clone().applyMatrix4(camera.matrixWorldInverse)
if (view.z >= 0) continue // behind the camera
const ndc = world.project(camera)
const px = toScreenPx(ndc.x, ndc.y, canvas)
const distance = px.distanceTo(pointerPx)
if (distance < bestDistance) {
bestDistance = distance
best = target.userData.handleType as T
}
}
return best
}

View File

@@ -1,107 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
MAX_DISTANCE,
MAX_PITCH,
MIN_DISTANCE,
MIN_PITCH,
pointToDistance,
pointToPitchAngle,
pointToYawAngle
} from './orbitDragMath'
const TARGET = { x: 0, y: 0, z: 0 }
const SHIFTED_TARGET = { x: 2, y: 1, z: -3 }
describe('pointToYawAngle', () => {
it('returns 0 when the point sits on +Z relative to the target', () => {
expect(pointToYawAngle({ x: 0, y: 0, z: 5 }, TARGET)).toBeCloseTo(0)
})
it('returns 90 when the point sits on +X (rotation around Y)', () => {
expect(pointToYawAngle({ x: 5, y: 0, z: 0 }, TARGET)).toBeCloseTo(90)
})
it('returns -90 when the point sits on -X', () => {
expect(pointToYawAngle({ x: -5, y: 0, z: 0 }, TARGET)).toBeCloseTo(-90)
})
it('returns 180 (or -180) when the point sits on -Z', () => {
expect(
Math.abs(pointToYawAngle({ x: 0, y: 0, z: -5 }, TARGET))
).toBeCloseTo(180)
})
it('is invariant to the y component of the point and target', () => {
expect(
pointToYawAngle({ x: 3, y: 99, z: 4 }, { x: 0, y: -7, z: 0 })
).toBeCloseTo(pointToYawAngle({ x: 3, y: 0, z: 4 }, TARGET))
})
it('works against a non-origin target', () => {
expect(
pointToYawAngle(
{ x: SHIFTED_TARGET.x, y: 0, z: SHIFTED_TARGET.z + 5 },
SHIFTED_TARGET
)
).toBeCloseTo(0)
})
})
describe('pointToPitchAngle', () => {
it('returns 0 when the point lies in the horizontal plane', () => {
expect(pointToPitchAngle({ x: 0, y: 0, z: 5 }, TARGET, 0)).toBeCloseTo(0)
})
it('returns +45 for a point lifted to (0, h, h) at yaw=0', () => {
expect(pointToPitchAngle({ x: 0, y: 5, z: 5 }, TARGET, 0)).toBeCloseTo(45)
})
it('returns -30 for a downward-pointing intersection at yaw=0', () => {
const h = 5
const v = h * Math.tan((30 * Math.PI) / 180)
expect(pointToPitchAngle({ x: 0, y: -v, z: h }, TARGET, 0)).toBeCloseTo(-30)
})
it('respects the current yaw direction when projecting horizontally', () => {
expect(pointToPitchAngle({ x: 5, y: 5, z: 0 }, TARGET, 90)).toBeCloseTo(45)
})
it('clamps to MAX_PITCH past the upper pole', () => {
expect(pointToPitchAngle({ x: 0, y: 100, z: 0.001 }, TARGET, 0)).toBe(
MAX_PITCH
)
})
it('clamps to MIN_PITCH past the lower pole', () => {
expect(pointToPitchAngle({ x: 0, y: -100, z: 0.001 }, TARGET, 0)).toBe(
MIN_PITCH
)
})
})
describe('pointToDistance', () => {
it('returns the radial projection at yaw=0, pitch=0', () => {
expect(pointToDistance({ x: 0, y: 0, z: 7 }, TARGET, 0, 0)).toBeCloseTo(7)
})
it('returns the radial projection along the current yaw direction', () => {
expect(pointToDistance({ x: 4, y: 0, z: 0 }, TARGET, 90, 0)).toBeCloseTo(4)
})
it('ignores off-axis components of the point', () => {
expect(pointToDistance({ x: 99, y: 0, z: 5 }, TARGET, 0, 0)).toBeCloseTo(5)
})
it('clamps to MIN_DISTANCE when the projection goes behind the target', () => {
expect(pointToDistance({ x: 0, y: 0, z: -10 }, TARGET, 0, 0)).toBe(
MIN_DISTANCE
)
})
it('clamps to MAX_DISTANCE when the projection overshoots', () => {
expect(pointToDistance({ x: 0, y: 0, z: 99999 }, TARGET, 0, 0)).toBe(
MAX_DISTANCE
)
})
})

View File

@@ -1,50 +0,0 @@
import { clamp } from 'es-toolkit'
import type { Vector3Like } from 'three'
const RAD2DEG = 180 / Math.PI
const DEG2RAD = Math.PI / 180
export const MIN_DISTANCE = 0.5
export const MAX_DISTANCE = 100
export const MIN_PITCH = -89
export const MAX_PITCH = 89
export function pointToYawAngle(
point: Vector3Like,
target: Vector3Like
): number {
return Math.atan2(point.x - target.x, point.z - target.z) * RAD2DEG
}
export function pointToPitchAngle(
point: Vector3Like,
target: Vector3Like,
yawDeg: number
): number {
const y = yawDeg * DEG2RAD
const dx = point.x - target.x
const dy = point.y - target.y
const dz = point.z - target.z
const horizontal = dx * Math.sin(y) + dz * Math.cos(y)
const raw = Math.atan2(dy, horizontal) * RAD2DEG
return clamp(raw, MIN_PITCH, MAX_PITCH)
}
export function pointToDistance(
point: Vector3Like,
target: Vector3Like,
yawDeg: number,
pitchDeg: number
): number {
const y = yawDeg * DEG2RAD
const p = pitchDeg * DEG2RAD
const dirX = Math.cos(p) * Math.sin(y)
const dirY = Math.sin(p)
const dirZ = Math.cos(p) * Math.cos(y)
const dx = point.x - target.x
const dy = point.y - target.y
const dz = point.z - target.z
const projection = dx * dirX + dy * dirY + dz * dirZ
return clamp(projection, MIN_DISTANCE, MAX_DISTANCE)
}

View File

@@ -1,64 +0,0 @@
import { describe, expect, it } from 'vitest'
import { pointToRollAngle, rollBasis } from './rollDragMath'
const TARGET = { x: 0, y: 0, z: 0 }
describe('rollBasis', () => {
it('returns world (+X right, +Y up) when the camera looks along -Z', () => {
const basis = rollBasis(TARGET, { x: 0, y: 0, z: 5 })
expect(basis.up.x).toBeCloseTo(0)
expect(basis.up.y).toBeCloseTo(1)
expect(basis.up.z).toBeCloseTo(0)
expect(basis.right.x).toBeCloseTo(1)
expect(basis.right.y).toBeCloseTo(0)
expect(basis.right.z).toBeCloseTo(0)
})
it('falls back to a +X right when the camera looks straight down', () => {
const basis = rollBasis(TARGET, { x: 0, y: 5, z: 0 })
expect(basis.right.x).toBeCloseTo(1)
})
it('orients (up, right) tangent to the camera ray for an oblique view', () => {
const basis = rollBasis(TARGET, { x: 3, y: 4, z: 0 })
expect(basis.right.z).toBeCloseTo(-1)
expect(basis.up.dot(basis.right)).toBeCloseTo(0)
expect(basis.up.dot(basis.backward)).toBeCloseTo(0)
})
})
describe('pointToRollAngle', () => {
const CAMERA = { x: 0, y: 0, z: 5 }
it('returns 0 when the point sits in the +up direction', () => {
expect(pointToRollAngle({ x: 0, y: 1, z: 0 }, TARGET, CAMERA)).toBeCloseTo(
0
)
})
it('returns +90 when the point sits in the +right direction', () => {
expect(pointToRollAngle({ x: 1, y: 0, z: 0 }, TARGET, CAMERA)).toBeCloseTo(
90
)
})
it('returns -90 when the point sits in the -right direction', () => {
expect(pointToRollAngle({ x: -1, y: 0, z: 0 }, TARGET, CAMERA)).toBeCloseTo(
-90
)
})
it('returns 180 (or -180) when the point sits in the -up direction', () => {
expect(
Math.abs(pointToRollAngle({ x: 0, y: -1, z: 0 }, TARGET, CAMERA))
).toBeCloseTo(180)
})
it('is invariant to the off-plane component of the point', () => {
const base = pointToRollAngle({ x: 1, y: 1, z: 0 }, TARGET, CAMERA)
const offPlane = pointToRollAngle({ x: 1, y: 1, z: 7 }, TARGET, CAMERA)
expect(offPlane).toBeCloseTo(base)
})
})

View File

@@ -1,40 +0,0 @@
import * as THREE from 'three'
const RAD2DEG = 180 / Math.PI
export interface RollBasis {
up: THREE.Vector3
right: THREE.Vector3
backward: THREE.Vector3
}
export function rollBasis(
target: THREE.Vector3Like,
cameraPos: THREE.Vector3Like
): RollBasis {
const backward = new THREE.Vector3(
cameraPos.x - target.x,
cameraPos.y - target.y,
cameraPos.z - target.z
).normalize()
const worldUp = new THREE.Vector3(0, 1, 0)
const right = new THREE.Vector3().crossVectors(worldUp, backward)
if (right.lengthSq() < 1e-8) right.set(1, 0, 0)
else right.normalize()
const up = new THREE.Vector3().crossVectors(backward, right).normalize()
return { up, right, backward }
}
export function pointToRollAngle(
point: THREE.Vector3Like,
target: THREE.Vector3Like,
cameraPos: THREE.Vector3Like
): number {
const { up, right } = rollBasis(target, cameraPos)
const rel = new THREE.Vector3(
point.x - target.x,
point.y - target.y,
point.z - target.z
)
return Math.atan2(rel.dot(right), rel.dot(up)) * RAD2DEG
}

View File

@@ -1 +0,0 @@
export type DraggingChangeListener = (dragging: boolean) => void

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