Compare commits

...

19 Commits

Author SHA1 Message Date
CodeRabbit Fixer
0ed9a379c6 fix: test(assetsStore): improve test comprehensiveness and avoid inline type redefinition (#9728)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 02:15:24 +01:00
Jin Yi
dc3e455993 fix: cloud login page stuck on splash loader for unauthenticated users (#9725)
## Summary

Fix cloud login page showing only the splash loader (wave SVG) instead
of the login form when the user is not authenticated.

## Changes

- **What**: Remove splash loader on `CloudLayoutView` mount. Cloud
onboarding pages do not use workspace initialization, so the
`workspaceStore.spinner` transition (`true→false`) that normally removes
the splash never occurs — leaving it visible indefinitely.

Co-authored-by: Amp <amp@ampcode.com>
2026-03-11 08:35:48 +09:00
Dante
76006fca52 feat: add text widget stories and Number input stories (#9527)
<img width="842" height="488" alt="스크린샷 2026-03-07 오후 9 39 20"
src="https://github.com/user-attachments/assets/9ac8bfcd-c882-4661-851f-b08838d4fed1"
/>

## Summary
- Add Storybook stories for WidgetInputText, WidgetTextarea, and
ScrubableNumberInput
- Reorganize story titles under `Components/Input/` to align with Figma
design system
- Fix PrimeIcons not rendering in Storybook (caused by
`[&_*]:!font-inter` override)
- Fix knip unused export warnings (dead code removal + workspace config)

## Test plan
- [ ] Run `pnpm storybook` and verify Components/Input/InputText stories
render
- [ ] Verify Components/Input/TextArea stories render with label and
copy button
- [ ] Verify Components/Input/Number stories render with -/+ icons
- [ ] Toggle Storybook theme between Light/Dark and confirm Number story
adapts
- [ ] Verify existing Button stories still render correctly

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9527-feat-add-text-widget-stories-and-Number-input-stories-31c6d73d3650817ba351cdef26a356c8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:34:57 +09:00
Dante
d2792cfac6 feat: add Storybook stories for Display components (#9702)
## Summary
- Add Storybook stories for `WidgetImageCompare` (Default,
WithBatchNavigation, SingleImageFallback, NoImages)
- WidgetGalleria and ImagePreview stories are deferred pending PrimeVue
removal

## Test plan
- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] Verified all stories render correctly in Storybook

Figma ref:
https://www.figma.com/design/vALUV83vIdBzEsTJAhQgXq/Comfy-Design-System?node-id=55-1536

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9702-feat-add-Storybook-stories-for-Display-components-31f6d73d365081e781faf3a8735aa3dc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:30:03 +09:00
Jin Yi
a786825093 feat: replace PrimeVue AutoComplete with SearchAutocomplete in ManagerDialog (#9645)
## Summary

Replace legacy PrimeVue `AutoCompletePlus` with a new
`SearchAutocomplete` component built on Reka UI, matching the
`SearchInput` design system.

## Changes

- **What**: Add `SearchAutocomplete` component extending `SearchInput`
with dropdown suggestions, IME composition handling, and generic typed
`optionLabel` support. Replace `AutoCompletePlus` usage in
`ManagerDialog`.
- **Dependencies**: None (uses existing Reka UI Combobox primitives)

## Review Focus

- `SearchAutocomplete` feature parity with the replaced
`AutoCompletePlus` (suggestions, option selection, IME handling)
- Dropdown styling and positioning via Reka UI `ComboboxContent`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9645-feat-replace-PrimeVue-AutoComplete-with-SearchAutocomplete-in-ManagerDialog-31e6d73d36508117ba0bef3d30dd0863)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-11 08:14:06 +09:00
Jin Yi
b0f3b69bda fix: add text color and increase size for nav badge count (#9713)
## Summary

Fix nav sidebar badge count not visible due to missing text color, and
increase badge size for better readability.

## Changes

- **What**: Added explicit `text-base-background` color and increased
min size (`min-h-5 min-w-5`) with padding to the StatusBadge in NavItem
so the count number is visible in dark mode.

## Review Focus

The parent NavItem div sets `text-base-foreground` which was overriding
the StatusBadge's contrast severity text color, making the count
invisible against the badge background.

## As-is
<img width="1607" height="1076" alt="스크린샷 2026-03-10 오후 9 28 17"
src="https://github.com/user-attachments/assets/f34de3fa-8930-4328-ba2b-03990a5e6f22"
/>

## To-be
<img width="1607" height="1058" alt="스크린샷 2026-03-10 오후 9 34 48"
src="https://github.com/user-attachments/assets/e420c359-78b7-4f5d-9d03-a600c51b880c"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9713-fix-add-text-color-and-increase-size-for-nav-badge-count-31f6d73d36508114874be2e31627099a)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-11 08:13:48 +09:00
Jin Yi
d11a0f6c5e feat: replace loading indicator with C logo fill loader and pre-Vue splash screen (#9516) 2026-03-11 08:00:10 +09:00
Jin Yi
f97c38e6ee fix: detect missing nodes when registry API fails to resolve packs (#9697) 2026-03-11 07:56:46 +09:00
Robin Huang
e89a0f96cd feat: track app mode entry and shared workflow loading (#9720)
## Summary

- Track entering app mode from template URL (`source: template_url`) and
default view dialog (`source: default_view_dialog`)
- Tag shared workflow loads with `openSource: 'shared'` instead of
defaulting to `'unknown'`
- Rename telemetry event from `app:toggle_linear_mode` to
`app:app_mode_opened`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9720-feat-track-app-mode-entry-and-shared-workflow-loading-31f6d73d365081af8c6ae3247a50cf3f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:05:19 -07:00
Robin Huang
12989e8b63 feat: add copy button to System Info panel (#9719)
## Summary

Adds a "Copy System Info" button next to the System Info heading in
Settings > About. Copies all system and device details as markdown text
for easy pasting into Slack or Notion.
<img width="1175" height="725" alt="Screenshot 2026-03-10 at 1 30 51 PM"
src="https://github.com/user-attachments/assets/6a091b6d-2246-4dc7-bc1d-8b5ebc2f9f9b"
/>


## Test plan
- Open Settings > About
- Click "Copy System Info" button
- Paste into Slack/Notion and verify formatting

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9719-feat-add-copy-button-to-System-Info-panel-31f6d73d36508148a06ae5f478ba62bf)
by [Unito](https://www.unito.io)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 13:57:11 -07:00
Hunter
c084605e4d fix: default frontend preview variant to cpu (#9718)
Frontend previews don't need GPU resources. Default to cpu variant and
only use gpu when the `preview-gpu` label is explicitly added.

The plain `preview` label now deploys a cpu-only ephemeral env.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9718-fix-default-frontend-preview-variant-to-cpu-31f6d73d3650811f878cd5dd5ad3881c)
by [Unito](https://www.unito.io)
2026-03-10 15:31:03 -04:00
Hunter
b368a865cf feat: dispatch frontend PR preview environments to cloud (#9715)
## Summary

Add support for deploying full ephemeral preview environments from
frontend PRs. This is the frontend-side half — it sends `pr_number` and
`variant` (cpu/gpu) in the dispatch payload, and adds a cleanup dispatch
on PR close/unlabel.

### Changes

- **`cloud-dispatch-build.yaml`** — Add `pr_number` and `variant` to the
`frontend-asset-build` dispatch payload. Variant is derived from which
preview label triggered the event (`preview-cpu` → cpu, else gpu).

- **`cloud-dispatch-cleanup.yaml`** (new) — Fire-and-forget dispatch of
`frontend-preview-cleanup` to the cloud repo when a frontend PR is
closed or has its preview label removed. Enables synchronized teardown.

### Companion PR

Cloud-side: Comfy-Org/cloud (creates the `deploy-frontend-preview` job,
extends the reconciler)

### How it works

1. Label a frontend PR with `preview`, `preview-cpu`, or `preview-gpu`
2. Assets build and upload to GCS (existing flow)
3. Cloud deploys a full ephemeral env at `fe-pr-{N}.testenvs.comfy.org`
using all `:main` service tags
4. Subsequent pushes update the frontend SHA via AppSet upsert
5. On close/unlabel, cleanup dispatch triggers immediate teardown

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9715-feat-dispatch-frontend-PR-preview-environments-to-cloud-31f6d73d3650819da1b5ca5ce419e06e)
by [Unito](https://www.unito.io)
2026-03-10 13:16:37 -04:00
AustinMroz
1d7a5b9e0b Mobile input tweaks (#9686)
- Buttons are marked as `touch-manipulation` so double-tapping on them
doesn't initiate a zoom.
- Move scrubable inputs to usePointerSwipe
- Strangely, swipe direction was inverted on mobile. This solves the
issue and simplifies code
  - Moves event handlers into the scrubbable input component
- Make the slightly bigger buttons only apply when on mobile.
- Updates the workflows dropdown to have a check by the activeWorkflow
and truncate workflow names
- Displays dropzones (for the image preview) on mobile, but disables the
prompt to drag and drop an image if none is selected.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9686-Mobile-input-tweaks-31f6d73d3650811d9025d0cd1ac58534)
by [Unito](https://www.unito.io)
2026-03-09 23:08:42 -07:00
AustinMroz
fbcd36d355 Revert flake snapshot update (#9699)
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9699-Revert-flake-snapshot-update-31f6d73d3650818db8a1f07aae70182e)
by [Unito](https://www.unito.io)
2026-03-09 22:18:42 -07:00
Robin Huang
e594164b71 feat: show App/Node Graph type indicator on template cards (#9695)
Show a type label (App or Node Graph) below the description on each
template card in the workflow templates modal. Templates with
`.app.json` suffix display an app icon with "App", all others show a
workflow icon with "Node Graph". Card size changed from compact to tall
to fit the extra row.


https://github.com/user-attachments/assets/dc14d7f5-2994-4764-aa96-c5fc5b634e7e

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9695-feat-show-App-Node-Graph-type-indicator-on-template-cards-31f6d73d3650813f8310c850f6107cf6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-09 22:17:45 -07:00
Jin Yi
245f840e7c fix: prevent HoneyToast from collapsing to minimum width in collapsed state (#9701)
## Summary

Fix HoneyToast footer content (text + buttons) being squished when
collapsed due to `sm:w-min` on the outer container.

## Changes

- **What**: Replace `sm:w-min sm:min-w-0` with state-aware sizing
(`sm:w-fit` when collapsed, `sm:w-[max(400px,40vw)]` when expanded) on
the HoneyToast container. Add `gap-4` between footer text and buttons in
ManagerProgressToast, `min-w-0` on text area, and `shrink-0` on button
area to prevent overlap.

## Review Focus

- HoneyToast is used in 3 places: ManagerProgressToast,
ModelImportProgressDialog, AssetExportProgressDialog. The change moves
width control from the inner content div to the outer container, which
should have no negative impact on the other two consumers.

## As-is
<img width="481" height="146" alt="스크린샷 2026-03-10 오전 10 40 52"
src="https://github.com/user-attachments/assets/b5e12e20-23ea-4f11-9778-ad4e6c10a425"
/>

## To-be
<img width="506" height="62" alt="스크린샷 2026-03-10 오후 1 46 18"
src="https://github.com/user-attachments/assets/f2b7963d-eedb-4885-bc57-f8b377962e92"
/>
<img width="630" height="83" alt="스크린샷 2026-03-10 오후 1 46 10"
src="https://github.com/user-attachments/assets/e9c35f1c-3441-4fb2-8fa4-f66b7d53b3e5"
/>
<img width="683" height="103" alt="스크린샷 2026-03-10 오후 1 46 02"
src="https://github.com/user-attachments/assets/afb94a16-cfba-4da9-8676-35f4d3133b57"
/>
2026-03-09 22:17:22 -07:00
Jin Yi
240b54419b fix: load API format workflows with missing node types (#9694)
## Summary

`loadApiJson` early-returns when missing node types are detected,
preventing the entire API-format workflow from loading onto the canvas.

## Changes

- **What**: Remove early `return` in `loadApiJson` so missing nodes are
skipped while the rest of the workflow loads normally, consistent with
how `loadGraphData` handles missing nodes in standard workflow format.

## Review Focus

The existing code already handles missing nodes gracefully:
- `LiteGraph.createNode()` returns `null` for unregistered types
- `if (!node) continue` skips missing nodes during graph construction
- `if (!fromNode) continue` skips connections to missing nodes
- `if (!node) return` skips input processing for missing nodes

The early `return` was unnecessarily preventing the entire load. The
warning modal is still shown via `showMissingNodesError`.

## Test workflow & screen recording
[04wan2.2smoothmix图生视频
(3).json](https://github.com/user-attachments/files/25858354/04wan2.2smoothmix.3.json)

[screen-capture.webm](https://github.com/user-attachments/assets/9c396f80-fff1-4d17-882c-35ada86542c1)
2026-03-09 22:13:41 -07:00
Robin Huang
d9020b7fbe feat: show user avatar for personal workspace (#9687)
Use the login provider's profile picture (Google, GitHub, etc.) as the
topbar avatar when in the personal workspace, instead of the generated
gradient letter avatar.

<img width="371" height="563" alt="Screenshot 2026-03-09 at 6 45 11 PM"
src="https://github.com/user-attachments/assets/29dc0d87-0bdb-497c-ab1d-f8d4d6784217"
/>


- Fixes #

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9687-feat-show-user-avatar-for-personal-workspace-31f6d73d36508191ab34fe7880e5e57e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:12:38 -07:00
Dante
c4272ef1da refactor: reorganize Select stories and add size/state variants (#9639)
<img width="373" height="535" alt="스크린샷 2026-03-09 오후 2 48 10"
src="https://github.com/user-attachments/assets/7fea3fd4-0d90-4022-ad78-c53e3d5be887"
/>


## Summary
- Reorganize Select-related stories under `Components/Select/` hierarchy
(SingleSelect, MultiSelect, Select)
- Add `size` prop (`lg`/`md`) to SingleSelect, MultiSelect,
SelectTrigger for Figma Large (40px) / Medium (32px) variants
- Add `invalid` prop (red border) to SingleSelect and SelectTrigger
- Add `loading` prop (spinner) to SingleSelect
- Add `hover:bg-secondary-background-hover` to all select triggers
- Align disabled opacity to 30% per Figma spec
- Add new stories: Disabled, Invalid, Loading, MediumSize, AllStates

## Test plan
- [ ] Verify Storybook renders all stories under `Components/Select/`
- [ ] Check hover state visually on all select triggers
- [ ] Verify Medium size (32px) renders correctly
- [ ] Verify Invalid state shows red border
- [ ] Verify Loading state shows spinner
- [ ] Verify Disabled state has 30% opacity and no hover effect

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9639-refactor-reorganize-Select-stories-and-add-size-state-variants-31e6d73d36508142b835f04ab6bdaefe)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:00:58 +09:00
73 changed files with 1899 additions and 1411 deletions

View File

@@ -38,6 +38,9 @@ TEST_COMFYUI_DIR=/home/ComfyUI
ALGOLIA_APP_ID=4E0RO38HS8
ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# Enable PostHog debug logging in the browser console.
# VITE_POSTHOG_DEBUG=true
# Sentry ENV vars replace with real ones for debugging
# SENTRY_AUTH_TOKEN=private-token # get from sentry
# SENTRY_ORG=comfy-org

View File

@@ -46,18 +46,30 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
PR_NUMBER: ${{ github.event.pull_request.number }}
ACTION: ${{ github.event.action }}
LABEL_NAME: ${{ github.event.label.name }}
PR_LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }}
run: |
if [ "${EVENT_NAME}" = "pull_request" ]; then
REF="${PR_HEAD_SHA}"
BRANCH="${PR_HEAD_REF}"
# Derive variant from all PR labels (default to cpu for frontend-only previews)
VARIANT="cpu"
echo "${PR_LABELS}" | grep -q '"preview-gpu"' && VARIANT="gpu"
else
REF="${GITHUB_SHA}"
BRANCH="${GITHUB_REF_NAME}"
PR_NUMBER=""
VARIANT=""
fi
payload="$(jq -nc \
--arg ref "${REF}" \
--arg branch "${BRANCH}" \
'{ref: $ref, branch: $branch}')"
--arg pr_number "${PR_NUMBER}" \
--arg variant "${VARIANT}" \
'{ref: $ref, branch: $branch, pr_number: $pr_number, variant: $variant}')"
echo "json=${payload}" >> "${GITHUB_OUTPUT}"
- name: Dispatch to cloud repo

View File

@@ -0,0 +1,39 @@
---
# Dispatches a frontend-preview-cleanup event to the cloud repo when a
# frontend PR with a preview label is closed or has its preview label
# removed. The cloud repo handles the actual environment teardown.
#
# This is fire-and-forget — it does NOT wait for the cloud workflow to
# complete. Status is visible in the cloud repo's Actions tab.
name: Cloud Frontend Preview Cleanup Dispatch
on:
pull_request:
types: [closed, unlabeled]
permissions: {}
jobs:
dispatch:
# Only dispatch when:
# - PR closed AND had a preview label
# - Preview label specifically removed
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
((github.event.action == 'closed' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))) ||
(github.event.action == 'unlabeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)))
runs-on: ubuntu-latest
steps:
- name: Dispatch to cloud repo
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
token: ${{ secrets.CLOUD_DISPATCH_TOKEN }}
repository: Comfy-Org/cloud
event-type: frontend-preview-cleanup
client-payload: >-
{"pr_number": "${{ github.event.pull_request.number }}"}

View File

@@ -58,7 +58,7 @@ export const withTheme = (Story: StoryFn, context: StoryContext) => {
document.documentElement.classList.remove('dark-theme')
document.body.classList.remove('dark-theme')
}
document.body.classList.add('[&_*]:!font-inter')
document.body.classList.add('font-inter')
return Story(context.args, context)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 119 KiB

File diff suppressed because one or more lines are too long

View File

@@ -20,6 +20,10 @@ const config: KnipConfig = {
'packages/tailwind-utils': {
project: ['src/**/*.{js,ts}']
},
'packages/shared-frontend-utils': {
project: ['src/**/*.{js,ts}'],
entry: ['src/formatUtil.ts', 'src/networkUtil.ts']
},
'packages/registry-types': {
project: ['src/**/*.{js,ts}']
}
@@ -32,7 +36,9 @@ const config: KnipConfig = {
'@primeuix/forms',
'@primeuix/styled',
'@primeuix/utils',
'@primevue/icons'
'@primevue/icons',
// Used by lucideStrokePlugin.js (CSS @plugin)
'@iconify/utils'
],
ignore: [
// Auto generated manager types
@@ -47,7 +53,9 @@ const config: KnipConfig = {
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js'
'.agents/checks/eslint.strict.config.js',
// Loaded via @plugin directive in CSS, not detected by knip
'packages/design-system/src/css/lucideStrokePlugin.js'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

51
public/splash.css Normal file
View File

@@ -0,0 +1,51 @@
/* Pre-Vue splash loader — colors set by inline script */
#splash-loader {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
contain: strict;
}
#splash-loader svg {
width: min(200px, 50vw);
height: auto;
transform: translateZ(0);
}
#splash-loader .wave-group {
animation: splash-rise 4s ease-in-out infinite alternate;
will-change: transform;
transform: translateZ(0);
}
#splash-loader .wave-path {
animation: splash-wave 1.2s linear infinite;
will-change: transform;
transform: translateZ(0);
}
@keyframes splash-rise {
from {
transform: translateY(280px);
}
to {
transform: translateY(-80px);
}
}
@keyframes splash-wave {
from {
transform: translateX(0);
}
to {
transform: translateX(-880px);
}
}
@media (prefers-reduced-motion: reduce) {
#splash-loader .wave-group,
#splash-loader .wave-path {
animation: none;
}
#splash-loader .wave-group {
transform: translateY(-80px);
}
}

View File

@@ -2,20 +2,13 @@
<router-view />
<GlobalDialog />
<BlockUI full-screen :blocked="isLoading" />
<div
v-if="isLoading"
class="pointer-events-none fixed inset-0 z-1200 flex items-center justify-center"
>
<LogoComfyWaveLoader size="xl" color="yellow" />
</div>
</template>
<script setup lang="ts">
import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted } from 'vue'
import { computed, onMounted, watch } from 'vue'
import LogoComfyWaveLoader from '@/components/loader/LogoComfyWaveLoader.vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -31,6 +24,16 @@ app.extensionManager = useWorkspaceStore()
const conflictDetection = useConflictDetection()
const isLoading = computed<boolean>(() => workspaceStore.spinner)
watch(
isLoading,
(loading, prevLoading) => {
if (prevLoading && !loading) {
document.getElementById('splash-loader')?.remove()
}
},
{ flush: 'post' }
)
const showContextMenu = (event: MouseEvent) => {
const { target } = event
switch (true) {

View File

@@ -1,6 +1,7 @@
import { computed } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
@@ -54,6 +55,7 @@ export function useAppSetDefaultView() {
appliedAsApp,
onViewApp: () => {
closeAppliedDialog()
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
setMode('app')
},
onExitToWorkflow: () => {

View File

@@ -54,11 +54,12 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
:disabled="toValue(item.disabled) ?? !item.command"
@select="item.command?.({ originalEvent: $event, item })"
>
<i class="size-5" :class="item.icon" />
{{ item.label }}
<i class="size-5 shrink-0" :class="item.icon" />
<div class="mr-auto truncate" v-text="item.label" />
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
<div
v-if="item.new"
class="ml-auto flex items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
v-else-if="item.new"
class="flex shrink-0 items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
v-text="t('contextMenu.new')"
/>
</DropdownMenuItem>

View File

@@ -27,7 +27,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
const itemClass = computed(() =>
cn(
'm-1 flex cursor-pointer gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
'm-1 flex cursor-pointer items-center-safe gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
itemProp
)
)

View File

@@ -0,0 +1,160 @@
import type {
ComponentPropsAndSlots,
Meta,
StoryObj
} from '@storybook/vue3-vite'
import { ref, toRefs } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
import ScrubableNumberInput from './ScrubableNumberInput.vue'
type StoryArgs = ComponentPropsAndSlots<typeof ScrubableNumberInput>
const meta: Meta<StoryArgs> = {
title: 'Components/Input/Number',
component: ScrubableNumberInput,
tags: ['autodocs'],
parameters: { layout: 'centered' },
argTypes: {
min: { control: 'number' },
max: { control: 'number' },
step: { control: 'number' },
disabled: { control: 'boolean' },
hideButtons: { control: 'boolean' }
},
args: {
min: 0,
max: 100,
step: 1,
disabled: false,
hideButtons: false
},
decorators: [
(story) => ({
components: { story },
template: '<div class="w-60"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { ScrubableNumberInput },
setup() {
const { min, max, step, disabled, hideButtons } = toRefs(args)
const value = ref(42)
return { value, min, max, step, disabled, hideButtons }
},
template:
'<ScrubableNumberInput v-model="value" :min :max :step :disabled :hideButtons />'
})
}
export const Disabled: Story = {
args: { disabled: true },
render: (args) => ({
components: { ScrubableNumberInput },
setup() {
const { disabled } = toRefs(args)
const value = ref(50)
return { value, disabled }
},
template:
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" :disabled />'
})
}
export const AtMinimum: Story = {
render: () => ({
components: { ScrubableNumberInput },
setup() {
const value = ref(0)
return { value }
},
template:
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" />'
})
}
export const AtMaximum: Story = {
render: () => ({
components: { ScrubableNumberInput },
setup() {
const value = ref(100)
return { value }
},
template:
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" />'
})
}
export const FloatPrecision: Story = {
args: { min: 0, max: 1, step: 0.01 },
render: (args) => ({
components: { ScrubableNumberInput },
setup() {
const { min, max, step } = toRefs(args)
const value = ref(0.75)
return { value, min, max, step }
},
template:
'<ScrubableNumberInput v-model="value" :min :max :step display-value="0.75" />'
})
}
export const LargeNumber: Story = {
render: () => ({
components: { ScrubableNumberInput },
setup() {
const value = ref(1809000312992)
return { value }
},
template:
'<ScrubableNumberInput v-model="value" :min="0" :max="Number.MAX_SAFE_INTEGER" :step="1" />'
})
}
export const HiddenButtons: Story = {
args: { hideButtons: true },
render: (args) => ({
components: { ScrubableNumberInput },
setup() {
const { hideButtons } = toRefs(args)
const value = ref(42)
return { value, hideButtons }
},
template:
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" :hideButtons />'
})
}
export const WithControlButton: Story = {
render: () => ({
components: { ScrubableNumberInput, Button, Popover },
setup() {
const value = ref(1809000312992)
return { value }
},
template: `
<ScrubableNumberInput v-model="value" :min="0" :max="Number.MAX_SAFE_INTEGER" :step="1">
<Popover>
<template #button>
<Button
variant="textonly"
size="sm"
class="h-4 w-7 self-center rounded-xl bg-primary-background/30 p-0 hover:bg-primary-background-hover/30"
>
<i class="icon-[lucide--shuffle] w-full text-xs text-primary-background" />
</Button>
</template>
<div class="p-4 text-sm">Control popover content</div>
</Popover>
</ScrubableNumberInput>
`
})
}

View File

@@ -33,19 +33,20 @@
spellcheck="false"
@blur="handleBlur"
@keyup.enter="handleBlur"
@dragstart.prevent
@keydown.up.prevent="updateValueBy(step)"
@keydown.down.prevent="updateValueBy(-step)"
@keydown.page-up.prevent="updateValueBy(10 * step)"
@keydown.page-down.prevent="updateValueBy(-10 * step)"
/>
<div
ref="swipeElement"
:class="
cn(
'absolute inset-0 z-10 cursor-ew-resize',
'absolute inset-0 z-10 cursor-ew-resize touch-pan-y',
textEdit && 'pointer-events-none hidden'
)
"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointercancel="resetDrag"
/>
</div>
<slot />
@@ -65,7 +66,7 @@
</template>
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import { onClickOutside, usePointerSwipe, whenever } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -73,8 +74,8 @@ import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const {
min,
max,
min = -Number.MAX_VALUE,
max = Number.MAX_VALUE,
step = 1,
disabled = false,
hideButtons = false,
@@ -96,6 +97,7 @@ const modelValue = defineModel<number>({ default: 0 })
const container = useTemplateRef<HTMLDivElement>('container')
const inputField = useTemplateRef<HTMLInputElement>('inputField')
const swipeElement = useTemplateRef('swipeElement')
const textEdit = ref(false)
onClickOutside(container, () => {
@@ -103,21 +105,11 @@ onClickOutside(container, () => {
})
function clamp(value: number): number {
const lo = min ?? -Infinity
const hi = max ?? Infinity
return Math.min(hi, Math.max(lo, value))
return Math.min(max, Math.max(min, value))
}
const canDecrement = computed(
() => modelValue.value > (min ?? -Infinity) && !disabled
)
const canIncrement = computed(
() => modelValue.value < (max ?? Infinity) && !disabled
)
const dragging = ref(false)
const dragDelta = ref(0)
const hasDragged = ref(false)
const canDecrement = computed(() => modelValue.value > min && !disabled)
const canIncrement = computed(() => modelValue.value < max && !disabled)
function handleBlur(e: Event) {
const target = e.target as HTMLInputElement
@@ -135,41 +127,27 @@ function handleBlur(e: Event) {
textEdit.value = false
}
function handlePointerDown(e: PointerEvent) {
if (e.button !== 0) return
if (disabled) return
const target = e.target as HTMLElement
target.setPointerCapture(e.pointerId)
dragging.value = true
dragDelta.value = 0
hasDragged.value = false
}
function handlePointerMove(e: PointerEvent) {
if (!dragging.value) return
dragDelta.value += e.movementX
const steps = (dragDelta.value / 10) | 0
if (steps === 0) return
hasDragged.value = true
const unclipped = modelValue.value + steps * step
dragDelta.value %= 10
modelValue.value = clamp(unclipped)
}
let dragDelta = 0
function handlePointerUp() {
if (!dragging.value) return
if (isSwiping.value) return
if (!hasDragged.value) {
textEdit.value = true
inputField.value?.focus()
inputField.value?.select()
}
resetDrag()
textEdit.value = true
inputField.value?.focus()
inputField.value?.select()
}
function resetDrag() {
dragging.value = false
dragDelta.value = 0
const { distanceX, isSwiping } = usePointerSwipe(swipeElement, {
onSwipeEnd: () => (dragDelta = 0)
})
whenever(distanceX, () => {
if (disabled) return
const delta = ((distanceX.value - dragDelta) / 10) | 0
dragDelta += delta * 10
modelValue.value = clamp(modelValue.value - delta * step)
})
function updateValueBy(delta: number) {
modelValue.value = Math.min(max, Math.max(min, modelValue.value + delta))
}
</script>

View File

@@ -1,9 +1,15 @@
<template>
<div class="system-stats">
<div class="mb-6">
<h2 class="mb-4 text-2xl font-semibold">
{{ $t('g.systemInfo') }}
</h2>
<div class="mb-4 flex items-center gap-2">
<h2 class="text-2xl font-semibold">
{{ $t('g.systemInfo') }}
</h2>
<Button variant="secondary" @click="copySystemInfo">
<i class="pi pi-copy" />
{{ $t('g.copySystemInfo') }}
</Button>
</div>
<div class="grid grid-cols-2 gap-2">
<template v-for="col in systemColumns" :key="col.field">
<div :class="cn('font-medium', isOutdated(col) && 'text-danger-100')">
@@ -46,6 +52,8 @@ import TabView from 'primevue/tabview'
import { computed } from 'vue'
import DeviceInfo from '@/components/common/DeviceInfo.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { isCloud } from '@/platform/distribution/types'
import type { SystemStats } from '@/schemas/apiSchema'
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
@@ -55,6 +63,8 @@ const props = defineProps<{
stats: SystemStats
}>()
const { copyToClipboard } = useCopyToClipboard()
const systemInfo = computed(() => ({
...props.stats.system,
argv: props.stats.system.argv.join(' ')
@@ -108,7 +118,7 @@ function isOutdated(column: ColumnDef): boolean {
return !!installed && !!required && installed !== required
}
const getDisplayValue = (column: ColumnDef) => {
function getDisplayValue(column: ColumnDef) {
const value = systemInfo.value[column.field]
if (column.formatNumber && typeof value === 'number') {
return column.formatNumber(value)
@@ -118,4 +128,33 @@ const getDisplayValue = (column: ColumnDef) => {
}
return value
}
function formatSystemInfoText(): string {
const lines: string[] = ['## System Info']
for (const col of systemColumns.value) {
const display = getDisplayValue(col)
if (display !== undefined && display !== '') {
lines.push(`${col.header}: ${display}`)
}
}
if (hasDevices.value) {
lines.push('')
lines.push('## Devices')
for (const device of props.stats.devices) {
lines.push(`- ${device.name} (${device.type})`)
lines.push(` VRAM Total: ${formatSize(device.vram_total)}`)
lines.push(` VRAM Free: ${formatSize(device.vram_free)}`)
lines.push(` Torch VRAM Total: ${formatSize(device.torch_vram_total)}`)
lines.push(` Torch VRAM Free: ${formatSize(device.torch_vram_free)}`)
}
}
return lines.join('\n')
}
function copySystemInfo() {
copyToClipboard(formatSystemInfoText())
}
</script>

View File

@@ -178,7 +178,7 @@
v-show="isTemplateVisibleOnDistribution(template)"
:key="template.name"
ref="cardRefs"
size="compact"
size="tall"
variant="ghost"
rounded="lg"
:data-testid="`template-workflow-${template.name}`"
@@ -318,6 +318,20 @@
</Button>
</div>
</div>
<div class="flex">
<span
class="text-neutral flex items-center gap-1.5 text-xs font-bold"
>
<template v-if="isAppTemplate(template)">
<i class="icon-[lucide--panels-top-left]" />
{{ $t('builderToolbar.app', 'App') }}
</template>
<template v-else>
<i class="icon-[lucide--workflow]" />
{{ $t('builderToolbar.nodeGraph', 'Node Graph') }}
</template>
</span>
</div>
</div>
</CardBottom>
</template>
@@ -483,6 +497,8 @@ const {
const getEffectiveSourceModule = (template: TemplateInfo) =>
template.sourceModule || 'default'
const isAppTemplate = (template: TemplateInfo) => template.name.endsWith('.app')
const getBaseThumbnailSrc = (template: TemplateInfo) => {
const sm = getEffectiveSourceModule(template)
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')

View File

@@ -479,50 +479,53 @@ useEventListener(
onMounted(async () => {
comfyApp.vueAppReady = true
workspaceStore.spinner = true
// ChangeTracker needs to be initialized before setup, as it will overwrite
// some listeners of litegraph canvas.
ChangeTracker.init()
try {
// ChangeTracker needs to be initialized before setup, as it will overwrite
// some listeners of litegraph canvas.
ChangeTracker.init()
await until(() => isSettingsReady.value || !!settingsError.value).toBe(true)
await until(() => isSettingsReady.value || !!settingsError.value).toBe(true)
if (settingsError.value) {
if (settingsError.value instanceof UnauthorizedError) {
localStorage.removeItem('Comfy.userId')
localStorage.removeItem('Comfy.userName')
window.location.reload()
return
if (settingsError.value) {
if (settingsError.value instanceof UnauthorizedError) {
localStorage.removeItem('Comfy.userId')
localStorage.removeItem('Comfy.userName')
window.location.reload()
return
}
throw settingsError.value
}
throw settingsError.value
// Register core settings immediately after settings are ready
CORE_SETTINGS.forEach(settingStore.addSetting)
await Promise.all([
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
useNewUserService().initializeIfNewUser()
])
if (i18nError.value) {
console.warn(
'[GraphCanvas] Failed to load custom nodes i18n:',
i18nError.value
)
}
// @ts-expect-error fixme ts strict error
await comfyApp.setup(canvasRef.value)
canvasStore.canvas = comfyApp.canvas
canvasStore.canvas.render_canvas_border = false
useSearchBoxStore().setPopoverRef(nodeSearchboxPopoverRef.value)
window.app = comfyApp
window.graph = comfyApp.graph
comfyAppReady.value = true
vueNodeLifecycle.setupEmptyGraphListener()
} finally {
workspaceStore.spinner = false
}
// Register core settings immediately after settings are ready
CORE_SETTINGS.forEach(settingStore.addSetting)
await Promise.all([
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
useNewUserService().initializeIfNewUser()
])
if (i18nError.value) {
console.warn(
'[GraphCanvas] Failed to load custom nodes i18n:',
i18nError.value
)
}
// @ts-expect-error fixme ts strict error
await comfyApp.setup(canvasRef.value)
canvasStore.canvas = comfyApp.canvas
canvasStore.canvas.render_canvas_border = false
workspaceStore.spinner = false
useSearchBoxStore().setPopoverRef(nodeSearchboxPopoverRef.value)
window.app = comfyApp
window.graph = comfyApp.graph
comfyAppReady.value = true
vueNodeLifecycle.setupEmptyGraphListener()
comfyApp.canvas.onSelectionChange = useChainCallback(
comfyApp.canvas.onSelectionChange,
() => canvasStore.updateSelectedItems()

View File

@@ -26,15 +26,18 @@ function toggle() {
v-if="visible"
role="status"
aria-live="polite"
class="fixed inset-x-4 bottom-6 z-9999 mx-auto w-auto max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg transition-all duration-300 sm:inset-x-0 sm:w-min sm:min-w-0"
:class="
cn(
'fixed inset-x-4 bottom-6 z-9999 mx-auto w-auto max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg transition-all duration-300 sm:inset-x-0',
isExpanded ? 'sm:w-[max(400px,40vw)]' : 'sm:w-fit'
)
"
>
<div
:class="
cn(
'max-w-full min-w-0 overflow-hidden transition-all duration-300',
isExpanded
? 'max-h-100 w-full sm:w-[max(400px,40vw)]'
: 'max-h-0 w-0'
isExpanded ? 'max-h-100 w-full' : 'max-h-0 w-0'
)
"
>

View File

@@ -1,73 +1,33 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { MultiSelectProps } from 'primevue/multiselect'
import { ref } from 'vue'
import MultiSelect from './MultiSelect.vue'
import type { SelectOption } from './types'
// Combine our component props with PrimeVue MultiSelect props
// Since we use v-bind="$attrs", all PrimeVue props are available
interface ExtendedProps extends Partial<MultiSelectProps> {
// Our custom props
label?: string
showSearchBox?: boolean
showSelectedCount?: boolean
showClearButton?: boolean
searchPlaceholder?: string
listMaxHeight?: string
popoverMinWidth?: string
popoverMaxWidth?: string
// Override modelValue type to match our Option type
modelValue?: SelectOption[]
}
const meta: Meta<ExtendedProps> = {
title: 'Components/Input/MultiSelect',
const meta: Meta<typeof MultiSelect> = {
title: 'Components/Select/MultiSelect',
component: MultiSelect,
tags: ['autodocs'],
parameters: { layout: 'padded' },
decorators: [
() => ({
template: '<div class="pt-4"><story /></div>'
})
],
argTypes: {
label: {
control: 'text'
label: { control: 'text' },
size: {
control: { type: 'select' },
options: ['lg', 'md']
},
options: {
control: 'object'
},
showSearchBox: {
control: 'boolean',
description: 'Toggle searchBar visibility'
},
showSelectedCount: {
control: 'boolean',
description: 'Toggle selected count visibility'
},
showClearButton: {
control: 'boolean',
description: 'Toggle clear button visibility'
},
searchPlaceholder: {
control: 'text'
},
listMaxHeight: {
control: 'text',
description: 'Maximum height of the dropdown list'
},
popoverMinWidth: {
control: 'text',
description: 'Minimum width of the popover'
},
popoverMaxWidth: {
control: 'text',
description: 'Maximum width of the popover'
}
showSearchBox: { control: 'boolean' },
showSelectedCount: { control: 'boolean' },
showClearButton: { control: 'boolean' },
searchPlaceholder: { control: 'text' }
},
args: {
label: 'Select',
options: [
{ name: 'Vue', value: 'vue' },
{ name: 'React', value: 'react' },
{ name: 'Angular', value: 'angular' },
{ name: 'Svelte', value: 'svelte' }
],
label: 'Category',
size: 'lg',
showSearchBox: false,
showSelectedCount: false,
showClearButton: false,
@@ -78,352 +38,125 @@ const meta: Meta<ExtendedProps> = {
export default meta
type Story = StoryObj<typeof meta>
const sampleOptions: SelectOption[] = [
{ name: 'Vue', value: 'vue' },
{ name: 'React', value: 'react' },
{ name: 'Angular', value: 'angular' },
{ name: 'Svelte', value: 'svelte' }
]
export const Default: Story = {
render: (args) => ({
components: { MultiSelect },
setup() {
const selected = ref([])
const options = args.options || [
{ name: 'Vue', value: 'vue' },
{ name: 'React', value: 'react' },
{ name: 'Angular', value: 'angular' },
{ name: 'Svelte', value: 'svelte' }
]
return { selected, options, args }
const selected = ref<SelectOption[]>([])
return { selected, sampleOptions, args }
},
template: `
<div>
<MultiSelect
v-model="selected"
:options="options"
:label="args.label"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/>
<div class="mt-4 p-3 bg-base-background rounded">
<p class="text-sm">Selected: {{ selected.length > 0 ? selected.map(s => s.name).join(', ') : 'None' }}</p>
</div>
</div>
`
template:
'<MultiSelect v-model="selected" :options="sampleOptions" :label="args.label" :size="args.size" :show-search-box="args.showSearchBox" :show-selected-count="args.showSelectedCount" :show-clear-button="args.showClearButton" />'
})
}
export const WithPreselectedValues: Story = {
render: (args) => ({
export const MediumSize: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const options = args.options || [
{ name: 'JavaScript', value: 'js' },
{ name: 'TypeScript', value: 'ts' },
{ name: 'Python', value: 'python' },
{ name: 'Go', value: 'go' },
{ name: 'Rust', value: 'rust' }
]
const selected = ref([options[0], options[1]])
return { selected, options, args }
const selected = ref<SelectOption[]>([sampleOptions[0]])
return { selected, sampleOptions }
},
template: `
<div>
<MultiSelect
v-model="selected"
:options="options"
:label="args.label"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/>
<div class="mt-4 p-3 bg-base-background rounded">
<p class="text-sm">Selected: {{ selected.map(s => s.name).join(', ') }}</p>
</div>
</div>
`
template:
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" size="md" />'
}),
args: {
label: 'Select Languages',
options: [
{ name: 'JavaScript', value: 'js' },
{ name: 'TypeScript', value: 'ts' },
{ name: 'Python', value: 'python' },
{ name: 'Go', value: 'go' },
{ name: 'Rust', value: 'rust' }
],
showSearchBox: false,
showSelectedCount: false,
showClearButton: false,
searchPlaceholder: 'Search...'
}
parameters: { controls: { disable: true } }
}
export const MultipleSelectors: Story = {
export const WithPreselectedValues: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected = ref<SelectOption[]>([sampleOptions[0], sampleOptions[1]])
return { selected, sampleOptions }
},
template:
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" />'
}),
parameters: { controls: { disable: true } }
}
export const Disabled: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected = ref<SelectOption[]>([sampleOptions[0]])
return { selected, sampleOptions }
},
template:
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" disabled />'
}),
parameters: { controls: { disable: true } }
}
export const WithSearchBox: Story = {
args: { showSearchBox: true },
render: (args) => ({
components: { MultiSelect },
setup() {
const frameworkOptions = ref([
{ name: 'Vue', value: 'vue' },
{ name: 'React', value: 'react' },
{ name: 'Angular', value: 'angular' },
{ name: 'Svelte', value: 'svelte' }
])
const selected = ref<SelectOption[]>([])
return { selected, sampleOptions, args }
},
template:
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" :show-search-box="args.showSearchBox" />'
})
}
const projectOptions = ref([
{ name: 'Project A', value: 'proj-a' },
{ name: 'Project B', value: 'proj-b' },
{ name: 'Project C', value: 'proj-c' },
{ name: 'Project D', value: 'proj-d' }
])
export const AllHeaderFeatures: Story = {
args: {
showSearchBox: true,
showSelectedCount: true,
showClearButton: true
},
render: (args) => ({
components: { MultiSelect },
setup() {
const selected = ref<SelectOption[]>([])
return { selected, sampleOptions, args }
},
template:
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" :show-search-box="args.showSearchBox" :show-selected-count="args.showSelectedCount" :show-clear-button="args.showClearButton" />'
})
}
const tagOptions = ref([
{ name: 'Frontend', value: 'frontend' },
{ name: 'Backend', value: 'backend' },
{ name: 'Database', value: 'database' },
{ name: 'DevOps', value: 'devops' },
{ name: 'Testing', value: 'testing' }
])
const selectedFrameworks = ref([])
const selectedProjects = ref([])
const selectedTags = ref([])
return {
frameworkOptions,
projectOptions,
tagOptions,
selectedFrameworks,
selectedProjects,
selectedTags,
args
}
export const AllStates: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const a = ref<SelectOption[]>([])
const b = ref<SelectOption[]>([sampleOptions[0]])
const c = ref<SelectOption[]>([sampleOptions[0]])
return { sampleOptions, a, b, c }
},
template: `
<div class="space-y-4">
<div class="flex gap-2">
<MultiSelect
v-model="selectedFrameworks"
:options="frameworkOptions"
label="Select Frameworks"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/>
<MultiSelect
v-model="selectedProjects"
:options="projectOptions"
label="Select Projects"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/>
<MultiSelect
v-model="selectedTags"
:options="tagOptions"
label="Select Tags"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/>
<div class="flex flex-col gap-6">
<div>
<p class="mb-2 text-xs text-muted-foreground">Large (Interface)</p>
<div class="flex flex-col gap-3">
<MultiSelect v-model="a" :options="sampleOptions" label="Default" />
<MultiSelect v-model="b" :options="sampleOptions" label="With Selection" />
<MultiSelect v-model="c" :options="sampleOptions" label="Disabled" disabled />
</div>
</div>
<div class="p-4 bg-base-background rounded">
<h4 class="font-medium mt-0">Current Selection:</h4>
<div class="flex flex-col text-sm">
<p>Frameworks: {{ selectedFrameworks.length > 0 ? selectedFrameworks.map(s => s.name).join(', ') : 'None' }}</p>
<p>Projects: {{ selectedProjects.length > 0 ? selectedProjects.map(s => s.name).join(', ') : 'None' }}</p>
<p>Tags: {{ selectedTags.length > 0 ? selectedTags.map(s => s.name).join(', ') : 'None' }}</p>
<div>
<p class="mb-2 text-xs text-muted-foreground">Medium (Node)</p>
<div class="flex flex-col gap-3">
<MultiSelect v-model="a" :options="sampleOptions" label="Default" size="md" />
<MultiSelect v-model="b" :options="sampleOptions" label="With Selection" size="md" />
<MultiSelect v-model="c" :options="sampleOptions" label="Disabled" size="md" disabled />
</div>
</div>
</div>
`
}),
args: {
showSearchBox: false,
showSelectedCount: false,
showClearButton: false,
searchPlaceholder: 'Search...'
}
}
export const WithSearchBox: Story = {
...Default,
args: {
...Default.args,
showSearchBox: true
}
}
export const WithSelectedCount: Story = {
...Default,
args: {
...Default.args,
showSelectedCount: true
}
}
export const WithClearButton: Story = {
...Default,
args: {
...Default.args,
showClearButton: true
}
}
export const AllHeaderFeatures: Story = {
...Default,
args: {
...Default.args,
showSearchBox: true,
showSelectedCount: true,
showClearButton: true
}
}
export const CustomSearchPlaceholder: Story = {
...Default,
args: {
...Default.args,
showSearchBox: true,
searchPlaceholder: 'Filter packages...'
}
}
export const CustomMaxHeight: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected1 = ref([])
const selected2 = ref([])
const selected3 = ref([])
const manyOptions = Array.from({ length: 20 }, (_, i) => ({
name: `Option ${i + 1}`,
value: `option${i + 1}`
}))
return { selected1, selected2, selected3, manyOptions }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Small Height (10rem)</h3>
<MultiSelect
v-model="selected1"
:options="manyOptions"
label="Small Dropdown"
list-max-height="10rem"
show-selected-count
/>
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Default Height (28rem)</h3>
<MultiSelect
v-model="selected2"
:options="manyOptions"
label="Default Dropdown"
list-max-height="28rem"
show-selected-count
/>
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Large Height (32rem)</h3>
<MultiSelect
v-model="selected3"
:options="manyOptions"
label="Large Dropdown"
list-max-height="32rem"
show-selected-count
/>
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMinWidth: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected1 = ref([])
const selected2 = ref([])
const selected3 = ref([])
const options = [
{ name: 'A', value: 'a' },
{ name: 'B', value: 'b' },
{ name: 'Very Long Option Name Here', value: 'long' }
]
return { selected1, selected2, selected3, options }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
<MultiSelect v-model="selected1" :options="options" label="Auto" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min Width 18rem</h3>
<MultiSelect v-model="selected2" :options="options" label="Min 18rem" popover-min-width="18rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min Width 28rem</h3>
<MultiSelect v-model="selected3" :options="options" label="Min 28rem" popover-min-width="28rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMaxWidth: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected1 = ref([])
const selected2 = ref([])
const selected3 = ref([])
const longOptions = [
{ name: 'Short', value: 'short' },
{
name: 'This is a very long option name that would normally expand the dropdown',
value: 'long1'
},
{
name: 'Another extremely long option that demonstrates max-width constraint',
value: 'long2'
}
]
return { selected1, selected2, selected3, longOptions }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
<MultiSelect v-model="selected1" :options="longOptions" label="Auto" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Max Width 18rem</h3>
<MultiSelect v-model="selected2" :options="longOptions" label="Max 18rem" popover-max-width="18rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min 12rem Max 22rem</h3>
<MultiSelect v-model="selected3" :options="longOptions" label="Min & Max" popover-min-width="12rem" popover-max-width="22rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
controls: { disable: true }
}
}

View File

@@ -16,20 +16,23 @@
:pt="{
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'relative inline-flex h-10 cursor-pointer select-none',
'relative inline-flex cursor-pointer select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid',
selectedCount > 0
? 'border-node-component-border'
: 'border-transparent',
'focus-within:border-node-component-border',
{ 'cursor-default opacity-60': props.disabled }
selectedCount > 0 ? 'border-base-foreground' : 'border-transparent',
'focus-within:border-base-foreground',
props.disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
)
}),
labelContainer: {
class:
'flex-1 flex items-center overflow-hidden whitespace-nowrap pl-4 py-2 '
class: cn(
'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap',
size === 'md' ? 'pl-3' : 'pl-4'
)
},
label: {
class: 'p-0'
@@ -129,12 +132,12 @@
<!-- Trigger value (keep text scale identical) -->
<template #value>
<span class="text-sm">
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
{{ label }}
</span>
<span
v-if="selectedCount > 0"
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-primary-background text-xs font-semibold text-base-foreground"
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
>
{{ selectedCount }}
</span>
@@ -197,6 +200,8 @@ defineOptions({
interface Props {
/** Input label shown on the trigger button */
label?: string
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
size?: 'lg' | 'md'
/** Show search box in the panel header */
showSearchBox?: boolean
/** Show selected count text in the panel header */
@@ -216,6 +221,7 @@ interface Props {
}
const {
label,
size = 'lg',
showSearchBox = false,
showSelectedCount = false,
showClearButton = false,

View File

@@ -0,0 +1,219 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import MultiSelect from './MultiSelect.vue'
import SingleSelect from './SingleSelect.vue'
import type { SelectOption } from './types'
const meta: Meta = {
title: 'Components/Select/SelectDropdown',
tags: ['autodocs'],
parameters: { layout: 'padded' },
decorators: [
() => ({
template: '<div class="pt-4"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
const modelOptions: SelectOption[] = [
{ name: 'ACE-Step', value: 'ace-step' },
{ name: 'Anima', value: 'anima' },
{ name: 'BRIA', value: 'bria' },
{ name: 'ByteDance', value: 'bytedance' },
{ name: 'Capybara', value: 'capybara' },
{ name: 'Chatter Box', value: 'chatter-box' },
{ name: 'Chroma', value: 'chroma' },
{ name: 'ChronoEdit', value: 'chronoedit' },
{ name: 'DWPose', value: 'dwpose' },
{ name: 'Depth Anything v2', value: 'depth-anything-v2' },
{ name: 'ElevenLabs', value: 'elevenlabs' },
{ name: 'Flux', value: 'flux' },
{ name: 'HunyuanVideo', value: 'hunyuan-video' },
{ name: 'Stable Diffusion', value: 'stable-diffusion' },
{ name: 'SDXL', value: 'sdxl' }
]
const useCaseOptions: SelectOption[] = [
{ name: 'Text to Image', value: 'text-to-image' },
{ name: 'Image to Image', value: 'image-to-image' },
{ name: 'Inpainting', value: 'inpainting' },
{ name: 'Upscaling', value: 'upscaling' },
{ name: 'Video Generation', value: 'video-generation' },
{ name: 'Audio Generation', value: 'audio-generation' },
{ name: '3D Generation', value: '3d-generation' }
]
const sortOptions: SelectOption[] = [
{ name: 'Default', value: 'default' },
{ name: 'Recommended', value: 'recommended' },
{ name: 'Popular', value: 'popular' },
{ name: 'Newest', value: 'newest' },
{ name: 'VRAM Usage (Low to High)', value: 'vram-low-to-high' },
{ name: 'Model Size (Low to High)', value: 'model-size-low-to-high' },
{ name: 'Alphabetical (A-Z)', value: 'alphabetical' }
]
export const ModelFilter: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected = ref<SelectOption[]>([
modelOptions[1],
modelOptions[2],
modelOptions[3]
])
return { selected, modelOptions }
},
template: `
<MultiSelect
v-model="selected"
:options="modelOptions"
:label="selected.length === 0 ? 'Models' : selected.length === 1 ? selected[0].name : selected.length + ' Models'"
show-search-box
show-selected-count
show-clear-button
class="w-[250px]"
>
<template #icon>
<i class="icon-[lucide--cpu]" />
</template>
</MultiSelect>
`
}),
parameters: { controls: { disable: true } }
}
export const UseCaseFilter: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected = ref<SelectOption[]>([])
return { selected, useCaseOptions }
},
template: `
<MultiSelect
v-model="selected"
:options="useCaseOptions"
:label="selected.length === 0 ? 'Use Case' : selected.length === 1 ? selected[0].name : selected.length + ' Use Cases'"
show-search-box
show-selected-count
show-clear-button
>
<template #icon>
<i class="icon-[lucide--target]" />
</template>
</MultiSelect>
`
}),
parameters: { controls: { disable: true } }
}
export const SortDropdown: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | undefined>('default')
return { selected, sortOptions }
},
template: `
<SingleSelect
v-model="selected"
:options="sortOptions"
label="Sort by"
class="w-62.5"
>
<template #icon>
<i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
</template>
</SingleSelect>
`
}),
parameters: { controls: { disable: true } }
}
export const TemplateFilterBar: Story = {
render: () => ({
components: { MultiSelect, SingleSelect },
setup() {
const selectedModels = ref<SelectOption[]>([
modelOptions[1],
modelOptions[2],
modelOptions[3]
])
const selectedUseCases = ref<SelectOption[]>([])
const sortBy = ref<string | undefined>('default')
const modelLabel = () => {
if (selectedModels.value.length === 0) return 'Models'
if (selectedModels.value.length === 1)
return selectedModels.value[0].name
return selectedModels.value.length + ' Models'
}
const useCaseLabel = () => {
if (selectedUseCases.value.length === 0) return 'Use Case'
if (selectedUseCases.value.length === 1)
return selectedUseCases.value[0].name
return selectedUseCases.value.length + ' Use Cases'
}
return {
selectedModels,
selectedUseCases,
sortBy,
modelOptions,
useCaseOptions,
sortOptions,
modelLabel,
useCaseLabel
}
},
template: `
<div class="flex flex-wrap items-center justify-between gap-2" style="min-width: 700px;">
<div class="flex flex-wrap gap-2">
<MultiSelect
v-model="selectedModels"
:options="modelOptions"
:label="modelLabel()"
show-search-box
show-selected-count
show-clear-button
class="w-[250px]"
>
<template #icon>
<i class="icon-[lucide--cpu]" />
</template>
</MultiSelect>
<MultiSelect
v-model="selectedUseCases"
:options="useCaseOptions"
:label="useCaseLabel()"
show-search-box
show-selected-count
show-clear-button
>
<template #icon>
<i class="icon-[lucide--target]" />
</template>
</MultiSelect>
</div>
<SingleSelect
v-model="sortBy"
:options="sortOptions"
label="Sort by"
class="w-62.5"
>
<template #icon>
<i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
</template>
</SingleSelect>
</div>
`
}),
parameters: { controls: { disable: true } }
}

View File

@@ -3,29 +3,31 @@ import { ref } from 'vue'
import SingleSelect from './SingleSelect.vue'
// SingleSelect already includes options prop, so no need to extend
const meta: Meta<typeof SingleSelect> = {
title: 'Components/Input/SingleSelect',
title: 'Components/Select/SingleSelect',
component: SingleSelect,
tags: ['autodocs'],
parameters: { layout: 'padded' },
decorators: [
() => ({
template: '<div class="pt-4"><story /></div>'
})
],
argTypes: {
label: { control: 'text' },
options: { control: 'object' },
listMaxHeight: {
control: 'text',
description: 'Maximum height of the dropdown list'
size: {
control: { type: 'select' },
options: ['lg', 'md']
},
popoverMinWidth: {
control: 'text',
description: 'Minimum width of the popover'
},
popoverMaxWidth: {
control: 'text',
description: 'Maximum width of the popover'
}
invalid: { control: 'boolean' },
loading: { control: 'boolean' }
},
args: {
label: 'Sorting Type',
label: 'Category',
size: 'lg',
invalid: false,
loading: false,
options: [
{ name: 'Popular', value: 'popular' },
{ name: 'Newest', value: 'newest' },
@@ -37,7 +39,7 @@ const meta: Meta<typeof SingleSelect> = {
}
export default meta
export type Story = StoryObj<typeof meta>
type Story = StoryObj<typeof meta>
const sampleOptions = [
{ name: 'Popular', value: 'popular' },
@@ -52,205 +54,118 @@ export const Default: Story = {
components: { SingleSelect },
setup() {
const selected = ref<string | null>(null)
const options = args.options || sampleOptions
return { selected, options, args }
return { selected, args }
},
template: `
<div>
<SingleSelect v-model="selected" :options="options" :label="args.label" />
<div class="mt-4 p-3 bg-base-background rounded">
<p class="text-sm">Selected: {{ selected ?? 'None' }}</p>
</div>
</div>
`
template:
'<SingleSelect v-model="selected" :options="args.options" :label="args.label" :size="args.size" :invalid="args.invalid" :loading="args.loading" />'
})
}
export const MediumSize: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | null>('popular')
return { selected, sampleOptions }
},
template:
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" size="md" />'
}),
parameters: { controls: { disable: true } }
}
export const WithIcon: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | null>('popular')
const options = sampleOptions
return { selected, options }
return { selected, sampleOptions }
},
template: `
<div>
<SingleSelect v-model="selected" :options="options" label="Sorting Type">
<template #icon>
<i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
</template>
</SingleSelect>
<div class="mt-4 p-3 bg-base-background rounded">
<p class="text-sm">Selected: {{ selected }}</p>
</div>
</div>
<SingleSelect v-model="selected" :options="sampleOptions" label="Sorting Type">
<template #icon>
<i class="icon-[lucide--arrow-up-down] size-3.5" />
</template>
</SingleSelect>
`
})
}),
parameters: { controls: { disable: true } }
}
export const Preselected: Story = {
export const Disabled: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | null>('newest')
const options = sampleOptions
return { selected, options }
const selected = ref<string | null>('popular')
return { selected, sampleOptions }
},
template: `
<SingleSelect v-model="selected" :options="options" label="Sorting Type" />
`
})
template:
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" disabled />'
}),
parameters: { controls: { disable: true } }
}
export const AllVariants: Story = {
export const Invalid: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const options = sampleOptions
const a = ref<string | null>(null)
const selected = ref<string | null>('popular')
return { selected, sampleOptions }
},
template:
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" invalid />'
}),
parameters: { controls: { disable: true } }
}
export const Loading: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | null>('popular')
return { selected, sampleOptions }
},
template:
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" loading />'
}),
parameters: { controls: { disable: true } }
}
export const AllStates: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const a = ref<string | null>('popular')
const b = ref<string | null>('popular')
const c = ref<string | null>('az')
return { options, a, b, c }
const c = ref<string | null>('popular')
const d = ref<string | null>('popular')
const e = ref<string | null>('popular')
return { sampleOptions, a, b, c, d, e }
},
template: `
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<SingleSelect v-model="a" :options="options" label="No Icon" />
<div class="flex flex-col gap-6">
<div>
<p class="mb-2 text-xs text-muted-foreground">Large (Interface)</p>
<div class="flex flex-col gap-3">
<SingleSelect v-model="a" :options="sampleOptions" label="Default" />
<SingleSelect v-model="b" :options="sampleOptions" label="Disabled" disabled />
<SingleSelect v-model="c" :options="sampleOptions" label="Invalid" invalid />
<SingleSelect v-model="d" :options="sampleOptions" label="Loading" loading />
</div>
</div>
<div class="flex items-center gap-3">
<SingleSelect v-model="b" :options="options" label="With Icon">
<template #icon>
<i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
</template>
</SingleSelect>
</div>
<div class="flex items-center gap-3">
<SingleSelect v-model="c" :options="options" label="Preselected (A→Z)" />
<div>
<p class="mb-2 text-xs text-muted-foreground">Medium (Node)</p>
<div class="flex flex-col gap-3">
<SingleSelect v-model="a" :options="sampleOptions" label="Default" size="md" />
<SingleSelect v-model="b" :options="sampleOptions" label="Disabled" size="md" disabled />
<SingleSelect v-model="c" :options="sampleOptions" label="Invalid" size="md" invalid />
<SingleSelect v-model="e" :options="sampleOptions" label="Loading" size="md" loading />
</div>
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMaxHeight: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | null>(null)
const manyOptions = Array.from({ length: 20 }, (_, i) => ({
name: `Option ${i + 1}`,
value: `option${i + 1}`
}))
return { selected, manyOptions }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Small Height (10rem)</h3>
<SingleSelect v-model="selected" :options="manyOptions" label="Small Dropdown" list-max-height="10rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Default Height (28rem)</h3>
<SingleSelect v-model="selected" :options="manyOptions" label="Default Dropdown" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Large Height (32rem)</h3>
<SingleSelect v-model="selected" :options="manyOptions" label="Large Dropdown" list-max-height="32rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMinWidth: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected1 = ref<string | null>(null)
const selected2 = ref<string | null>(null)
const selected3 = ref<string | null>(null)
const options = [
{ name: 'A', value: 'a' },
{ name: 'B', value: 'b' },
{ name: 'Very Long Option Name Here', value: 'long' }
]
return { selected1, selected2, selected3, options }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
<SingleSelect v-model="selected1" :options="options" label="Auto" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min Width 15rem</h3>
<SingleSelect v-model="selected2" :options="options" label="Min 15rem" popover-min-width="15rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min Width 25rem</h3>
<SingleSelect v-model="selected3" :options="options" label="Min 25rem" popover-min-width="25rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMaxWidth: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected1 = ref<string | null>(null)
const selected2 = ref<string | null>(null)
const selected3 = ref<string | null>(null)
const longOptions = [
{ name: 'Short', value: 'short' },
{
name: 'This is a very long option name that would normally expand the dropdown',
value: 'long1'
},
{
name: 'Another extremely long option that demonstrates max-width constraint',
value: 'long2'
}
]
return { selected1, selected2, selected3, longOptions }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
<SingleSelect v-model="selected1" :options="longOptions" label="Auto" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Max Width 15rem</h3>
<SingleSelect v-model="selected2" :options="longOptions" label="Max 15rem" popover-max-width="15rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min 10rem Max 20rem</h3>
<SingleSelect v-model="selected3" :options="longOptions" label="Min & Max" popover-min-width="10rem" popover-max-width="20rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
controls: { disable: true }
}
}

View File

@@ -15,23 +15,26 @@
unstyled
:pt="{
root: ({ props }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: [
// container
'h-10 relative inline-flex cursor-pointer select-none items-center',
// trigger surface
class: cn(
'relative inline-flex cursor-pointer items-center select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg',
'bg-secondary-background text-base-foreground',
'border-[2.5px] border-solid border-transparent',
'transition-all duration-200 ease-in-out',
'focus-within:border-node-component-border',
// disabled
{ 'opacity-60 cursor-default': props.disabled }
]
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid',
invalid
? 'border-destructive-background'
: 'border-transparent focus-within:border-node-component-border',
props.disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
)
}),
label: {
class:
// Align with MultiSelect labelContainer spacing
'flex-1 flex items-center whitespace-nowrap pl-4 py-2 outline-hidden'
class: cn(
'flex flex-1 items-center py-2 whitespace-nowrap outline-hidden',
size === 'md' ? 'pl-3' : 'pl-4'
)
},
dropdown: {
class:
@@ -77,6 +80,8 @@
}
}"
:aria-label="label || t('g.singleSelectDropdown')"
:aria-busy="loading || undefined"
:aria-invalid="invalid || undefined"
role="combobox"
:aria-expanded="false"
aria-haspopup="listbox"
@@ -84,8 +89,16 @@
>
<!-- Trigger value -->
<template #value="slotProps">
<div class="flex items-center gap-2 text-sm">
<slot name="icon" />
<div
:class="
cn('flex items-center gap-2', size === 'md' ? 'text-xs' : 'text-sm')
"
>
<i
v-if="loading"
class="icon-[lucide--loader-circle] animate-spin text-muted-foreground"
/>
<slot v-else name="icon" />
<span
v-if="slotProps.value !== null && slotProps.value !== undefined"
class="text-base-foreground"
@@ -98,9 +111,12 @@
</div>
</template>
<!-- Trigger caret -->
<!-- Trigger caret (hidden when loading) -->
<template #dropdownicon>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
<i
v-if="!loading"
class="icon-[lucide--chevron-down] text-muted-foreground"
/>
</template>
<!-- Option row -->
@@ -133,6 +149,9 @@ defineOptions({
const {
label,
options,
size = 'lg',
invalid = false,
loading = false,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
@@ -144,6 +163,12 @@ const {
* in getLabel() to map values to their display names.
*/
options?: SelectOption[]
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
size?: 'lg' | 'md'
/** Show invalid (destructive) border */
invalid?: boolean
/** Show loading spinner instead of chevron */
loading?: boolean
/** Maximum height of the dropdown panel (default: 28rem) */
listMaxHeight?: string
/** Minimum width of the popover (default: auto) */

View File

@@ -1,96 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import LogoCFillLoader from './LogoCFillLoader.vue'
const meta: Meta<typeof LogoCFillLoader> = {
title: 'Components/Loader/LogoCFillLoader',
component: LogoCFillLoader,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: { default: 'dark' }
},
argTypes: {
size: {
control: 'select',
options: ['sm', 'md', 'lg', 'xl']
},
color: {
control: 'select',
options: ['yellow', 'blue', 'white', 'black']
},
bordered: {
control: 'boolean'
},
disableAnimation: {
control: 'boolean'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Small: Story = {
args: { size: 'sm' }
}
export const Large: Story = {
args: { size: 'lg' }
}
export const ExtraLarge: Story = {
args: { size: 'xl' }
}
export const NoBorder: Story = {
args: { bordered: false }
}
export const Static: Story = {
args: { disableAnimation: true }
}
export const BrandColors: Story = {
render: () => ({
components: { LogoCFillLoader },
template: `
<div class="flex items-end gap-12">
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">Yellow</span>
<LogoCFillLoader size="lg" color="yellow" />
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">Blue</span>
<LogoCFillLoader size="lg" color="blue" />
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">White</span>
<LogoCFillLoader size="lg" color="white" />
</div>
<div class="p-4 bg-white rounded" style="background: white">
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-600">Black</span>
<LogoCFillLoader size="lg" color="black" />
</div>
</div>
</div>
`
})
}
export const AllSizes: Story = {
render: () => ({
components: { LogoCFillLoader },
template: `
<div class="flex items-end gap-8">
<LogoCFillLoader size="sm" color="yellow" />
<LogoCFillLoader size="md" color="yellow" />
<LogoCFillLoader size="lg" color="yellow" />
<LogoCFillLoader size="xl" color="yellow" />
</div>
`
})
}

View File

@@ -1,100 +0,0 @@
<template>
<span role="status" :class="cn('inline-flex', colorClass)">
<svg
:width="Math.round(heightMap[size] * (VB_W / VB_H))"
:height="heightMap[size]"
:viewBox="`0 0 ${VB_W} ${VB_H}`"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<mask :id="maskId">
<path :d="C_PATH" fill="white" />
</mask>
</defs>
<path
v-if="bordered"
:d="C_PATH"
stroke="currentColor"
stroke-width="2"
fill="none"
opacity="0.4"
/>
<g :mask="`url(#${maskId})`">
<rect
:class="disableAnimation ? undefined : 'c-fill-rect'"
:x="-BLEED"
:y="-BLEED"
:width="VB_W + BLEED * 2"
:height="VB_H + BLEED * 2"
fill="currentColor"
/>
</g>
</svg>
<span class="sr-only">{{ t('g.loading') }}</span>
</span>
</template>
<script setup lang="ts">
import { useId, computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { useI18n } from 'vue-i18n'
const {
size = 'md',
color = 'black',
bordered = true,
disableAnimation = false
} = defineProps<{
size?: 'sm' | 'md' | 'lg' | 'xl'
color?: 'yellow' | 'blue' | 'white' | 'black'
bordered?: boolean
disableAnimation?: boolean
}>()
const { t } = useI18n()
const maskId = `c-mask-${useId()}`
const VB_W = 185
const VB_H = 201
const BLEED = 1
// Larger than LogoComfyWaveLoader because the C logo is near-square (185×201)
// while the COMFY wordmark is wide (879×284), so larger heights are needed
// for visually comparable perceived size.
const heightMap = { sm: 48, md: 80, lg: 120, xl: 200 } as const
const colorMap = {
yellow: 'text-brand-yellow',
blue: 'text-brand-blue',
white: 'text-white',
black: 'text-black'
} as const
const colorClass = computed(() => colorMap[color])
const C_PATH =
'M42.1217 200.812C37.367 200.812 33.5304 199.045 31.0285 195.703C28.4569 192.27 27.7864 187.477 29.1882 182.557L34.8172 162.791C35.2661 161.217 34.9537 159.523 33.9747 158.214C32.9958 156.908 31.464 156.139 29.8371 156.139L13.6525 156.139C8.89521 156.139 5.05862 154.374 2.55797 151.032C-0.0136533 147.597-0.684085 142.804 0.71869 137.883L20.0565 70.289L22.1916 62.8625C25.0617 52.7847 35.5288 44.5943 45.528 44.5943L64.8938 44.5943C67.2048 44.5943 69.2376 43.0535 69.8738 40.8175L76.2782 18.3344C79.1454 8.26681 89.6127 0.0763962 99.6117 0.0763945L141.029 0.00258328L171.349-2.99253e-05C176.104-3.0756e-05 179.941 1.765 182.442 5.10626C185.013 8.53932 185.684 13.3324 184.282 18.2528L175.612 48.6947C172.746 58.7597 162.279 66.9475 152.28 66.9475L110.771 67.0265L91.4113 67.0265C89.1029 67.0265 87.0727 68.5647 86.4326 70.7983L70.2909 127.179C69.8394 128.756 70.1518 130.454 71.1334 131.763C72.1123 133.07 73.6441 133.839 75.2697 133.839C75.2736 133.839 102.699 133.785 102.699 133.785L132.929 133.785C137.685 133.785 141.522 135.55 144.023 138.892C146.594 142.327 147.265 147.12 145.862 152.041L137.192 182.478C134.326 192.545 123.859 200.733 113.86 200.733L72.3517 200.812L42.1217 200.812Z'
</script>
<style scoped>
.c-fill-rect {
animation: c-fill-up 2.5s cubic-bezier(0.25, 0, 0.3, 1) forwards;
will-change: transform;
}
@keyframes c-fill-up {
0% {
transform: translateY(calc(v-bind(VB_H) * 1px + v-bind(BLEED) * 1px));
}
100% {
transform: translateY(calc(v-bind(BLEED) * -1px));
}
}
@media (prefers-reduced-motion: reduce) {
.c-fill-rect {
animation: none;
}
}
</style>

View File

@@ -9,6 +9,18 @@ import enMessages from '@/locales/en/main.json' with { type: 'json' }
import CurrentUserButton from './CurrentUserButton.vue'
const mockFeatureFlags = vi.hoisted(() => ({
teamWorkspacesEnabled: false
}))
const mockTeamWorkspaceStore = vi.hoisted(() => ({
workspaceName: { value: '' },
initState: { value: 'idle' },
isInPersonalWorkspace: { value: false }
}))
const mockIsCloud = vi.hoisted(() => ({ value: false }))
// Mock all firebase modules
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
@@ -32,16 +44,19 @@ vi.mock('pinia', () => ({
// Mock the useFeatureFlags composable
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: vi.fn(() => ({
flags: { teamWorkspacesEnabled: false }
flags: mockFeatureFlags
}))
}))
// Mock the useTeamWorkspaceStore
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: vi.fn(() => ({
workspaceName: { value: '' },
initState: { value: 'idle' }
}))
useTeamWorkspaceStore: vi.fn(() => mockTeamWorkspaceStore)
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
}))
// Mock the useCurrentUser composable
@@ -64,6 +79,16 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
}
}))
// Mock the WorkspaceProfilePic component
vi.mock('@/platform/workspace/components/WorkspaceProfilePic.vue', () => ({
default: {
name: 'WorkspaceProfilePicMock',
render() {
return h('div', 'WorkspaceProfilePic')
}
}
}))
// Mock the CurrentUserPopoverLegacy component
vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
default: {
@@ -78,9 +103,15 @@ vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
describe('CurrentUserButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFeatureFlags.teamWorkspacesEnabled = false
mockTeamWorkspaceStore.workspaceName.value = ''
mockTeamWorkspaceStore.initState.value = 'idle'
mockTeamWorkspaceStore.isInPersonalWorkspace.value = false
mockIsCloud.value = false
})
const mountComponent = (): VueWrapper => {
const mountComponent = (options?: { stubButton?: boolean }): VueWrapper => {
const { stubButton = true } = options ?? {}
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -99,7 +130,7 @@ describe('CurrentUserButton', () => {
hide: vi.fn()
}
},
Button: true
...(stubButton ? { Button: true } : {})
}
}
})
@@ -137,4 +168,27 @@ describe('CurrentUserButton', () => {
// Verify that popover.hide was called
expect(popoverHideSpy).toHaveBeenCalled()
})
it('shows UserAvatar in personal workspace', () => {
mockIsCloud.value = true
mockFeatureFlags.teamWorkspacesEnabled = true
mockTeamWorkspaceStore.initState.value = 'ready'
mockTeamWorkspaceStore.isInPersonalWorkspace.value = true
const wrapper = mountComponent({ stubButton: false })
expect(wrapper.html()).toContain('Avatar')
expect(wrapper.html()).not.toContain('WorkspaceProfilePic')
})
it('shows WorkspaceProfilePic in team workspace', () => {
mockIsCloud.value = true
mockFeatureFlags.teamWorkspacesEnabled = true
mockTeamWorkspaceStore.initState.value = 'ready'
mockTeamWorkspaceStore.isInPersonalWorkspace.value = false
mockTeamWorkspaceStore.workspaceName.value = 'My Team'
const wrapper = mountComponent({ stubButton: false })
expect(wrapper.html()).toContain('WorkspaceProfilePic')
expect(wrapper.html()).not.toContain('Avatar')
})
})

View File

@@ -98,15 +98,21 @@ const photoURL = computed<string | undefined>(
() => userPhotoUrl.value ?? undefined
)
const { workspaceName: teamWorkspaceName, initState } = storeToRefs(
useTeamWorkspaceStore()
)
const {
workspaceName: teamWorkspaceName,
initState,
isInPersonalWorkspace
} = storeToRefs(useTeamWorkspaceStore())
const showWorkspaceSkeleton = computed(
() => isCloud && teamWorkspacesEnabled.value && initState.value === 'loading'
)
const showWorkspaceIcon = computed(
() => isCloud && teamWorkspacesEnabled.value && initState.value === 'ready'
() =>
isCloud &&
teamWorkspacesEnabled.value &&
initState.value === 'ready' &&
!isInPersonalWorkspace.value
)
const workspaceName = computed(() => {

View File

@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const buttonVariants = cva({
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0',
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer touch-manipulation whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0',
variants: {
variant: {
secondary:

View File

@@ -0,0 +1,213 @@
<template>
<ComboboxRoot
v-model="modelValue"
v-model:open="isOpen"
ignore-filter
:disabled
:class="className"
>
<ComboboxAnchor
:class="
cn(
searchInputVariants({ size }),
disabled && 'pointer-events-none opacity-50'
)
"
@click="focus"
>
<Button
v-if="modelValue"
:class="cn('absolute', sizeConfig.clearPos)"
variant="textonly"
size="icon-sm"
:aria-label="$t('g.clear')"
@click.stop="clearSearch"
>
<i :class="cn('icon-[lucide--x]', sizeConfig.icon)" />
</Button>
<i
v-else-if="loading"
:class="
cn(
'pointer-events-none absolute icon-[lucide--loader-circle] animate-spin',
sizeConfig.iconPos,
sizeConfig.icon
)
"
/>
<i
v-else
:class="
cn(
'pointer-events-none absolute',
sizeConfig.iconPos,
sizeConfig.icon,
icon
)
"
/>
<ComboboxInput
ref="inputRef"
v-model="modelValue"
:class="
cn(
'size-full border-none bg-transparent outline-none',
sizeConfig.inputPl,
sizeConfig.inputText
)
"
:placeholder="placeholderText"
:auto-focus="autofocus"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
@keydown.enter="onEnterKey"
/>
</ComboboxAnchor>
<ComboboxContent
v-if="suggestions.length > 0"
position="popper"
:side-offset="4"
:class="
cn(
'z-50 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
'rounded-lg border border-border-default bg-base-background p-1 shadow-lg'
)
"
>
<ComboboxItem
v-for="(suggestion, index) in suggestions"
:key="suggestionKey(suggestion, index)"
:value="suggestionValue(suggestion)"
:class="
cn(
'cursor-pointer rounded-sm px-3 py-2 text-sm outline-none',
'data-highlighted:bg-secondary-background-hover'
)
"
@select.prevent="onSelectSuggestion(suggestion)"
>
<slot name="suggestion" :suggestion>
{{ suggestionLabel(suggestion) }}
</slot>
</ComboboxItem>
</ComboboxContent>
</ComboboxRoot>
</template>
<script setup lang="ts" generic="T">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import {
ComboboxAnchor,
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxRoot
} from 'reka-ui'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { SearchInputVariants } from './searchInput.variants'
import {
searchInputSizeConfig,
searchInputVariants
} from './searchInput.variants'
const { t } = useI18n()
const {
placeholder,
icon = 'icon-[lucide--search]',
autofocus = false,
loading = false,
disabled = false,
size = 'md',
suggestions = [],
optionLabel,
optionKey,
class: className
} = defineProps<{
placeholder?: string
icon?: string
autofocus?: boolean
loading?: boolean
disabled?: boolean
size?: SearchInputVariants['size']
suggestions?: T[]
optionLabel?: keyof T & string
optionKey?: keyof T & string
class?: HTMLAttributes['class']
}>()
const emit = defineEmits<{
select: [item: T]
}>()
const sizeConfig = computed(() => searchInputSizeConfig[size])
const modelValue = defineModel<string>({ required: true })
const inputRef = ref<InstanceType<typeof ComboboxInput> | null>(null)
const isOpen = ref(false)
const isComposing = ref(false)
function focus() {
inputRef.value?.$el?.focus()
}
defineExpose({ focus })
const placeholderText = computed(
() => placeholder ?? t('g.searchPlaceholder', { subject: '' })
)
function clearSearch() {
modelValue.value = ''
focus()
}
function getItemProperty(item: T, key: keyof T & string): string {
if (typeof item === 'object' && item !== null) {
return String(item[key])
}
return String(item)
}
function suggestionLabel(item: T): string {
if (optionLabel) return getItemProperty(item, optionLabel)
return String(item)
}
function suggestionKey(item: T, index: number): string {
if (optionKey) return getItemProperty(item, optionKey)
return `${suggestionLabel(item)}-${index}`
}
function suggestionValue(item: T): string {
return suggestionLabel(item)
}
function onSelectSuggestion(item: T) {
modelValue.value = suggestionLabel(item)
isOpen.value = false
emit('select', item)
}
function onEnterKey(e: KeyboardEvent) {
if (isComposing.value) {
e.preventDefault()
e.stopPropagation()
}
}
watch(
() => suggestions,
(items) => {
isOpen.value = items.length > 0 && !!modelValue.value
}
)
</script>

View File

@@ -1,10 +1,5 @@
<template>
<ComboboxRoot
:ignore-filter="true"
:open="false"
:disabled="disabled"
:class="className"
>
<ComboboxRoot :open="false" ignore-filter :disabled :class="className">
<ComboboxAnchor
:class="
cn(

View File

@@ -1,261 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Select from './Select.vue'
import SelectContent from './SelectContent.vue'
import SelectGroup from './SelectGroup.vue'
import SelectItem from './SelectItem.vue'
import SelectLabel from './SelectLabel.vue'
import SelectSeparator from './SelectSeparator.vue'
import SelectTrigger from './SelectTrigger.vue'
import SelectValue from './SelectValue.vue'
const meta = {
title: 'Components/Select',
component: Select,
tags: ['autodocs'],
argTypes: {
modelValue: {
control: 'text',
description: 'Selected value'
},
disabled: {
control: 'boolean',
description: 'When true, disables the select'
},
'onUpdate:modelValue': { action: 'update:modelValue' }
}
} satisfies Meta<typeof Select>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref(args.modelValue || '')
return { value, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="cherry">Cherry</SelectItem>
<SelectItem value="grape">Grape</SelectItem>
<SelectItem value="orange">Orange</SelectItem>
</SelectContent>
</Select>
<div class="mt-4 text-sm text-muted-foreground">
Selected: {{ value || 'None' }}
</div>
`
}),
args: {
disabled: false
}
}
export const WithPlaceholder: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
return { value, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Choose an option..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="option1">Option 1</SelectItem>
<SelectItem value="option2">Option 2</SelectItem>
<SelectItem value="option3">Option 3</SelectItem>
</SelectContent>
</Select>
`
}),
args: {
disabled: false
}
}
export const Disabled: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('apple')
return { value, args }
},
template: `
<Select v-model="value" disabled>
<SelectTrigger class="w-56">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="cherry">Cherry</SelectItem>
</SelectContent>
</Select>
`
})
}
export const WithGroups: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
return { value, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select a model type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Checkpoints</SelectLabel>
<SelectItem value="sd15">SD 1.5</SelectItem>
<SelectItem value="sdxl">SDXL</SelectItem>
<SelectItem value="flux">Flux</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>LoRAs</SelectLabel>
<SelectItem value="lora-style">Style LoRA</SelectItem>
<SelectItem value="lora-character">Character LoRA</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Other</SelectLabel>
<SelectItem value="vae">VAE</SelectItem>
<SelectItem value="embedding">Embedding</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div class="mt-4 text-sm text-muted-foreground">
Selected: {{ value || 'None' }}
</div>
`
}),
args: {
disabled: false
}
}
export const Scrollable: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
const items = Array.from({ length: 20 }, (_, i) => ({
value: `item-${i + 1}`,
label: `Option ${i + 1}`
}))
return { value, items, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in items"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</SelectItem>
</SelectContent>
</Select>
`
}),
args: {
disabled: false
}
}
export const CustomWidth: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
return { value, args }
},
template: `
<div class="space-y-4">
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-32">
<SelectValue placeholder="Small" />
</SelectTrigger>
<SelectContent>
<SelectItem value="a">A</SelectItem>
<SelectItem value="b">B</SelectItem>
<SelectItem value="c">C</SelectItem>
</SelectContent>
</Select>
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-full">
<SelectValue placeholder="Full width select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="option1">Option 1</SelectItem>
<SelectItem value="option2">Option 2</SelectItem>
<SelectItem value="option3">Option 3</SelectItem>
</SelectContent>
</Select>
</div>
`
}),
args: {
disabled: false
}
}

View File

@@ -1,17 +0,0 @@
<script setup lang="ts">
import type { SelectGroupProps } from 'reka-ui'
import { SelectGroup } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectGroupProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectGroup :class="cn('w-full', className)" v-bind="restProps">
<slot />
</SelectGroup>
</template>

View File

@@ -1,25 +0,0 @@
<script setup lang="ts">
import type { SelectLabelProps } from 'reka-ui'
import { SelectLabel } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectLabelProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectLabel
v-bind="restProps"
:class="
cn(
'px-3 py-2 text-xs tracking-wide text-muted-foreground uppercase',
className
)
"
>
<slot />
</SelectLabel>
</template>

View File

@@ -1,18 +0,0 @@
<script setup lang="ts">
import type { SelectSeparatorProps } from 'reka-ui'
import { SelectSeparator } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectSeparatorProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectSeparator
v-bind="restProps"
:class="cn('-mx-1 my-1 h-px bg-border-default', className)"
/>
</template>

View File

@@ -5,24 +5,41 @@ import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectTriggerProps & { class?: HTMLAttributes['class'] }
const {
class: className,
size = 'lg',
invalid = false,
...restProps
} = defineProps<
SelectTriggerProps & {
class?: HTMLAttributes['class']
/** Trigger size: 'lg' (40px) or 'md' (32px) */
size?: 'lg' | 'md'
/** Show invalid (destructive) border */
invalid?: boolean
}
>()
</script>
<template>
<SelectTrigger
v-bind="restProps"
:aria-invalid="invalid || undefined"
:class="
cn(
'flex h-10 w-full cursor-pointer items-center justify-between select-none',
'rounded-lg px-4 py-2 text-sm',
'flex w-full cursor-pointer items-center justify-between select-none',
size === 'md' ? 'h-8 px-3 py-1 text-xs' : 'h-10 px-4 py-2 text-sm',
'rounded-lg',
'bg-secondary-background text-base-foreground',
'border-[2.5px] border-solid border-transparent',
'transition-all duration-200 ease-in-out',
'focus:border-node-component-border focus:outline-none',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid',
invalid
? 'border-destructive-background'
: 'border-transparent focus:border-node-component-border',
'focus:outline-none',
'data-placeholder:text-muted-foreground',
'disabled:cursor-not-allowed disabled:opacity-60',
'disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-secondary-background',
'[&>span]:truncate',
className
)

View File

@@ -25,7 +25,7 @@
:label="String(badge)"
severity="contrast"
variant="circle"
class="ml-auto"
class="ml-auto min-h-5 min-w-5 px-1 text-base-background"
/>
</div>
</template>

View File

@@ -93,6 +93,7 @@
"reportIssueTooltip": "Submit the error report to Comfy Org",
"reportSent": "Report Submitted",
"copyToClipboard": "Copy to Clipboard",
"copySystemInfo": "Copy System Info",
"copyAll": "Copy All",
"openNewIssue": "Open New Issue",
"showReport": "Show Report",
@@ -339,7 +340,8 @@
"conflicting": "Conflicting",
"inWorkflowSection": "IN WORKFLOW",
"allInWorkflow": "All in: {workflowName}",
"missingNodes": "Missing Nodes"
"missingNodes": "Missing Nodes",
"unresolvedNodes": "Unresolved Nodes"
},
"infoPanelEmpty": "Click an item to see the info",
"applyChanges": "Apply Changes",
@@ -404,6 +406,10 @@
"noDescription": "No description available",
"installSelected": "Install Selected",
"installAllMissingNodes": "Install All",
"unresolvedNodes": {
"title": "Unresolved Missing Nodes",
"message": "The following nodes are not installed and could not be found in the registry."
},
"allMissingNodesInstalled": "All missing nodes have been successfully installed",
"packsSelected": "packs selected",
"mixedSelectionMessage": "Cannot perform bulk action on mixed selection",
@@ -3181,6 +3187,7 @@
"deleteAllAssets": "Delete all assets from this run",
"hasCreditCost": "Requires additional credits",
"viewGraph": "View node graph",
"mobileNoWorkflow": "This workflow hasn't been built for app mode. Try a different one.",
"welcome": {
"title": "App Mode",
"message": "A simplified view that hides the node graph so you can focus on creating.",

View File

@@ -8,9 +8,14 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import GlobalToast from '@/components/toast/GlobalToast.vue'
import CloudTemplate from './CloudTemplate.vue'
onMounted(() => {
document.getElementById('splash-loader')?.remove()
})
</script>

View File

@@ -4,6 +4,7 @@ import type {
AuthMetadata,
BeginCheckoutMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
ExecutionTriggerSource,
@@ -160,6 +161,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackEnterLinear?.(metadata))
}
trackShareFlow(metadata: ShareFlowMetadata): void {
this.dispatch((provider) => provider.trackShareFlow?.(metadata))
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
this.dispatch((provider) => provider.trackPageVisibilityChanged?.(metadata))
}

View File

@@ -15,6 +15,7 @@ import type {
AuthMetadata,
CreditTopupMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionContext,
ExecutionTriggerSource,
ExecutionErrorMetadata,
@@ -362,6 +363,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
}
trackShareFlow(metadata: ShareFlowMetadata): void {
this.trackEvent(TelemetryEvents.SHARE_FLOW, metadata)
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
this.trackEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
}

View File

@@ -85,7 +85,8 @@ describe('PostHogTelemetryProvider', () => {
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
persistence: 'localStorage+cookie'
persistence: 'localStorage+cookie',
debug: false
})
})

View File

@@ -8,6 +8,7 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
AuthMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionContext,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
@@ -104,7 +105,8 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
persistence: 'localStorage+cookie'
persistence: 'localStorage+cookie',
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true'
})
this.isInitialized = true
this.flushEventQueue()
@@ -346,6 +348,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
}
trackShareFlow(metadata: ShareFlowMetadata): void {
this.trackEvent(TelemetryEvents.SHARE_FLOW, metadata)
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
this.trackEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
}

View File

@@ -137,13 +137,29 @@ export interface WorkflowImportMetadata {
/**
* The source of the workflow open/import action
*/
open_source?: 'file_button' | 'file_drop' | 'template' | 'unknown'
open_source?:
| 'file_button'
| 'file_drop'
| 'template'
| 'shared_url'
| 'unknown'
}
export interface EnterLinearMetadata {
source?: string
}
type ShareFlowStep =
| 'dialog_opened'
| 'save_prompted'
| 'link_created'
| 'link_copied'
export interface ShareFlowMetadata {
step: ShareFlowStep
source?: 'app_mode' | 'graph_mode'
}
/**
* Workflow open metadata
*/
@@ -362,6 +378,7 @@ export interface TelemetryProvider {
trackWorkflowImported?(metadata: WorkflowImportMetadata): void
trackWorkflowOpened?(metadata: WorkflowImportMetadata): void
trackEnterLinear?(metadata: EnterLinearMetadata): void
trackShareFlow?(metadata: ShareFlowMetadata): void
// Page visibility events
trackPageVisibilityChanged?(metadata: PageVisibilityMetadata): void
@@ -447,7 +464,8 @@ export const TelemetryEvents = {
// Workflow Management
WORKFLOW_IMPORTED: 'app:workflow_imported',
WORKFLOW_OPENED: 'app:workflow_opened',
ENTER_LINEAR_MODE: 'app:toggle_linear_mode',
ENTER_LINEAR_MODE: 'app:app_mode_opened',
SHARE_FLOW: 'app:share_flow',
// Page Visibility
PAGE_VISIBILITY_CHANGED: 'app:page_visibility_changed',
@@ -521,4 +539,5 @@ export type TelemetryEventProperties =
| HelpCenterClosedMetadata
| WorkflowCreatedMetadata
| EnterLinearMetadata
| ShareFlowMetadata
| SubscriptionMetadata

View File

@@ -26,17 +26,24 @@ import { refAutoReset } from '@vueuse/core'
import Button from '@/components/ui/button/Button.vue'
import Input from '@/components/ui/input/Input.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useTelemetry } from '@/platform/telemetry'
const { url } = defineProps<{
url: string
}>()
const { copyToClipboard } = useCopyToClipboard()
const { isAppMode } = useAppMode()
const copied = refAutoReset(false, 2000)
async function handleCopy() {
await copyToClipboard(url)
copied.value = true
useTelemetry()?.trackShareFlow({
step: 'link_copied',
source: isAppMode.value ? 'app_mode' : 'graph_mode'
})
}
</script>

View File

@@ -167,7 +167,9 @@ import type {
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useAppMode } from '@/composables/useAppMode'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useTelemetry } from '@/platform/telemetry'
import { appendJsonExt } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
@@ -182,6 +184,11 @@ const publishDialog = useComfyHubPublishDialog()
const shareService = useWorkflowShareService()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const { isAppMode } = useAppMode()
function getShareSource() {
return isAppMode.value ? 'app_mode' : ('graph_mode' as const)
}
type DialogState = 'loading' | 'unsaved' | 'ready' | 'shared' | 'stale'
type DialogMode = 'shareLink' | 'publishToHub'
@@ -298,6 +305,10 @@ async function refreshDialogState() {
if (!workflow || workflow.isTemporary || workflow.isModified) {
dialogState.value = 'unsaved'
useTelemetry()?.trackShareFlow({
step: 'save_prompted',
source: getShareSource()
})
if (workflow) {
workflowName.value = stripJsonExtension(workflow.filename)
}
@@ -379,6 +390,10 @@ const {
)
dialogState.value = 'shared'
acknowledged.value = false
useTelemetry()?.trackShareFlow({
step: 'link_created',
source: getShareSource()
})
return result
},

View File

@@ -1,4 +1,6 @@
import ShareWorkflowDialogContent from '@/platform/workflow/sharing/components/ShareWorkflowDialogContent.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { useWorkflowStore } from '../../management/stores/workflowStore'
@@ -13,6 +15,7 @@ export function useShareDialog() {
const dialogStore = useDialogStore()
const { pruneLinearData } = useAppModeStore()
const workflowStore = useWorkflowStore()
const { isAppMode } = useAppMode()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
@@ -51,7 +54,15 @@ export function useShareDialog() {
share()
}
function getShareSource() {
return isAppMode.value ? 'app_mode' : ('graph_mode' as const)
}
function showShareDialog() {
useTelemetry()?.trackShareFlow({
step: 'dialog_opened',
source: getShareSource()
})
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: ShareWorkflowDialogContent,

View File

@@ -164,7 +164,8 @@ describe('useSharedWorkflowUrlLoader', () => {
{ nodes: [] },
true,
true,
'Test Workflow'
'Test Workflow',
{ openSource: 'shared_url' }
)
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
@@ -360,7 +361,8 @@ describe('useSharedWorkflowUrlLoader', () => {
expect.anything(),
true,
true,
'Open shared workflow'
'Open shared workflow',
{ openSource: 'shared_url' }
)
})
})

View File

@@ -138,7 +138,9 @@ export function useSharedWorkflowUrlLoader() {
const nonOwnedAssets = payload.assets.filter((a) => !a.in_library)
try {
await app.loadGraphData(payload.workflowJson, true, true, workflowName)
await app.loadGraphData(payload.workflowJson, true, true, workflowName, {
openSource: 'shared_url'
})
} catch (error) {
console.error(
'[useSharedWorkflowUrlLoader] Failed to load workflow graph:',

View File

@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useTemplateWorkflows } from './useTemplateWorkflows'
@@ -121,6 +122,7 @@ export function useTemplateUrlLoader() {
})
} else if (modeParam === 'linear') {
// Set linear mode after successful template load
useTelemetry()?.trackEnterLinear({ source: 'template_url' })
canvasStore.linearMode = true
}
} catch (error) {

View File

@@ -81,18 +81,16 @@ describe('WorkspaceAuthGate', () => {
await flushPromises()
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
expect(wrapper.find('[role="status"]').exists()).toBe(false)
expect(mockRefreshRemoteConfig).not.toHaveBeenCalled()
})
})
describe('cloud builds - unauthenticated user', () => {
it('shows spinner while waiting for Firebase auth', () => {
it('hides slot while waiting for Firebase auth', () => {
mockIsInitialized.value = false
const wrapper = mountComponent()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(false)
})
@@ -100,7 +98,7 @@ describe('WorkspaceAuthGate', () => {
mockIsInitialized.value = false
const wrapper = mountComponent()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(false)
mockIsInitialized.value = true
mockCurrentUser.value = null
@@ -179,8 +177,8 @@ describe('WorkspaceAuthGate', () => {
const wrapper = mountComponent()
await flushPromises()
// Still showing spinner before timeout
expect(wrapper.find('[role="status"]').exists()).toBe(true)
// Slot not yet rendered before timeout
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(false)
// Advance past the 10 second timeout
await vi.advanceTimersByTimeAsync(10_001)

View File

@@ -1,11 +1,5 @@
<template>
<slot v-if="isReady" />
<div
v-else
class="fixed inset-0 z-1100 flex items-center justify-center bg-(--p-mask-background)"
>
<LogoComfyWaveLoader size="xl" color="yellow" disable-animation />
</div>
</template>
<script setup lang="ts">
@@ -20,6 +14,9 @@
*
* This prevents race conditions where API calls use Firebase tokens
* instead of workspace tokens when the workspace feature is enabled.
*
* The splash loader in index.html (z-9999) covers the screen during this
* phase, so no separate loading indicator is needed here.
*/
import { promiseTimeout, until } from '@vueuse/core'
import { storeToRefs } from 'pinia'
@@ -30,7 +27,6 @@ import { isCloud } from '@/platform/distribution/types'
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import LogoComfyWaveLoader from '@/components/loader/LogoComfyWaveLoader.vue'
const FIREBASE_INIT_TIMEOUT_MS = 16_000
const CONFIG_REFRESH_TIMEOUT_MS = 10_000

View File

@@ -120,7 +120,7 @@ function getDropIndicator(node: LGraphNode) {
return {
iconClass: 'icon-[lucide--image]',
imageUrl: buildImageUrl(),
label: t('linearMode.dragAndDropImage'),
label: props.mobile ? undefined : t('linearMode.dragAndDropImage'),
onClick: () => node.widgets?.[1]?.callback?.(undefined)
}
}
@@ -206,14 +206,14 @@ defineExpose({ runButtonClick })
<DropZone
:on-drag-over="nodeData.onDragOver"
:on-drag-drop="nodeData.onDragDrop"
:drop-indicator="mobile ? undefined : nodeData.dropIndicator"
:drop-indicator="nodeData.dropIndicator"
class="text-muted-foreground"
>
<NodeWidgets
:node-data
:class="
cn(
'gap-y-3 rounded-lg py-3 *:has-[textarea]:h-50 **:[.col-span-2]:grid-cols-1 **:[.h-7]:h-10',
'gap-y-3 rounded-lg py-3 *:has-[textarea]:h-50 **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
nodeData.hasErrors &&
'ring-2 ring-node-stroke-error ring-inset'
)
@@ -325,4 +325,9 @@ defineExpose({ runButtonClick })
</section>
</div>
</div>
<div
v-else-if="mobile"
class="flex size-full items-center bg-base-background p-4 text-center"
v-text="t('linearMode.mobileNoWorkflow')"
/>
</template>

View File

@@ -8,7 +8,6 @@ import DropdownMenu from '@/components/common/DropdownMenu.vue'
import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -76,13 +75,16 @@ function onClick(index: number) {
}
const workflowsEntries = computed(() => {
return workflowStore.openWorkflows.map((w) => ({
label: w.filename,
icon: w.activeState?.extra?.linearMode
? 'icon-[lucide--panels-top-left] bg-primary-background'
: undefined,
command: () => workflowService.openWorkflow(w)
}))
return [
...workflowStore.openWorkflows.map((w) => ({
label: w.filename,
icon: w.activeState?.extra?.linearMode
? 'icon-[lucide--panels-top-left] bg-primary-background'
: undefined,
command: () => workflowService.openWorkflow(w),
checked: workflowStore.activeWorkflow === w
}))
]
})
const menuEntries = computed<MenuItem[]>(() => [
@@ -157,9 +159,9 @@ const menuEntries = computed<MenuItem[]>(() => [
class="flex h-16 w-full items-center gap-3 border-b border-border-subtle bg-base-background px-4 py-3"
>
<DropdownMenu :entries="menuEntries" />
<Popover
<DropdownMenu
:entries="workflowsEntries"
class="w-(--reka-popover-content-available-width)"
class="max-h-[40vh] w-(--reka-dropdown-menu-content-available-width)"
:collision-padding="20"
>
<template #button>
@@ -179,7 +181,7 @@ const menuEntries = computed<MenuItem[]>(() => [
/>
</div>
</template>
</Popover>
</DropdownMenu>
<CurrentUserButton v-if="isLoggedIn" :show-arrow="false" />
</header>
<div class="size-full rounded-b-4xl contain-content">

View File

@@ -0,0 +1,104 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { ImageCompareValue } from './WidgetImageCompare.vue'
import WidgetImageCompare from './WidgetImageCompare.vue'
function createSampleImage(label: string, fill: string): string {
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">` +
`<rect width="512" height="512" fill="${fill}" />` +
`<text x="50%" y="50%" fill="white" font-size="40"` +
` text-anchor="middle" dominant-baseline="middle">` +
`${label}</text></svg>`
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
}
const SAMPLE_BEFORE = createSampleImage('Before', '#475569')
const SAMPLE_AFTER = createSampleImage('After', '#0f766e')
const meta: Meta<typeof WidgetImageCompare> = {
title: 'Components/Display/ImageCompare',
component: WidgetImageCompare,
tags: ['autodocs'],
parameters: { layout: 'centered' },
decorators: [
(story) => ({
components: { story },
template: '<div class="w-88 h-80"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { WidgetImageCompare },
setup() {
const widget = ref<SimplifiedWidget<ImageCompareValue>>({
name: 'compare',
type: 'IMAGE_COMPARE',
value: {
beforeImages: [SAMPLE_BEFORE],
afterImages: [SAMPLE_AFTER]
}
})
return { widget }
},
template: '<WidgetImageCompare :widget="widget" />'
})
}
export const WithBatchNavigation: Story = {
render: () => ({
components: { WidgetImageCompare },
setup() {
const widget = ref<SimplifiedWidget<ImageCompareValue>>({
name: 'compare',
type: 'IMAGE_COMPARE',
value: {
beforeImages: [SAMPLE_BEFORE, SAMPLE_AFTER],
afterImages: [SAMPLE_AFTER, SAMPLE_BEFORE],
beforeAlt: 'Before batch',
afterAlt: 'After batch'
}
})
return { widget }
},
template: '<WidgetImageCompare :widget="widget" />'
})
}
export const SingleImageFallback: Story = {
render: () => ({
components: { WidgetImageCompare },
setup() {
const widget = ref<SimplifiedWidget<string>>({
name: 'compare',
type: 'IMAGE_COMPARE',
value: SAMPLE_BEFORE
})
return { widget }
},
template: '<WidgetImageCompare :widget="widget" />'
})
}
export const NoImages: Story = {
render: () => ({
components: { WidgetImageCompare },
setup() {
const widget = ref<SimplifiedWidget<ImageCompareValue>>({
name: 'compare',
type: 'IMAGE_COMPARE',
value: {}
})
return { widget }
},
template: '<WidgetImageCompare :widget="widget" />'
})
}

View File

@@ -58,7 +58,7 @@ describe('WidgetImageCompare Display', () => {
expect(images[1].attributes('src')).toBe('https://example.com/before.jpg')
images.forEach((img) => {
expect(img.classes()).toContain('object-contain')
expect(img.classes()).toContain('object-cover')
})
})
})
@@ -290,7 +290,6 @@ describe('WidgetImageCompare Display', () => {
const slider = wrapper.find('[role="presentation"]')
expect(slider.exists()).toBe(true)
expect(slider.classes()).toContain('bg-white')
})
it('does not render slider when no images', () => {

View File

@@ -26,14 +26,14 @@
<div
v-if="beforeImage || afterImage"
ref="containerRef"
class="relative min-h-0 flex-1"
class="relative min-h-0 flex-1 overflow-hidden rounded-lg bg-node-component-surface py-4"
>
<img
v-if="afterImage"
:src="afterImage"
:alt="afterAlt"
draggable="false"
class="size-full object-contain"
class="absolute inset-0 size-full object-cover"
/>
<img
@@ -41,12 +41,18 @@
:src="beforeImage"
:alt="beforeAlt"
draggable="false"
class="absolute inset-0 size-full object-contain"
:style="{ clipPath: `inset(0 ${100 - sliderPosition}% 0 0)` }"
class="absolute inset-0 size-full object-cover"
:style="
hasCompareImages
? { clipPath: `inset(0 ${100 - sliderPosition}% 0 0)` }
: undefined
"
/>
<!-- Circular drag handle -->
<div
class="pointer-events-none absolute inset-y-0 z-10 w-0.5 bg-white shadow-md"
v-if="hasCompareImages"
class="pointer-events-none absolute top-1/2 z-10 size-6 -translate-1/2 rounded-full border-2 border-white bg-white/30 shadow-lg backdrop-blur-sm"
:style="{ left: `${sliderPosition}%` }"
role="presentation"
/>
@@ -142,6 +148,10 @@ const afterImage = computed(() => {
return value?.afterImages?.[afterIndex.value] ?? ''
})
const hasCompareImages = computed(() =>
Boolean(beforeImage.value && afterImage.value)
)
const beforeAlt = computed(() => {
const value = props.widget.value
return !isSingleImage(value) && value?.beforeAlt

View File

@@ -121,12 +121,6 @@ const buttonsDisabled = computed(() => {
)
})
function updateValueBy(delta: number) {
const max = filteredProps.value.max ?? Number.MAX_VALUE
const min = filteredProps.value.min ?? -Number.MAX_VALUE
modelValue.value = Math.min(max, Math.max(min, modelValue.value + delta))
}
const buttonTooltip = computed(() => {
if (buttonsDisabled.value) {
return 'Increment/decrement disabled: value exceeds JavaScript precision limit (±2^53)'
@@ -171,10 +165,6 @@ const inputAriaAttrs = computed(() => ({
:parse-value="parseWidgetValue"
:input-attrs="inputAriaAttrs"
:class="cn(WidgetInputBaseClass, 'relative flex h-7 grow text-xs')"
@keydown.up.prevent="updateValueBy(stepValue)"
@keydown.down.prevent="updateValueBy(-stepValue)"
@keydown.page-up.prevent="updateValueBy(10 * stepValue)"
@keydown.page-down.prevent="updateValueBy(-10 * stepValue)"
>
<template #background>
<div

View File

@@ -17,7 +17,7 @@ interface StoryArgs extends ComponentPropsAndSlots<typeof WidgetInputText> {
}
const meta: Meta<StoryArgs> = {
title: 'Widgets/WidgetInputText',
title: 'Components/Input/InputText',
component: WidgetInputText,
tags: ['autodocs'],
parameters: { layout: 'centered' },

View File

@@ -16,7 +16,7 @@ interface StoryArgs extends ComponentPropsAndSlots<typeof WidgetTextarea> {
}
const meta: Meta<StoryArgs> = {
title: 'Widgets/WidgetTextarea',
title: 'Components/Input/TextArea',
component: WidgetTextarea,
tags: ['autodocs'],
parameters: {

View File

@@ -1830,7 +1830,6 @@ export class ComfyApp {
)
if (missingNodeTypes.length) {
this.showMissingNodesError(missingNodeTypes.map((t) => t.class_type))
return
}
const ids = Object.keys(apiData)

View File

@@ -101,7 +101,7 @@ export const useColorPaletteService = () => {
linkColorPalette: Colors['node_slot']
) {
if (!linkColorPalette) return
const rootStyle = document.body?.style
const rootStyle = document.documentElement?.style
if (!rootStyle) return
for (const dataType of nodeDefStore.nodeDataTypes) {
@@ -121,7 +121,7 @@ export const useColorPaletteService = () => {
colorPaletteId: string
) {
if (!palette) return
const rootStyle = document.body?.style
const rootStyle = document.documentElement?.style
if (!rootStyle) return
for (const themeVar of Object.keys(THEME_PROPERTY_MAP)) {
@@ -206,7 +206,10 @@ export const useColorPaletteService = () => {
*
* @param comfyColorPalette - The palette to set.
*/
const loadComfyColorPalette = (comfyColorPalette: Colors['comfy_base']) => {
const loadComfyColorPalette = (
comfyColorPalette: Colors['comfy_base'],
isLightTheme: boolean
) => {
if (!comfyColorPalette) return
const rootStyle = document.documentElement.style
for (const [key, value] of Object.entries(comfyColorPalette)) {
@@ -228,6 +231,14 @@ export const useColorPaletteService = () => {
} else {
rootStyle.removeProperty('--bg-img')
}
try {
const splashBg = isLightTheme ? '#FFFFFF' : comfyColorPalette['bg-color']
localStorage.setItem('comfy-splash-bg', splashBg)
localStorage.setItem('comfy-splash-fg', comfyColorPalette['fg-color'])
} catch (_) {
/* empty */
}
}
/**
@@ -249,7 +260,10 @@ export const useColorPaletteService = () => {
colorPaletteId
)
loadLinkColorPaletteForVueNodes(completedPalette.colors.node_slot)
loadComfyColorPalette(completedPalette.colors.comfy_base)
loadComfyColorPalette(
completedPalette.colors.comfy_base,
completedPalette.light_theme === true
)
app.canvas.setDirty(true, true)
colorPaletteStore.activePaletteId = colorPaletteId

View File

@@ -71,46 +71,15 @@ vi.mock('@/stores/modelToNodeStore', () => ({
})
}))
// Mock TaskItemImpl
vi.mock('@/stores/queueStore', () => ({
TaskItemImpl: class {
public flatOutputs: Array<{
supportsPreview: boolean
filename: string
subfolder: string
type: string
url: string
}>
public previewOutput:
| {
supportsPreview: boolean
filename: string
subfolder: string
type: string
url: string
}
| undefined
public jobId: string
constructor(public job: JobListItem) {
this.jobId = job.id
this.flatOutputs = [
{
supportsPreview: true,
filename: 'test.png',
subfolder: '',
type: 'output',
url: 'http://test.com/test.png'
}
]
this.previewOutput = this.flatOutputs[0]
}
get previewableOutputs() {
return this.flatOutputs.filter((o) => o.supportsPreview)
}
// Use the real TaskItemImpl and ResultItemImpl from queueStore
// to avoid redefining types and filtering logic inline.
vi.mock('@/stores/queueStore', async (importOriginal) => {
const original = (await importOriginal()) as Record<string, unknown>
return {
TaskItemImpl: original.TaskItemImpl,
ResultItemImpl: original.ResultItemImpl
}
}))
})
// Mock asset mappers - add unique timestamps
vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
@@ -140,20 +109,29 @@ describe('assetsStore - Refactored (Option A)', () => {
let store: ReturnType<typeof useAssetsStore>
// Helper function to create mock job items
const createMockJobItem = (index: number): JobListItem => ({
const createMockJobItem = (
index: number,
overrides?: {
status?: JobListItem['status']
preview_output?: JobListItem['preview_output'] | null
}
): JobListItem => ({
id: `prompt_${index}`,
status: 'completed',
status: overrides?.status ?? 'completed',
create_time: 1000 + index,
update_time: 1000 + index,
last_state_update: 1000 + index,
priority: 1000 + index,
preview_output: {
filename: `output_${index}.png`,
subfolder: '',
type: 'output',
nodeId: 'node_1',
mediaType: 'images'
}
preview_output:
overrides?.preview_output === null
? undefined
: (overrides?.preview_output ?? {
filename: `output_${index}.png`,
subfolder: '',
type: 'output',
nodeId: 'node_1',
mediaType: 'images'
})
})
beforeEach(() => {
@@ -494,6 +472,140 @@ describe('assetsStore - Refactored (Option A)', () => {
expect(Array.isArray(asset.user_metadata!.allOutputs)).toBe(true)
})
})
describe('Media Type Filtering', () => {
it('should include image outputs as previewable', async () => {
const mockHistory = [
createMockJobItem(0, {
preview_output: {
filename: 'photo.png',
subfolder: '',
type: 'output',
nodeId: 'node_1',
mediaType: 'images'
}
})
]
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(1)
})
it('should include video outputs as previewable', async () => {
const mockHistory = [
createMockJobItem(0, {
preview_output: {
filename: 'clip.mp4',
subfolder: '',
type: 'output',
nodeId: 'node_1',
mediaType: 'video'
}
})
]
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(1)
})
it('should include audio outputs as previewable', async () => {
const mockHistory = [
createMockJobItem(0, {
preview_output: {
filename: 'sound.mp3',
subfolder: '',
type: 'output',
nodeId: 'node_1',
mediaType: 'audio'
}
})
]
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(1)
})
it('should include 3D outputs as previewable', async () => {
const mockHistory = [
createMockJobItem(0, {
preview_output: {
filename: 'model.glb',
subfolder: '',
type: 'output',
nodeId: 'node_1',
mediaType: '3d'
}
})
]
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(1)
})
it('should skip non-previewable outputs (e.g., text files)', async () => {
const mockHistory = [
createMockJobItem(0, {
preview_output: {
filename: 'data.txt',
subfolder: '',
type: 'output',
nodeId: 'node_1',
mediaType: 'text'
}
})
]
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(0)
})
})
describe('Edge Cases', () => {
it('should skip jobs without preview_output', async () => {
const mockHistory = [
createMockJobItem(0, { preview_output: null }),
createMockJobItem(1)
]
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(1)
})
it('should skip non-completed jobs', async () => {
const mockHistory = [
createMockJobItem(0, { status: 'failed' }),
createMockJobItem(1, { status: 'cancelled' }),
createMockJobItem(2, { status: 'pending' }),
createMockJobItem(3)
]
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(1)
})
it('should handle empty history response', async () => {
vi.mocked(api.getHistory).mockResolvedValue([])
await store.updateHistory()
expect(store.historyAssets).toHaveLength(0)
expect(store.hasMoreHistory).toBe(false)
})
})
})
describe('assetsStore - Model Assets Cache (Cloud)', () => {

View File

@@ -132,7 +132,6 @@ watch(
} else {
document.body.classList.add(DARK_THEME_CLASS)
}
if (isDesktop) {
electronAPI().changeTheme({
color: 'rgba(0, 0, 0, 0)',

View File

@@ -87,6 +87,8 @@ const login = async () => {
}
onMounted(async () => {
document.getElementById('splash-loader')?.remove()
if (!userStore.initialized) {
await userStore.initialize()
}

View File

@@ -245,8 +245,10 @@ onBeforeUnmount(() => {
</template>
<template #footer="{ toggle }">
<div class="flex w-full items-center justify-between px-6 py-2 shadow-lg">
<div class="flex items-center text-base leading-none">
<div
class="flex w-full items-center justify-between gap-4 px-6 py-2 shadow-lg"
>
<div class="flex min-w-0 items-center text-base leading-none">
<div class="flex items-center">
<template v-if="isInProgress">
<DotSpinner duration="1s" class="mr-2" />
@@ -262,7 +264,7 @@ onBeforeUnmount(() => {
</template>
</div>
</div>
<div class="flex items-center gap-4">
<div class="flex shrink-0 items-center gap-4">
<span v-if="isInProgress" class="text-sm text-muted-foreground">
{{ completedTasksCount }} {{ t('g.progressCountOf') }}
{{ totalTasksCount }}

View File

@@ -15,50 +15,22 @@
<template #header>
<div class="flex w-full items-center justify-between gap-2">
<div class="flex items-center gap-2">
<div class="flex w-full items-center gap-2">
<SingleSelect
v-model="searchMode"
class="min-w-34"
:options="filterOptions"
/>
<AutoCompletePlus
v-model.lazy="searchQuery"
<SearchAutocomplete
v-model="searchQuery"
:suggestions="suggestions"
:placeholder="$t('manager.searchPlaceholder')"
:complete-on-focus="false"
:delay="8"
option-label="query"
class="w-full max-w-lg min-w-md"
:pt="{
root: { class: 'relative' },
pcInputText: {
root: {
autofocus: true,
class:
'w-full h-10 rounded-lg bg-comfy-input text-comfy-input-foreground border-none outline-none text-sm'
}
},
overlay: {
class:
'bg-comfy-input rounded-lg mt-1 shadow-lg border border-border-default'
},
list: { class: 'p-1' },
option: {
class:
'px-3 py-2 rounded hover:bg-button-hover-surface cursor-pointer text-sm'
},
loader: { style: 'display: none' }
}"
:show-empty-message="false"
@complete="stubTrue"
@option-select="onOptionSelect"
>
<template #dropdownicon>
<i
class="pi pi-search absolute top-1/2 left-3 -translate-y-1/2 text-muted-foreground"
/>
</template>
</AutoCompletePlus>
autofocus
size="lg"
class="max-w-96 flex-1"
@select="onOptionSelect"
/>
</div>
<PackInstallButton
v-if="isMissingTab && missingNodePacks.length > 0"
@@ -128,6 +100,10 @@
<div v-if="isLoading" class="size-full scrollbar-hide overflow-auto">
<GridSkeleton :grid-style="GRID_STYLE" :skeleton-card-count />
</div>
<UnresolvedNodesMessage
v-else-if="isUnresolvedTab"
:node-names="unresolvedNodeNames"
/>
<NoResultsPlaceholder
v-else-if="displayPacks.length === 0"
:title="emptyStateTitle"
@@ -166,8 +142,7 @@
<script setup lang="ts">
import { until, whenever } from '@vueuse/core'
import { merge, stubTrue } from 'es-toolkit/compat'
import type { AutoCompleteOptionSelectEvent } from 'primevue/autocomplete'
import { merge } from 'es-toolkit/compat'
import {
computed,
onBeforeUnmount,
@@ -183,7 +158,7 @@ import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import AutoCompletePlus from '@/components/primevueOverride/AutoCompletePlus.vue'
import SearchAutocomplete from '@/components/ui/search-input/SearchAutocomplete.vue'
import Button from '@/components/ui/button/Button.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
@@ -192,6 +167,7 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { components } from '@/types/comfyRegistryTypes'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import type { QuerySuggestion } from '@/types/searchServiceTypes'
import { OnCloseKey } from '@/types/widgetTypes'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import PackUpdateButton from '@/workbench/extensions/manager/components/manager/button/PackUpdateButton.vue'
@@ -199,6 +175,7 @@ import InfoPanel from '@/workbench/extensions/manager/components/manager/infoPan
import InfoPanelMultiItem from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanelMultiItem.vue'
import PackCard from '@/workbench/extensions/manager/components/manager/packCard/PackCard.vue'
import GridSkeleton from '@/workbench/extensions/manager/components/manager/skeleton/GridSkeleton.vue'
import UnresolvedNodesMessage from '@/workbench/extensions/manager/components/manager/UnresolvedNodesMessage.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useUpdateAvailableNodes } from '@/workbench/extensions/manager/composables/nodePack/useUpdateAvailableNodes'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
@@ -246,6 +223,7 @@ const {
// Missing nodes composable
const {
missingNodePacks,
unresolvedNodeNames,
isLoading: isMissingLoading,
error: missingError
} = useMissingNodes()
@@ -309,7 +287,17 @@ const navItems = computed<(NavItemData | NavGroupData)[]>(() => [
id: ManagerTab.Missing,
label: t('manager.nav.missingNodes'),
icon: 'icon-[lucide--triangle-alert]'
}
},
...(unresolvedNodeNames.value.length > 0
? [
{
id: ManagerTab.Unresolved,
label: t('manager.nav.unresolvedNodes'),
icon: 'icon-[lucide--help-circle]',
badge: unresolvedNodeNames.value.length
}
]
: [])
]
}
])
@@ -337,6 +325,12 @@ const selectedTab = computed(() =>
findNavItemById(navItems.value, selectedNavId.value)
)
watch(navItems, (items) => {
if (selectedNavId.value && !findNavItemById(items, selectedNavId.value)) {
selectedNavId.value = ManagerTab.Missing
}
})
const {
searchQuery,
pageNumber,
@@ -377,8 +371,8 @@ const availableSortOptions = computed(() => {
}))
})
const onOptionSelect = (event: AutoCompleteOptionSelectEvent) => {
searchQuery.value = event.value.query
const onOptionSelect = (suggestion: QuerySuggestion) => {
searchQuery.value = suggestion.query
}
const onApproachEnd = () => {
@@ -403,6 +397,9 @@ const isUpdateAvailableTab = computed(
const isMissingTab = computed(
() => selectedTab.value?.id === ManagerTab.Missing
)
const isUnresolvedTab = computed(
() => selectedTab.value?.id === ManagerTab.Unresolved
)
// Map of tab IDs to their empty state i18n key suffixes
const tabEmptyStateKeys: Partial<Record<ManagerTab, string>> = {

View File

@@ -0,0 +1,26 @@
<template>
<div class="flex flex-col items-center gap-3 p-8">
<i class="icon-[lucide--triangle-alert] text-4xl text-warning-background" />
<h3 class="text-base font-semibold">
{{ $t('manager.unresolvedNodes.title') }}
</h3>
<p class="text-center text-sm text-muted-foreground">
{{ $t('manager.unresolvedNodes.message') }}
</p>
<ul class="mt-2 flex flex-col gap-1 rounded-lg bg-secondary-background p-2">
<li
v-for="name in nodeNames"
:key="name"
class="px-3 py-1.5 font-mono text-sm"
>
{{ name }}
</li>
</ul>
</div>
</template>
<script setup lang="ts">
defineProps<{
nodeNames: string[]
}>()
</script>

View File

@@ -94,6 +94,7 @@ describe('useMissingNodes', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
unresolvedNodeNames: ref([]),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
@@ -119,6 +120,7 @@ describe('useMissingNodes', () => {
it('filters out installed packs correctly', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
unresolvedNodeNames: ref([]),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
@@ -140,6 +142,7 @@ describe('useMissingNodes', () => {
it('returns empty array when all packs are installed', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
unresolvedNodeNames: ref([]),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
@@ -158,6 +161,7 @@ describe('useMissingNodes', () => {
it('returns all packs when none are installed', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
unresolvedNodeNames: ref([]),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
@@ -191,6 +195,7 @@ describe('useMissingNodes', () => {
it('fetches even when packs already exist (watch always fires with immediate:true)', async () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
unresolvedNodeNames: ref([]),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
@@ -206,6 +211,7 @@ describe('useMissingNodes', () => {
it('fetches even when already loading (watch fires regardless of loading state)', async () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
unresolvedNodeNames: ref([]),
isLoading: ref(true),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
@@ -223,6 +229,7 @@ describe('useMissingNodes', () => {
it('exposes loading state from useWorkflowPacks', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
unresolvedNodeNames: ref([]),
isLoading: ref(true),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
@@ -239,6 +246,7 @@ describe('useMissingNodes', () => {
const testError = 'Failed to fetch workflow packs'
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
unresolvedNodeNames: ref([]),
isLoading: ref(false),
error: ref(testError),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
@@ -257,6 +265,7 @@ describe('useMissingNodes', () => {
const workflowPacksRef = ref<WorkflowPack[]>([])
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: workflowPacksRef,
unresolvedNodeNames: ref([]),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
@@ -283,6 +292,7 @@ describe('useMissingNodes', () => {
const workflowPacksRef = ref(mockWorkflowPacks)
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: workflowPacksRef,
unresolvedNodeNames: ref([]),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
@@ -306,6 +316,68 @@ describe('useMissingNodes', () => {
})
})
describe('unresolved nodes', () => {
it('reports hasMissingNodes when unresolvedNodeNames is non-empty', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
unresolvedNodeNames: ref(['UnknownNode1', 'UnknownNode2']),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
const { hasMissingNodes, unresolvedNodeNames } = useMissingNodes()
expect(hasMissingNodes.value).toBe(true)
expect(unresolvedNodeNames.value).toEqual([
'UnknownNode1',
'UnknownNode2'
])
})
it('does not report hasMissingNodes when unresolvedNodeNames is empty', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
unresolvedNodeNames: ref([]),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
const { hasMissingNodes } = useMissingNodes()
expect(hasMissingNodes.value).toBe(false)
})
it('updates reactively when unresolvedNodeNames changes', async () => {
const unresolvedRef = ref<string[]>([])
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
unresolvedNodeNames: unresolvedRef,
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
const { hasMissingNodes, unresolvedNodeNames } = useMissingNodes()
expect(hasMissingNodes.value).toBe(false)
expect(unresolvedNodeNames.value).toEqual([])
unresolvedRef.value = ['NewMissingNode']
await nextTick()
expect(hasMissingNodes.value).toBe(true)
expect(unresolvedNodeNames.value).toEqual(['NewMissingNode'])
})
})
describe('missing core nodes detection', () => {
const createMockNode = (type: string, packId?: string, version?: string) =>
createMockLGraphNode({

View File

@@ -21,8 +21,13 @@ export const useMissingNodes = createSharedComposable(() => {
const nodeDefStore = useNodeDefStore()
const comfyManagerStore = useComfyManagerStore()
const workflowStore = useWorkflowStore()
const { workflowPacks, isLoading, error, startFetchWorkflowPacks } =
useWorkflowPacks()
const {
workflowPacks,
unresolvedNodeNames,
isLoading,
error,
startFetchWorkflowPacks
} = useWorkflowPacks()
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
@@ -67,7 +72,8 @@ export const useMissingNodes = createSharedComposable(() => {
const hasMissingNodes = computed(() => {
return (
missingNodePacks.value.length > 0 ||
Object.keys(missingCoreNodes.value).length > 0
Object.keys(missingCoreNodes.value).length > 0 ||
unresolvedNodeNames.value.length > 0
)
})
@@ -83,6 +89,7 @@ export const useMissingNodes = createSharedComposable(() => {
return {
missingNodePacks,
missingCoreNodes,
unresolvedNodeNames,
hasMissingNodes,
isLoading,
error

View File

@@ -31,6 +31,7 @@ const _useWorkflowPacks = () => {
const { inferPackFromNodeName } = useComfyRegistryStore()
const workflowPacks = ref<WorkflowPack[]>([])
const unresolvedNodeNames = ref<string[]>([])
const getWorkflowNodePackId = (node: LGraphNode): string | undefined => {
if (typeof node.properties?.cnr_id === 'string') {
@@ -111,12 +112,39 @@ const _useWorkflowPacks = () => {
/**
* Get the node packs for all nodes in the workflow (including subgraphs).
* Nodes that have no local definition and no registry match are tracked
* as unresolved so downstream consumers can surface them to the user.
*/
const getWorkflowPacks = async () => {
if (!app.rootGraph) return []
const packPromises = mapAllNodes(app.rootGraph, workflowNodeToPack)
const packs = await Promise.all(packPromises)
workflowPacks.value = packs.filter((pack) => pack !== undefined)
if (!app.rootGraph) {
workflowPacks.value = []
unresolvedNodeNames.value = []
return
}
const resolvedPacks: WorkflowPack[] = []
const unresolved: string[] = []
await Promise.all(
mapAllNodes(app.rootGraph, async (node) => {
const pack = await workflowNodeToPack(node)
if (pack) {
resolvedPacks.push(pack)
} else {
const nodeName = node.type
if (
nodeName &&
getWorkflowNodePackId(node) === undefined &&
!nodeDefStore.nodeDefsByName[nodeName]
) {
unresolved.push(nodeName)
}
}
})
)
workflowPacks.value = resolvedPacks
unresolvedNodeNames.value = [...new Set(unresolved)]
}
const packsToUniqueIds = (packs: WorkflowPack[]) =>
@@ -147,6 +175,7 @@ const _useWorkflowPacks = () => {
isLoading,
isReady,
workflowPacks: nodePacks,
unresolvedNodeNames,
startFetchWorkflowPacks: async () => {
await getWorkflowPacks() // Parse the packs from the workflow nodes
await startFetch() // Fetch the packs infos from the registry

View File

@@ -176,6 +176,9 @@ export function useManagerDisplayPacks(
return sortPacks(filterNotInstalled(base))
}
case ManagerTab.Unresolved:
return []
default:
return searchResults.value
}

View File

@@ -19,7 +19,8 @@ export enum ManagerTab {
UpdateAvailable = 'updateAvailable',
Conflicting = 'conflicting',
Workflow = 'workflow',
Missing = 'missing'
Missing = 'missing',
Unresolved = 'unresolved'
}
export type TaskLog = {