Compare commits

...

12 Commits

Author SHA1 Message Date
GitHub Action
f0b211f931 [automated] Apply ESLint and Oxfmt fixes 2026-03-05 08:27:38 +00:00
Alexander Brown
89be4d5c1e Merge branch 'main' into refactor/share-by-url-prerequisites 2026-03-05 00:25:19 -08:00
Alexander Brown
153f32c990 Merge branch 'main' into refactor/share-by-url-prerequisites 2026-03-04 23:31:51 -08:00
Alexander Brown
d7119efe54 Merge branch 'main' into refactor/share-by-url-prerequisites 2026-03-04 23:31:30 -08:00
Alexander Brown
2529cca7ad test: add tests for workflow id assignment
Verify new workflows get a unique id and that input data is not mutated.

Amp-Thread-ID: https://ampcode.com/threads/T-019cbc0c-d7dd-7011-b1ac-3bdaedab5b03
Co-authored-by: Amp <amp@ampcode.com>
2026-03-04 19:52:26 -08:00
Alexander Brown
1c95015910 fix: UserButton sizing in WorkflowTabs
Add grid and w-10 classes for consistent button dimensions.

Amp-Thread-ID: https://ampcode.com/threads/T-019cbc0c-d7dd-7011-b1ac-3bdaedab5b03
Co-authored-by: Amp <amp@ampcode.com>
2026-03-04 19:50:47 -08:00
Alexander Brown
339ea644fe refactor: enhance UI components for reusability
BaseModalLayout: add leftPanelWidth and contentPadding props

StatusBadge: add class prop for custom styling

TagsInput: add alwaysEditing prop to skip click-to-edit
Amp-Thread-ID: https://ampcode.com/threads/T-019cbc0c-d7dd-7011-b1ac-3bdaedab5b03
Co-authored-by: Amp <amp@ampcode.com>
2026-03-04 19:49:15 -08:00
Alexander Brown
140be34a21 refactor: restyle Textarea to match design system
Use design system tokens for consistent styling with Input component.

Amp-Thread-ID: https://ampcode.com/threads/T-019cbc0c-d7dd-7011-b1ac-3bdaedab5b03
Co-authored-by: Amp <amp@ampcode.com>
2026-03-04 19:47:00 -08:00
Alexander Brown
227a038d83 feat: add Input UI component and Storybook story
New reusable text input component styled with design system tokens.

Amp-Thread-ID: https://ampcode.com/threads/T-019cbc0c-d7dd-7011-b1ac-3bdaedab5b03
Co-authored-by: Amp <amp@ampcode.com>
2026-03-04 19:46:01 -08:00
Alexander Brown
e0ca790d5d fix: use expect.poll() for duplicate workflow test
Replace nextFrame + expect with expect.poll for more reliable async assertion.

Amp-Thread-ID: https://ampcode.com/threads/T-019cbc0c-d7dd-7011-b1ac-3bdaedab5b03
Co-authored-by: Amp <amp@ampcode.com>
2026-03-04 19:44:33 -08:00
Alexander Brown
ba0d7e3a76 fix: ensure all new workflows have a unique id
Add ensureWorkflowId helper to guarantee every workflow has an id.

Deep-clones input data to avoid mutating caller references.

Amp-Thread-ID: https://ampcode.com/threads/T-019cbc0c-d7dd-7011-b1ac-3bdaedab5b03
Co-authored-by: Amp <amp@ampcode.com>
2026-03-04 19:43:01 -08:00
Alexander Brown
7005c0fb28 fix: normalize ISO 8601 timestamps in userFileStore
Handle Go backend returning ISO 8601 strings instead of millisecond timestamps by normalizing through Date parsing.

Amp-Thread-ID: https://ampcode.com/threads/T-019cbc0c-d7dd-7011-b1ac-3bdaedab5b03
Co-authored-by: Amp <amp@ampcode.com>
2026-03-04 19:41:05 -08:00
12 changed files with 181 additions and 36 deletions

View File

@@ -330,12 +330,9 @@ test.describe('Workflows sidebar', () => {
.getPersistedItem('workflow1.json')
.click({ button: 'right' })
await comfyPage.contextMenu.clickMenuItem('Duplicate')
await comfyPage.nextFrame()
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json',
'*workflow1 (Copy).json'
])
await expect
.poll(() => workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow.json', '*workflow1 (Copy).json'])
})
test('Can drop workflow from workflows sidebar', async ({ comfyPage }) => {

View File

@@ -1,24 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { statusBadgeVariants } from './statusBadge.variants'
import type { StatusBadgeVariants } from './statusBadge.variants'
const {
label,
severity = 'default',
variant
variant,
class: className
} = defineProps<{
label?: string | number
severity?: StatusBadgeVariants['severity']
variant?: StatusBadgeVariants['variant']
class?: string
}>()
const badgeClass = computed(() =>
statusBadgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
})
cn(
statusBadgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
}),
className
)
)
</script>

View File

@@ -21,13 +21,13 @@
>
<template #pipCmd>
<code
class="px-1 py-0.5 rounded text-xs font-mono bg-comfy-menu-bg text-comfy-input-foreground"
class="px-1 py-0.5 rounded-sm text-xs font-mono bg-comfy-menu-bg text-comfy-input-foreground"
>pip install -U --pre comfyui-manager</code
>
</template>
<template #flag>
<code
class="px-1 py-0.5 rounded text-xs font-mono bg-comfy-menu-bg text-comfy-input-foreground"
class="px-1 py-0.5 rounded-sm text-xs font-mono bg-comfy-menu-bg text-comfy-input-foreground"
>--enable-manager</code
>
</template>

View File

@@ -88,7 +88,7 @@
v-if="isLoggedIn"
:show-arrow="false"
compact
class="shrink-0 p-1"
class="shrink-0 p-1 grid w-10"
/>
<LoginButton v-else-if="isDesktop" class="p-1" />
</div>

View File

@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Input from './Input.vue'
const meta: Meta<typeof Input> = {
title: 'Components/Input',
component: Input,
tags: ['autodocs'],
render: (args) => ({
components: { Input },
setup: () => ({ args }),
template: '<Input v-bind="args" placeholder="Enter text..." />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const WithValue: Story = {
args: {
modelValue: 'Hello, world!'
}
}
export const Disabled: Story = {
render: (args) => ({
components: { Input },
setup: () => ({ args }),
template: '<Input v-bind="args" placeholder="Disabled input" disabled />'
})
}

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useTemplateRef } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
const modelValue = defineModel<string | number>()
const inputRef = useTemplateRef<HTMLInputElement>('inputEl')
defineExpose({
focus: () => inputRef.value?.focus(),
select: () => inputRef.value?.select()
})
</script>
<template>
<input
ref="inputEl"
v-model="modelValue"
:class="
cn(
'flex h-10 w-full min-w-0 appearance-none rounded-lg border-none bg-secondary-background px-4 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-default disabled:pointer-events-none disabled:opacity-50',
className
)
"
/>
</template>

View File

@@ -16,9 +16,15 @@ import type { FocusCallback } from './tagsInputContext'
const {
disabled = false,
alwaysEditing = false,
class: className,
...restProps
} = defineProps<TagsInputRootProps<T> & { class?: HTMLAttributes['class'] }>()
} = defineProps<
TagsInputRootProps<T> & {
class?: HTMLAttributes['class']
alwaysEditing?: boolean
}
>()
const emits = defineEmits<TagsInputRootEmits<T>>()
const isEditing = ref(false)
@@ -28,9 +34,10 @@ const focusInput = ref<FocusCallback>()
provide(tagsInputFocusKey, (callback: FocusCallback) => {
focusInput.value = callback
})
provide(tagsInputIsEditingKey, isEditing)
const isEditingEnabled = computed(() => alwaysEditing || isEditing.value)
provide(tagsInputIsEditingKey, isEditingEnabled)
const internalDisabled = computed(() => disabled || !isEditing.value)
const internalDisabled = computed(() => disabled || !isEditingEnabled.value)
const delegatedProps = computed(() => ({
...restProps,
@@ -40,7 +47,7 @@ const delegatedProps = computed(() => ({
const forwarded = useForwardPropsEmits(delegatedProps, emits)
async function enableEditing() {
if (!disabled && !isEditing.value) {
if (!disabled && !alwaysEditing && !isEditing.value) {
isEditing.value = true
await nextTick()
focusInput.value?.()
@@ -48,7 +55,9 @@ async function enableEditing() {
}
onClickOutside(rootEl, () => {
isEditing.value = false
if (!alwaysEditing) {
isEditing.value = false
}
})
</script>
@@ -61,7 +70,7 @@ onClickOutside(rootEl, () => {
'group relative flex flex-wrap items-center gap-2 rounded-lg bg-transparent p-2 text-xs text-base-foreground',
!internalDisabled &&
'hover:bg-modal-card-background-hovered focus-within:bg-modal-card-background-hovered',
!disabled && !isEditing && 'cursor-pointer',
!disabled && !isEditingEnabled && 'cursor-pointer',
className
)
"
@@ -69,7 +78,7 @@ onClickOutside(rootEl, () => {
>
<slot :is-empty="modelValue.length === 0" />
<i
v-if="!disabled && !isEditing"
v-if="!disabled && !isEditingEnabled"
aria-hidden="true"
class="icon-[lucide--square-pen] absolute bottom-2 right-2 size-4 text-muted-foreground transition-opacity opacity-0 group-hover:opacity-100"
/>

View File

@@ -16,7 +16,7 @@ const modelValue = defineModel<string | number>()
v-model="modelValue"
:class="
cn(
'flex min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
'flex min-h-16 w-full rounded-lg border-none bg-secondary-background px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-default disabled:pointer-events-none disabled:opacity-50',
className
)
"

View File

@@ -77,9 +77,7 @@
>
{{ contentTitle }}
</h2>
<div
class="min-h-0 flex-1 px-6 pt-0 pb-10 overflow-y-auto scrollbar-custom"
>
<div :class="contentContainerClass">
<slot name="content" />
</div>
</main>
@@ -153,15 +151,20 @@ const SIZE_CLASSES = {
} as const
type ModalSize = keyof typeof SIZE_CLASSES
type ContentPadding = 'default' | 'compact' | 'none'
const {
contentTitle,
rightPanelTitle,
size = 'lg'
size = 'lg',
leftPanelWidth = '14rem',
contentPadding = 'default'
} = defineProps<{
contentTitle: string
rightPanelTitle?: string
size?: ModalSize
leftPanelWidth?: string
contentPadding?: ContentPadding
}>()
const sizeClasses = computed(() => SIZE_CLASSES[size])
@@ -197,10 +200,18 @@ const showLeftPanel = computed(() => {
return shouldShow
})
const contentContainerClass = computed(() =>
cn(
'flex min-h-0 flex-1 flex-col overflow-y-auto scrollbar-custom',
contentPadding === 'default' && 'px-6 pt-0 pb-10',
contentPadding === 'compact' && 'px-6 pt-0 pb-2'
)
)
const gridStyle = computed(() => ({
gridTemplateColumns: hasRightPanel.value
? `${hasLeftPanel.value && showLeftPanel.value ? '14rem' : '0rem'} 1fr ${isRightPanelOpen.value ? '18rem' : '0rem'}`
: `${hasLeftPanel.value && showLeftPanel.value ? '14rem' : '0rem'} 1fr`
? `${hasLeftPanel.value && showLeftPanel.value ? leftPanelWidth : '0rem'} 1fr ${isRightPanelOpen.value ? '18rem' : '0rem'}`
: `${hasLeftPanel.value && showLeftPanel.value ? leftPanelWidth : '0rem'} 1fr`
}))
const toggleLeftPanel = () => {

View File

@@ -183,6 +183,31 @@ describe('useWorkflowStore', () => {
const workflow = store.createTemporary('a.json')
expect(workflow.path).toBe('workflows/a (2).json')
})
it('should assign a workflow id to newly created temporary workflows', () => {
const workflow = store.createTemporary('id-test.json')
const state = JSON.parse(workflow.content!)
expect(typeof state.id).toBe('string')
expect(state.id.length).toBeGreaterThan(0)
})
it('should assign an id when temporary workflow data is missing one', () => {
const workflowDataWithoutId = {
...defaultGraph,
id: undefined
}
const workflow = store.createTemporary(
'missing-id.json',
workflowDataWithoutId
)
const state = JSON.parse(workflow.content!)
expect(typeof state.id).toBe('string')
expect(state.id.length).toBeGreaterThan(0)
expect(workflowDataWithoutId.id).toBeUndefined()
})
})
describe('openWorkflow', () => {

View File

@@ -255,6 +255,20 @@ export const useWorkflowStore = defineStore('workflow', () => {
return workflow
}
const ensureWorkflowId = (
workflowData?: ComfyWorkflowJSON
): ComfyWorkflowJSON => {
const base = workflowData
? (JSON.parse(JSON.stringify(workflowData)) as ComfyWorkflowJSON)
: (JSON.parse(defaultGraphJSON) as ComfyWorkflowJSON)
if (!base.id) {
base.id = generateUUID()
}
return base
}
/**
* Helper to create a new temporary workflow
*/
@@ -268,9 +282,9 @@ export const useWorkflowStore = defineStore('workflow', () => {
size: -1
})
workflow.originalContent = workflow.content = workflowData
? JSON.stringify(workflowData)
: defaultGraphJSON
const initialWorkflowData = ensureWorkflowId(workflowData)
workflow.originalContent = workflow.content =
JSON.stringify(initialWorkflowData)
workflowLookup.value[workflow.path] = workflow
return workflow
@@ -284,9 +298,13 @@ export const useWorkflowStore = defineStore('workflow', () => {
ComfyWorkflow.basePath + (path ?? 'Unsaved Workflow.json')
)
const normalizedWorkflowData = workflowData
? ensureWorkflowId(workflowData)
: undefined
// Try to reuse an existing loaded workflow with the same filename
// that is not stored in the workflows directory
if (path && workflowData) {
if (path && normalizedWorkflowData) {
const existingWorkflow = workflows.value.find(
(w) => w.fullFilename === path
)
@@ -296,12 +314,12 @@ export const useWorkflowStore = defineStore('workflow', () => {
ComfyWorkflow.basePath.slice(0, -1)
)
) {
existingWorkflow.changeTracker.reset(workflowData)
existingWorkflow.changeTracker.reset(normalizedWorkflowData)
return existingWorkflow
}
}
return createNewWorkflow(fullPath, workflowData)
return createNewWorkflow(fullPath, normalizedWorkflowData)
}
/**

View File

@@ -8,6 +8,19 @@ import { getPathDetails } from '@/utils/formatUtil'
import { syncEntities } from '@/utils/syncUtil'
import { buildTree } from '@/utils/treeUtil'
/**
* Normalizes a timestamp value that may be either a number (milliseconds)
* or an ISO 8601 string (from Go's time.Time JSON serialization) into
* a consistent millisecond timestamp.
*/
function normalizeTimestamp(value: number | string): number {
if (typeof value === 'string') {
const ms = new Date(value).getTime()
return Number.isNaN(ms) ? Date.now() : ms
}
return value
}
/**
* Represents a file in the user's data directory.
*/
@@ -140,7 +153,7 @@ export class UserFile {
// https://github.com/comfyanonymous/ComfyUI/pull/5446
const updatedFile = (await resp.json()) as string | UserDataFullInfo
if (typeof updatedFile === 'object') {
this.lastModified = updatedFile.modified
this.lastModified = normalizeTimestamp(updatedFile.modified)
this.size = updatedFile.size
}
this.originalContent = this.content
@@ -175,7 +188,7 @@ export class UserFile {
// https://github.com/comfyanonymous/ComfyUI/pull/5446
const updatedFile = (await resp.json()) as string | UserDataFullInfo
if (typeof updatedFile === 'object') {
this.lastModified = updatedFile.modified
this.lastModified = normalizeTimestamp(updatedFile.modified)
this.size = updatedFile.size
}
return this