feat: workflow sharing and ComfyHub publish flow (#8951)

## Summary

Add workflow sharing by URL and a multi-step ComfyHub publish wizard,
gated by feature flags and an optional profile gate.

## Changes

- **What**: Share dialog with URL generation and asset warnings;
ComfyHub publish wizard (Describe → Examples → Finish) with thumbnail
upload and tags; profile gate flow; shared workflow URL loader with
confirmation dialog
- **Dependencies**: None (new `sharing/` module under
`src/platform/workflow/`)

## Review Focus

- Three new feature flags: `workflow_sharing_enabled`,
`comfyhub_upload_enabled`, `comfyhub_profile_gate_enabled`
- Share service API contract and stale-share detection
(`workflowShareService.ts`)
- Publish wizard and profile gate state management
- Shared workflow URL loading and query-param preservation

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8951-feat-share-workflow-by-URL-30b6d73d3650813ebbfafdad775bfb33)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Alexander Brown
2026-03-05 16:33:06 -08:00
committed by GitHub
parent 6c2680f0ba
commit 1bac5d9bdd
71 changed files with 6448 additions and 598 deletions

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
)
"