Files
ComfyUI_frontend/src/stores/dialogStore.ts
Dante 8108967d49 feat(dialog): migrate Prompt + Confirmation dialogs to Reka-UI (Phase 1) (#12041)
## Summary

Phase 1 of the dialog migration kicked off in #11719. Migrates the two
simplest production dialogs — `PromptDialogContent` and
`ConfirmationDialogContent` — from PrimeVue `Dialog` onto the Reka-UI
primitives landed in Phase 0. Public API of `useDialogService` /
`dialogStore` is unchanged.

Parent:
[FE-571](https://linear.app/comfyorg/issue/FE-571/dialog-system-migration-primevue-reka-ui-parent)
This phase:
[FE-573](https://linear.app/comfyorg/issue/FE-573/phase-1-migrate-promptdialog-confirmationdialog-closes-11688)
Predecessor: #11719 (merged at `0788e7139`)

Refs #11688 (closed manually after Phase 0; the actual user-visible
max-width fix ships in this PR)

## Changes

### `src/services/dialogService.ts`
| Call site | Renderer | Size | Width override |
| --- | --- | --- | --- |
| `prompt()` | `'reka'` | `md` | — |
| `confirm()` | `'reka'` | `md` | — |
| `showBillingComingSoonDialog()` | `'reka'` | `sm` | `contentClass:
'max-w-[360px]'` |

### `src/components/dialog/content/ConfirmationDialogContent.vue`
- Drops `import Message from 'primevue/message'` — the only PrimeVue
dependency in the component
- Replaces `<Message>` with a Tailwind `role="status"` alert keeping the
`pi pi-info-circle` icon and muted-foreground severity

### `src/stores/dialogStore.ts` +
`src/components/dialog/GlobalDialog.vue`
- Adds `contentClass?: HTMLAttributes['class']` on
`CustomDialogComponentProps`
- Forwards it to `<DialogContent :class="...">` on the Reka branch
(PrimeVue path keeps using `pt`)

## Why this scope

1. **Smallest content surface** — `PromptDialogContent` is 43 LOC; the
only PrimeVue dependency in `ConfirmationDialogContent` is the
`<Message>` info banner.
2. **Closes #11688 ergonomics** — Reka's `md` size = `max-w-xl` (576px /
36rem), exactly the max-width the issue reporter asked for.
3. **Three known callers** — all in `dialogService.ts`. No other callers
needed to change.
4. **Renderer branch is already proven by Phase 0**; this PR just flips
the flag.

## Visual proof

Verified live in Storybook (`Components / Dialog / Dialog → Default` and
`… → All Sizes`) at viewport `1920×1080`. DOM inspection confirms the
rendered widths match the design intent:

| Story | size | Rendered width | Computed `max-width` |
| --- | --- | --- | --- |
| `Default` | `md` | **576 px** | **576 px (= 36rem)** |
| `All Sizes` (sm slot) | `sm` | 384 px | 384 px (= 24rem) |

The `md` measurement directly answers the #11688 reporter screenshot
(1558 px wide PrimeVue dialog → 576 px Reka dialog on the same display).
Local screenshot artifacts (not committed):
`temp/screenshots/phase1-md-576px-1920w.png`,
`temp/screenshots/phase1-md-allsizes-1920w.png`,
`temp/screenshots/phase1-sm-384px-1920w.png` — drag-drop into the PR
body before marking ready for review.

## Quality gates

- [x] `pnpm typecheck` — clean
- [x] `pnpm lint` — clean
- [x] `pnpm format` — applied (oxfmt)
- [x] `pnpm test:unit` (touched files): **26/26 passed**
- `ConfirmationDialogContent.test.ts` (9 tests, no longer needs PrimeVue
plugin)
  - `PromptDialogContent.test.ts` (5 tests, unchanged)
- `GlobalDialog.test.ts` (9 tests, Phase 0 coverage still passes after
the contentClass forwarder addition)
- `dialogService.renderer.test.ts` **new** — 3 tests asserting each call
site sets `renderer: 'reka'` (regression net)
- [ ] `pnpm test:browser:local --grep "@mobile confirm dialog"` —
**could not run locally** (no ComfyUI Python backend on `localhost:8188`
in this session); CI will gate the existing fixture, which is already
renderer-agnostic (`getByRole('dialog')` + `getByRole('button', ...)` in
`browser_tests/fixtures/components/ConfirmDialog.ts`).

## Public API impact

None. `useDialogService().prompt(...)` / `confirm(...)` /
`showBillingComingSoonDialog(...)` keep their existing signatures.
Custom-node extensions calling `app.extensionManager.dialog.*` continue
to work.

## Out of scope (later phases)

- `ErrorDialogContent`, `NodeSearchBox`, `SecretFormDialog`,
`VideoHelpDialog`, `CustomizationDialog` — Phase 2 (FE-574)
- Settings dialog — Phase 3 (FE-575)
- Manager dialog — Phase 4 (FE-576)
- `ConfirmDialog` callers (`SecretsPanel`, `BaseWorkflowsSidebarTab`) —
Phase 5 (FE-577)
- Removing PrimeVue `Dialog` imports + `<style>` cleanup in
`GlobalDialog.vue` — Phase 6 (FE-578)
- Legacy `ComfyDialog` (`src/scripts/ui/dialog.ts`)
- Deduplicating `Dialogue.vue` / `ImageLightbox.vue`


## Screenshot
<img width="865" height="497" alt="Screenshot 2026-05-08 at 4 35 45 PM"
src="https://github.com/user-attachments/assets/6aead2ad-2e0b-478a-9154-bb632a6bf3d1"
/>
<img width="1363" height="964" alt="Screenshot 2026-05-08 at 4 38 16 PM"
src="https://github.com/user-attachments/assets/10647752-a063-4901-a206-842799cc5d7a"
/>
<img width="889" height="486" alt="Screenshot 2026-05-08 at 4 46 57 PM"
src="https://github.com/user-attachments/assets/81899a81-205a-46f2-bddd-7639624607f6"
/>



## Test plan

- [x] Unit: 26/26 pass on touched files
- [ ] CI: `@mobile confirm dialog` spec on the migrated path
- [ ] Manual (post-CI on a real backend): open prompt and confirm
dialogs on 1920×1080 viewport, verify ≤ 36rem max-width, ESC closes,
backdrop click closes, Enter submits prompt, focus trap holds
- [ ] Manual: open Billing Coming Soon dialog — verify it stays at the
existing `max-w-[360px]` width
2026-05-08 12:11:06 +00:00

282 lines
7.9 KiB
TypeScript

// We should consider moving to https://primevue.org/dynamicdialog/ once everything is in Vue.
// Currently we need to bridge between legacy app code and Vue app with a Pinia store.
import { merge } from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import type { DialogPassThroughOptions } from 'primevue/dialog'
import { markRaw, ref } from 'vue'
import type { Component, HTMLAttributes } from 'vue'
import type GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import type { DialogContentSize } from '@/components/ui/dialog/dialog.variants'
import type { ComponentAttrs } from 'vue-component-type-helpers'
type DialogPosition =
| 'center'
| 'top'
| 'bottom'
| 'left'
| 'right'
| 'topleft'
| 'topright'
| 'bottomleft'
| 'bottomright'
/**
* Selects the dialog renderer used by `GlobalDialog`. `'primevue'` is the
* current default and runs the legacy PrimeVue `Dialog` path. `'reka'` opts
* into the Reka-UI primitive set under `src/components/ui/dialog/`. Migration
* tracked in `temp/plans/adr-0009-dialog-reka-migration-DRAFT.md`.
*/
type DialogRenderer = 'primevue' | 'reka'
interface CustomDialogComponentProps {
maximizable?: boolean
maximized?: boolean
onClose?: () => void
closable?: boolean
modal?: boolean
position?: DialogPosition
pt?: DialogPassThroughOptions
closeOnEscape?: boolean
dismissableMask?: boolean
unstyled?: boolean
headless?: boolean
renderer?: DialogRenderer
size?: DialogContentSize
/**
* Class applied to the Reka-UI `DialogContent` element. Ignored on the
* PrimeVue path — use `pt` for that renderer.
*/
contentClass?: HTMLAttributes['class']
}
export type DialogComponentProps = ComponentAttrs<typeof GlobalDialog> &
CustomDialogComponentProps
export interface DialogInstance<
H extends Component = Component,
B extends Component = Component,
F extends Component = Component
> {
key: string
visible: boolean
title?: string
headerComponent?: H
headerProps?: ComponentAttrs<H>
component: B
contentProps: ComponentAttrs<B>
footerComponent?: F
footerProps?: ComponentAttrs<F>
dialogComponentProps: DialogComponentProps
priority: number
}
export interface ShowDialogOptions<
H extends Component = Component,
B extends Component = Component,
F extends Component = Component
> {
key?: string
title?: string
headerComponent?: H
footerComponent?: F
component: B
props?: ComponentAttrs<B>
headerProps?: ComponentAttrs<H>
footerProps?: ComponentAttrs<F>
dialogComponentProps?: DialogComponentProps
/**
* Optional priority for dialog stacking.
* A dialog will never be shown above a dialog with a higher priority.
* @default 1
*/
priority?: number
}
export const useDialogStore = defineStore('dialog', () => {
const dialogStack = ref<DialogInstance[]>([])
/**
* The key of the currently active (top-most) dialog.
* Only the active dialog can be closed with the ESC key.
*/
const activeKey = ref<string | null>(null)
const genDialogKey = () => `dialog-${Math.random().toString(36).slice(2, 9)}`
/**
* Inserts a dialog into the stack at the correct position based on priority.
* Higher priority dialogs are placed before lower priority ones.
*/
function insertDialogByPriority(dialog: DialogInstance) {
const insertIndex = dialogStack.value.findIndex(
(d) => d.priority <= dialog.priority
)
dialogStack.value.splice(
insertIndex === -1 ? dialogStack.value.length : insertIndex,
0,
dialog
)
}
function riseDialog(options: { key: string }) {
const dialogKey = options.key
const index = dialogStack.value.findIndex((d) => d.key === dialogKey)
if (index !== -1) {
const [dialog] = dialogStack.value.splice(index, 1)
insertDialogByPriority(dialog)
activeKey.value = dialogKey
updateCloseOnEscapeStates()
}
}
function closeDialog(options?: { key: string }) {
const targetDialog = options
? dialogStack.value.find((d) => d.key === options.key)
: dialogStack.value.find((d) => d.key === activeKey.value)
if (!targetDialog) return
targetDialog.dialogComponentProps?.onClose?.()
const index = dialogStack.value.indexOf(targetDialog)
dialogStack.value.splice(index, 1)
activeKey.value =
dialogStack.value.length > 0
? dialogStack.value[dialogStack.value.length - 1].key
: null
updateCloseOnEscapeStates()
}
function createDialog<
H extends Component = Component,
B extends Component = Component,
F extends Component = Component
>(options: ShowDialogOptions<H, B, F> & { key: string }) {
if (dialogStack.value.length >= 10) {
dialogStack.value.shift()
}
const dialog = {
key: options.key,
visible: true,
title: options.title,
headerComponent: options.headerComponent
? markRaw(options.headerComponent)
: undefined,
footerComponent: options.footerComponent
? markRaw(options.footerComponent)
: undefined,
component: markRaw(options.component),
headerProps: { ...options.headerProps },
contentProps: { ...options.props },
footerProps: { ...options.footerProps },
priority: options.priority ?? 1,
dialogComponentProps: {
maximizable: false,
modal: true,
closable: true,
closeOnEscape: true,
dismissableMask: true,
...options.dialogComponentProps,
maximized: false,
onMaximize: () => {
dialog.dialogComponentProps.maximized = true
},
onUnmaximize: () => {
dialog.dialogComponentProps.maximized = false
},
onAfterHide: () => {
closeDialog(dialog)
},
pt: merge(options.dialogComponentProps?.pt || {}, {
root: {
onMousedown: () => {
riseDialog(dialog)
}
}
})
}
}
insertDialogByPriority(dialog)
activeKey.value = options.key
updateCloseOnEscapeStates()
return dialog
}
/**
* Ensures only the top-most dialog in the stack can be closed with the Escape key.
* This is necessary because PrimeVue Dialogs do not handle `closeOnEscape` prop
* correctly when multiple dialogs are open.
*/
function updateCloseOnEscapeStates() {
const topDialog = dialogStack.value.find((d) => d.key === activeKey.value)
const topClosable = topDialog?.dialogComponentProps.closable
dialogStack.value.forEach((dialog) => {
dialog.dialogComponentProps = {
...dialog.dialogComponentProps,
closeOnEscape: dialog === topDialog && !!topClosable
}
})
}
function showDialog<
H extends Component = Component,
B extends Component = Component,
F extends Component = Component
>(options: ShowDialogOptions<H, B, F>) {
const dialogKey = options.key || genDialogKey()
let dialog = dialogStack.value.find((d) => d.key === dialogKey)
if (dialog) {
dialog.visible = true
riseDialog(dialog)
} else {
dialog = createDialog({ ...options, key: dialogKey })
}
return dialog
}
/**
* Shows a dialog from a third party extension.
* Explicitly keys extension dialogs with `extension-` prefix,
* to avoid conflicts & prevent use of internal dialogs (available via `dialogService`).
*/
function showExtensionDialog(options: ShowDialogOptions & { key: string }) {
const { key } = options
if (!key) {
console.error('Extension dialog key is required')
return
}
const extKey = key.startsWith('extension-') ? key : `extension-${key}`
const dialog = dialogStack.value.find((d) => d.key === extKey)
if (!dialog) return createDialog({ ...options, key: extKey })
dialog.visible = true
riseDialog(dialog)
return dialog
}
function isDialogOpen(key: string) {
return dialogStack.value.some((d) => d.key === key)
}
return {
dialogStack,
riseDialog,
showDialog,
closeDialog,
showExtensionDialog,
isDialogOpen,
activeKey
}
})