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
This commit is contained in:
Dante
2026-05-08 21:11:06 +09:00
committed by GitHub
parent 0ef98de8eb
commit 8108967d49
11 changed files with 200 additions and 57 deletions

View File

@@ -82,7 +82,7 @@ export class Topbar {
}
getSaveDialog(): Locator {
return this.page.locator('.p-dialog-content input')
return this.page.getByRole('dialog').getByRole('textbox')
}
saveWorkflow(workflowName: string): Promise<void> {
@@ -116,9 +116,9 @@ export class Topbar {
// Check if a confirmation dialog appeared (e.g., "Overwrite existing file?")
// If so, return early to let the test handle the confirmation
const confirmationDialog = this.page.locator(
'.p-dialog:has-text("Overwrite")'
)
const confirmationDialog = this.page
.getByRole('dialog')
.filter({ hasText: 'Overwrite' })
if (await confirmationDialog.isVisible()) {
return
}

View File

@@ -127,9 +127,7 @@ export class BuilderSelectHelper {
await popoverTrigger.click()
await this.page.getByText('Rename', { exact: true }).click()
const dialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
const dialogInput = this.page.getByRole('dialog').getByRole('textbox')
await dialogInput.fill(newName)
await this.page.keyboard.press('Enter')
await dialogInput.waitFor({ state: 'hidden' })

View File

@@ -18,9 +18,7 @@ export class NodeOperationsHelper {
public readonly promptDialogInput: Locator
constructor(private comfyPage: ComfyPage) {
this.promptDialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
this.promptDialogInput = this.page.getByRole('dialog').getByRole('textbox')
}
private get page() {

View File

@@ -229,9 +229,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
// The dialog appearing proves the keybinding was intercepted by the app.
await comfyPage.keyboard.press('Control+s')
// The Save As dialog should appear (p-dialog overlay)
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
await expect(dialogOverlay).toBeVisible()
// The Save As dialog should appear
const saveDialog = comfyPage.page.getByRole('dialog')
await expect(saveDialog).toBeVisible()
// Dismiss the dialog
await comfyPage.keyboard.press('Escape')

View File

@@ -16,9 +16,9 @@ async function saveAndOpenPublishDialog(
workflowName: string
): Promise<void> {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
const overwriteDialog = comfyPage.page.locator(
'.p-dialog:has-text("Overwrite")'
)
const overwriteDialog = comfyPage.page
.getByRole('dialog')
.filter({ hasText: 'Overwrite' })
// Bounded wait: point-in-time isVisible() can miss dialogs that open
// slightly after saveWorkflow() resolves.
try {

View File

@@ -11,6 +11,7 @@
<DialogOverlay />
<DialogContent
:size="item.dialogComponentProps.size ?? 'md'"
:class="item.dialogComponentProps.contentClass"
:aria-labelledby="item.key"
@escape-key-down="
(e) =>

View File

@@ -1,11 +1,11 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import { useDialogStore } from '@/stores/dialogStore'
import ConfirmationDialogContent from './ConfirmationDialogContent.vue'
type Props = ComponentProps<typeof ConfirmationDialogContent>
@@ -13,7 +13,23 @@ type Props = ComponentProps<typeof ConfirmationDialogContent>
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
messages: {
en: {
g: {
cancel: 'Cancel',
confirm: 'Confirm',
delete: 'Delete',
overwrite: 'Overwrite',
save: 'Save',
no: 'No',
ok: 'OK',
close: 'Close'
},
desktopMenu: {
reinstall: 'Reinstall'
}
}
},
missingWarn: false,
fallbackWarn: false
})
@@ -24,10 +40,9 @@ describe('ConfirmationDialogContent', () => {
})
function renderComponent(props: Partial<Props> = {}) {
return render(ConfirmationDialogContent, {
global: {
plugins: [PrimeVue, i18n]
},
const user = userEvent.setup()
render(ConfirmationDialogContent, {
global: { plugins: [i18n] },
props: {
message: 'Test message',
type: 'default',
@@ -35,6 +50,7 @@ describe('ConfirmationDialogContent', () => {
...props
} as Props
})
return { user }
}
it('renders long messages without breaking layout', () => {
@@ -44,42 +60,103 @@ describe('ConfirmationDialogContent', () => {
expect(screen.getByText(longFilename)).toBeInTheDocument()
})
it('omits the Cancel button when type is dirtyClose', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.queryByText('g.cancel')).not.toBeInTheDocument()
expect(screen.getByText('g.save')).toBeInTheDocument()
it('renders the hint as a status alert when provided', () => {
renderComponent({ hint: 'This action cannot be undone.' })
const status = screen.getByRole('status')
expect(status).toHaveTextContent('This action cannot be undone.')
})
it('uses the provided denyLabel for the deny button on dirtyClose', () => {
renderComponent({ type: 'dirtyClose', denyLabel: 'Sign out anyway' })
expect(screen.getByText('Sign out anyway')).toBeInTheDocument()
expect(screen.queryByText('g.no')).not.toBeInTheDocument()
it('does not render a status alert when hint is omitted', () => {
renderComponent()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('calls onConfirm(false) when deny is clicked on dirtyClose', async () => {
const onConfirm = vi.fn()
renderComponent({
type: 'dirtyClose',
denyLabel: 'Close anyway',
onConfirm
describe('button surface per type', () => {
it("type='default' renders Cancel and Confirm", () => {
renderComponent({ type: 'default' })
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Confirm' })
).toBeInTheDocument()
})
await userEvent.click(screen.getByRole('button', { name: 'Close anyway' }))
it("type='delete' renders Cancel and Delete", () => {
renderComponent({ type: 'delete' })
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument()
})
expect(onConfirm).toHaveBeenCalledWith(false)
it("type='overwrite' renders Cancel and Overwrite", () => {
renderComponent({ type: 'overwrite' })
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Overwrite' })
).toBeInTheDocument()
})
it("type='dirtyClose' renders No and Save (no Cancel)", () => {
renderComponent({ type: 'dirtyClose' })
expect(
screen.queryByRole('button', { name: 'Cancel' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
})
it("type='info' renders only OK (no Cancel)", () => {
renderComponent({ type: 'info' })
expect(screen.getByRole('button', { name: 'OK' })).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Cancel' })
).not.toBeInTheDocument()
})
})
it('calls onConfirm(true) when save is clicked on dirtyClose', async () => {
it('confirm callback receives true and closes the dialog', async () => {
const onConfirm = vi.fn()
renderComponent({ type: 'dirtyClose', onConfirm })
const { user } = renderComponent({ type: 'default', onConfirm })
const closeSpy = vi.spyOn(useDialogStore(), 'closeDialog')
await userEvent.click(screen.getByRole('button', { name: 'g.save' }))
await user.click(screen.getByRole('button', { name: 'Confirm' }))
expect(onConfirm).toHaveBeenCalledWith(true)
expect(closeSpy).toHaveBeenCalledOnce()
})
it('falls back to "no" label when denyLabel is not provided', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.getByText('g.no')).toBeInTheDocument()
describe('dirtyClose deny label', () => {
it('uses the provided denyLabel for the deny button', () => {
renderComponent({ type: 'dirtyClose', denyLabel: 'Sign out anyway' })
expect(screen.getByText('Sign out anyway')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'No' })
).not.toBeInTheDocument()
})
it('falls back to "No" when denyLabel is not provided', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument()
})
it('calls onConfirm(false) when deny is clicked', async () => {
const onConfirm = vi.fn()
const { user } = renderComponent({
type: 'dirtyClose',
denyLabel: 'Close anyway',
onConfirm
})
await user.click(screen.getByRole('button', { name: 'Close anyway' }))
expect(onConfirm).toHaveBeenCalledWith(false)
})
it('calls onConfirm(true) when save is clicked', async () => {
const onConfirm = vi.fn()
const { user } = renderComponent({ type: 'dirtyClose', onConfirm })
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(onConfirm).toHaveBeenCalledWith(true)
})
})
})

View File

@@ -9,16 +9,14 @@
{{ item }}
</li>
</ul>
<Message
<div
v-if="hint"
class="mt-2"
icon="pi pi-info-circle"
severity="secondary"
size="small"
variant="simple"
role="status"
class="mt-2 flex items-start gap-2 text-sm text-muted-foreground"
>
{{ hint }}
</Message>
<i class="pi pi-info-circle mt-0.5" aria-hidden="true" />
<span>{{ hint }}</span>
</div>
</div>
<div class="flex shrink-0 flex-wrap justify-end gap-4">
<div
@@ -115,7 +113,6 @@
</template>
<script setup lang="ts">
import Message from 'primevue/message'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'

View File

@@ -0,0 +1,63 @@
/**
* Phase 1 dialog migration regression net: when `dialogService.prompt()`,
* `dialogService.confirm()`, or `dialogService.showBillingComingSoonDialog()`
* is invoked, the dialog stack item must carry `renderer: 'reka'`. Catches
* accidental reverts of the Reka renderer flip.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const showDialog = vi.hoisted(() => vi.fn())
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ showDialog })
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackEvent: vi.fn() })
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: { value: true },
isFreeTier: { value: false },
type: { value: 'legacy' }
})
}))
import { useDialogService } from '@/services/dialogService'
describe('dialogService Reka renderer opt-in (Phase 1)', () => {
beforeEach(() => {
showDialog.mockReset()
})
it("prompt() sets renderer 'reka' and size 'md'", () => {
void useDialogService().prompt({ title: 'T', message: 'M' })
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.size).toBe('md')
})
it("confirm() sets renderer 'reka' and size 'md'", () => {
void useDialogService().confirm({ title: 'T', message: 'M' })
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.size).toBe('md')
})
it("showBillingComingSoonDialog() sets renderer 'reka', size 'sm', and 360px contentClass", () => {
useDialogService().showBillingComingSoonDialog()
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.size).toBe('sm')
expect(args.dialogComponentProps.contentClass).toBe('max-w-[360px]')
})
})

View File

@@ -251,6 +251,8 @@ export const useDialogService = () => {
placeholder
},
dialogComponentProps: {
renderer: 'reka',
size: 'md',
onClose: () => {
resolve(null)
}
@@ -286,6 +288,8 @@ export const useDialogService = () => {
denyLabel
},
dialogComponentProps: {
renderer: 'reka',
size: 'md',
onClose: () => resolve(null)
}
}
@@ -572,9 +576,9 @@ export const useDialogService = () => {
onConfirm: () => {}
},
dialogComponentProps: {
pt: {
root: { class: 'max-w-[360px]' }
}
renderer: 'reka',
size: 'sm',
contentClass: 'max-w-[360px]'
}
})
}

View File

@@ -4,7 +4,7 @@ import { merge } from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import type { DialogPassThroughOptions } from 'primevue/dialog'
import { markRaw, ref } from 'vue'
import type { Component } 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'
@@ -43,6 +43,11 @@ interface CustomDialogComponentProps {
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> &