From 89fd45b70afd602afbb9be8756b54370f0512b93 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Tue, 8 Sep 2026 16:02:38 -0500 Subject: [PATCH 1/2] Enhance Gallery and Job pages: add metadata loading state and copy job ID feature; update JSON templates to embed metadata --- ui/src/lib/pages/GalleryPage.svelte | 29 ++++++++++++++----- ui/src/lib/pages/JobPage.svelte | 3 ++ .../templates/minimax/dialogue-short.json | 6 ++-- workflows/templates/minimax/music-video.json | 3 +- workflows/templates/minimax/storyboard.json | 9 ++++-- 5 files changed, 36 insertions(+), 14 deletions(-) diff --git a/ui/src/lib/pages/GalleryPage.svelte b/ui/src/lib/pages/GalleryPage.svelte index 2ac8375..b21a146 100644 --- a/ui/src/lib/pages/GalleryPage.svelte +++ b/ui/src/lib/pages/GalleryPage.svelte @@ -29,6 +29,7 @@ let anchor = $state(null) let busy = $state(false) let metadata = $state | null>(null) + let metadataLoading = $state(false) let sourceJob = $state<{ id: string; status: string } | null>(null) $effect(() => { @@ -179,13 +180,20 @@ function select(file: GalleryFile) { selected = file metadata = null + metadataLoading = true sourceJob = null - api.galleryMetadata(file.name).then((r) => { - if (selected?.name === file.name) { - metadata = r.metadata - sourceJob = r.job - } - }) + api + .galleryMetadata(file.name) + .then((r) => { + if (selected?.name === file.name) { + metadata = r.metadata + sourceJob = r.job + } + }) + .catch(() => {}) + .finally(() => { + if (selected?.name === file.name) metadataLoading = false + }) } async function removeFile() { @@ -346,7 +354,6 @@
{selected.name} - {mb(selected.size)} · {day(selected.mtime)} {#if embeddedWorkflow} +
{/if}
- {:else if selected.kind === 'image'} + {:else if selected.kind === 'image' && metadataLoading}
reading metadata…
+ {:else if selected.kind === 'image'} +
+ no embedded metadata - enable embed_metadata in the step's result +
{/if} diff --git a/ui/src/lib/pages/JobPage.svelte b/ui/src/lib/pages/JobPage.svelte index a2006cc..73ae051 100644 --- a/ui/src/lib/pages/JobPage.svelte +++ b/ui/src/lib/pages/JobPage.svelte @@ -5,6 +5,7 @@ import { groupResultFiles } from '../results' import { stepProgress } from '../progress' import FlowView from '../editor/FlowView.svelte' + import CopyButton from '../CopyButton.svelte' import type { JobDetail, JobEvent } from '../types' let { jobId }: { jobId: string } = $props() @@ -139,6 +140,8 @@ {#if job}

{job.workflow}

{job.status} + {job.id} + {#if cancelPending} Date: Tue, 8 Sep 2026 16:40:00 -0500 Subject: [PATCH 2/2] Implement custom confirmation dialog for delete actions across multiple pages --- ui/e2e/smoke.spec.ts | 10 +++- ui/src/App.svelte | 2 + ui/src/lib/ConfirmDialog.svelte | 63 ++++++++++++++++++++++++ ui/src/lib/confirm.svelte.ts | 42 ++++++++++++++++ ui/src/lib/pages/EditorPage.svelte | 15 ++++-- ui/src/lib/pages/GalleryPage.svelte | 15 ++++-- ui/src/lib/pages/GalleryPage.test.ts | 31 ++++++++++-- ui/src/lib/pages/ModelsPage.svelte | 6 ++- ui/src/lib/pages/PromptEditorPage.svelte | 6 ++- ui/src/lib/pages/ServerPage.svelte | 8 ++- ui/src/lib/pages/WorkflowPage.svelte | 10 +++- 11 files changed, 187 insertions(+), 21 deletions(-) create mode 100644 ui/src/lib/ConfirmDialog.svelte create mode 100644 ui/src/lib/confirm.svelte.ts diff --git a/ui/e2e/smoke.spec.ts b/ui/e2e/smoke.spec.ts index 7e331f7..0f3f84b 100644 --- a/ui/e2e/smoke.spec.ts +++ b/ui/e2e/smoke.spec.ts @@ -147,8 +147,11 @@ test('editor validates, saves into a new folder, and deletes', async ({ await expect( page.getByRole('heading', { name: 'e2e-scratch/E2EScratch' }), ).toBeVisible() - page.once('dialog', (dialog) => dialog.accept()) await page.getByRole('button', { name: /delete this workflow/ }).click() + await page + .getByRole('alertdialog') + .getByRole('button', { name: 'Delete', exact: true }) + .click() await expect(page.getByRole('heading', { name: 'Workflows' })).toBeVisible() await expect(page.getByRole('link', { name: /E2EScratch/ })).toHaveCount(0) }) @@ -193,8 +196,11 @@ test('prompts page lists, creates at the root, and deletes', async ({ 'an e2e scratch prompt', { timeout: 15_000 }, ) - page.once('dialog', (dialog) => dialog.accept()) await page.getByRole('button', { name: /Delete/ }).click() + await page + .getByRole('alertdialog') + .getByRole('button', { name: 'Delete', exact: true }) + .click() await expect(page.getByRole('heading', { name: 'Prompts' })).toBeVisible() await expect( page.getByRole('link', { name: 'E2EScratchPrompt' }), diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 05d6414..acc25ce 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -22,6 +22,7 @@ import KeyboardHelp from './lib/KeyboardHelp.svelte' import StatusPopover from './lib/StatusPopover.svelte' import TokenPopover from './lib/TokenPopover.svelte' + import ConfirmDialog from './lib/ConfirmDialog.svelte' import WorkflowsPage from './lib/pages/WorkflowsPage.svelte' import WorkflowPage from './lib/pages/WorkflowPage.svelte' import JobsPage from './lib/pages/JobsPage.svelte' @@ -298,6 +299,7 @@ + diff --git a/ui/src/lib/ConfirmDialog.svelte b/ui/src/lib/ConfirmDialog.svelte new file mode 100644 index 0000000..ea3e9a5 --- /dev/null +++ b/ui/src/lib/ConfirmDialog.svelte @@ -0,0 +1,63 @@ + + + + +{#if confirmState.open} + + +{/if} + + diff --git a/ui/src/lib/confirm.svelte.ts b/ui/src/lib/confirm.svelte.ts new file mode 100644 index 0000000..3bd2e4e --- /dev/null +++ b/ui/src/lib/confirm.svelte.ts @@ -0,0 +1,42 @@ +// A window.confirm replacement that renders through the app's own modal +// styling instead of the browser chrome. One state object plus a single +// mounted in App.svelte, mirroring the notify()/Toaster +// pair in toast.ts - callers just `await confirmDialog(...)` in place of +// `window.confirm(...)`. + +type ConfirmState = { + open: boolean + message: string + confirmLabel: string + cancelLabel: string +} + +export const confirmState = $state({ + open: false, + message: '', + confirmLabel: 'OK', + cancelLabel: 'Cancel', +}) + +// At most one confirm is ever in flight - a second call while one is open +// would have no dialog to land in anyway, so it simply replaces the first. +let resolver: ((value: boolean) => void) | null = null + +export function confirmDialog( + message: string, + options?: { confirmLabel?: string; cancelLabel?: string }, +): Promise { + confirmState.open = true + confirmState.message = message + confirmState.confirmLabel = options?.confirmLabel ?? 'OK' + confirmState.cancelLabel = options?.cancelLabel ?? 'Cancel' + return new Promise((resolve) => { + resolver = resolve + }) +} + +export function resolveConfirm(value: boolean): void { + confirmState.open = false + resolver?.(value) + resolver = null +} diff --git a/ui/src/lib/pages/EditorPage.svelte b/ui/src/lib/pages/EditorPage.svelte index 5ab14a6..fb3f975 100644 --- a/ui/src/lib/pages/EditorPage.svelte +++ b/ui/src/lib/pages/EditorPage.svelte @@ -15,6 +15,7 @@ } from '@lucide/svelte' import { api } from '../api' import { notify } from '../toast' + import { confirmDialog } from '../confirm.svelte' import { go } from '../router.svelte' import { emptyWorkflow, @@ -132,10 +133,16 @@ '#/workflows/' + name.split('/').map(encodeURIComponent).join('/'), ) - // Leaving with unsaved edits used to drop them without a word - function confirmLeave(event: MouseEvent) { - if (dirty && !window.confirm('Discard unsaved changes?')) - event.preventDefault() + // Leaving with unsaved edits used to drop them without a word. The + // confirm is async, so the default navigation is always prevented first + // and replayed by hand once the answer comes back. + async function confirmLeave(event: MouseEvent) { + if (!dirty) return + event.preventDefault() + const target = (event.currentTarget as HTMLAnchorElement).href + if (await confirmDialog('Discard unsaved changes?')) { + window.location.href = target + } } const savePreview = $derived.by(() => { diff --git a/ui/src/lib/pages/GalleryPage.svelte b/ui/src/lib/pages/GalleryPage.svelte index b21a146..23ce63a 100644 --- a/ui/src/lib/pages/GalleryPage.svelte +++ b/ui/src/lib/pages/GalleryPage.svelte @@ -14,6 +14,7 @@ import { go } from '../router.svelte' import { SvelteSet } from 'svelte/reactivity' import { notify } from '../toast' + import { confirmDialog } from '../confirm.svelte' import type { GalleryFile } from '../types' import WorkspacePicker from '../WorkspacePicker.svelte' import { workspace } from '../workspace.svelte' @@ -65,7 +66,9 @@ // choice to replace belongs to the person, not the button if ( message.includes('already exists') && - window.confirm(`${message}\n\nReplace it?`) + (await confirmDialog(`${message}\n\nReplace it?`, { + confirmLabel: 'Replace', + })) ) { try { const result = await api.keepOutput(selected.name, assetName, true) @@ -146,9 +149,10 @@ async function removePicked() { const names = pickedNames if ( - !window.confirm( + !(await confirmDialog( `Delete ${names.length} file${names.length === 1 ? '' : 's'}? This removes them on disk.`, - ) + { confirmLabel: 'Delete' }, + )) ) return busy = true @@ -199,7 +203,10 @@ async function removeFile() { if (!selected) return if ( - !window.confirm(`Delete ${selected.name}? This removes the file on disk.`) + !(await confirmDialog( + `Delete ${selected.name}? This removes the file on disk.`, + { confirmLabel: 'Delete' }, + )) ) return const name = selected.name diff --git a/ui/src/lib/pages/GalleryPage.test.ts b/ui/src/lib/pages/GalleryPage.test.ts index f1cc8f9..e4db962 100644 --- a/ui/src/lib/pages/GalleryPage.test.ts +++ b/ui/src/lib/pages/GalleryPage.test.ts @@ -1,9 +1,16 @@ -import { cleanup, render, screen, waitFor } from '@testing-library/svelte' +import { + cleanup, + render, + screen, + waitFor, + within, +} from '@testing-library/svelte' import { afterEach, beforeEach, expect, it, vi } from 'vitest' // Hoisted above the imports so the static import of the component below - // itself hoisted - sees an initialized mock. Importing the component inside // the test instead would charge its (multi-second) compile to the test timeout import GalleryPage from './GalleryPage.svelte' +import ConfirmDialog from '../ConfirmDialog.svelte' import type { GalleryFile } from '../types' const file = (name: string): GalleryFile => ({ @@ -68,12 +75,26 @@ const checkbox = (name: string) => screen.getByRole('checkbox', { name: `select ${name}` }) async function renderGallery(first = 'a.png') { + // ConfirmDialog is normally mounted once in App.svelte and driven through + // the shared confirm.svelte.ts state - render it alongside so a test can + // answer the dialogs GalleryPage's delete/replace flows open. + render(ConfirmDialog) render(GalleryPage) await waitFor(() => expect(screen.getByLabelText(`select ${first}`)).toBeTruthy(), ) } +/** Answers the confirm dialog opened by a delete/replace action - scoped to + * the dialog itself, since its "Delete" button shares a name with whatever + * trigger button opened it. */ +async function answerConfirm(accept: boolean) { + const dialog = await waitFor(() => screen.getByRole('alertdialog')) + within(dialog) + .getByRole('button', { name: accept ? /^delete$/i : /^cancel$/i }) + .click() +} + it('fetches the gallery listing exactly once on mount', async () => { render(GalleryPage) // Let the request settle and any (wrongly) re-triggered effects run @@ -130,13 +151,13 @@ it('archives every selected file in one request', async () => { }) it('drops deleted files from the grid and the selection', async () => { - vi.stubGlobal('confirm', () => true) await renderGallery() checkbox('a.png').click() checkbox('b.png').click() await waitFor(() => expect(screen.getByText('2 selected')).toBeTruthy()) screen.getByRole('button', { name: /^delete/i }).click() + await answerConfirm(true) await waitFor(() => expect(screen.queryByLabelText('select a.png')).toBeNull(), @@ -147,7 +168,6 @@ it('drops deleted files from the grid and the selection', async () => { }) it('keeps a file that failed to delete selected and reports it', async () => { - vi.stubGlobal('confirm', () => true) deleteOutput.mockImplementation((name: string) => name === 'b.png' ? Promise.reject(new Error('busy')) : Promise.resolve(), ) @@ -157,6 +177,7 @@ it('keeps a file that failed to delete selected and reports it', async () => { checkbox('b.png').click() await waitFor(() => expect(screen.getByText('2 selected')).toBeTruthy()) screen.getByRole('button', { name: /^delete/i }).click() + await answerConfirm(true) await waitFor(() => expect(screen.getByText('1 selected')).toBeTruthy()) expect(screen.queryByLabelText('select a.png')).toBeNull() @@ -165,19 +186,18 @@ it('keeps a file that failed to delete selected and reports it', async () => { }) it('does not delete anything when the confirmation is declined', async () => { - vi.stubGlobal('confirm', () => false) await renderGallery() checkbox('a.png').click() await waitFor(() => expect(screen.getByText('1 selected')).toBeTruthy()) screen.getByRole('button', { name: /^delete/i }).click() + await answerConfirm(false) await new Promise((r) => setTimeout(r, 10)) expect(deleteOutput).not.toHaveBeenCalled() }) it('drops a file deleted from the detail panel out of the selection', async () => { - vi.stubGlobal('confirm', () => true) await renderGallery() checkbox('a.png').click() @@ -196,6 +216,7 @@ it('drops a file deleted from the detail panel out of the selection', async () = screen .getByRole('button', { name: 'delete this file from the output directory' }) .click() + await answerConfirm(true) await waitFor(() => expect(screen.getByText('1 selected')).toBeTruthy()) expect(screen.getByLabelText('select b.png')).toBeTruthy() diff --git a/ui/src/lib/pages/ModelsPage.svelte b/ui/src/lib/pages/ModelsPage.svelte index 00d2b0f..04ecf12 100644 --- a/ui/src/lib/pages/ModelsPage.svelte +++ b/ui/src/lib/pages/ModelsPage.svelte @@ -11,6 +11,7 @@ } from '@lucide/svelte' import { api } from '../api' import { notify } from '../toast' + import { confirmDialog } from '../confirm.svelte' import CopyButton from '../CopyButton.svelte' import type { DiffusersStatus, @@ -120,10 +121,11 @@ async function remove(repo: ModelRepo) { if ( - !window.confirm( + !(await confirmDialog( `Delete ${repo.repo_id} (${gb(repo.size_on_disk)} GB) from the hub cache?\n` + 'The next workflow that needs it will download it again.', - ) + { confirmLabel: 'Delete' }, + )) ) return deleting = repo.repo_id diff --git a/ui/src/lib/pages/PromptEditorPage.svelte b/ui/src/lib/pages/PromptEditorPage.svelte index 1b50b5c..009f3e5 100644 --- a/ui/src/lib/pages/PromptEditorPage.svelte +++ b/ui/src/lib/pages/PromptEditorPage.svelte @@ -15,6 +15,7 @@ import { go } from '../router.svelte' import { phaseLabel } from '../progress' import { notify } from '../toast' + import { confirmDialog } from '../confirm.svelte' import { loadPromptLibrary } from '../promptlib.svelte' import { groupOf, leafOf } from '../grouping' import { @@ -429,9 +430,10 @@ /* the confirm still protects the file itself */ } if ( - !window.confirm( + !(await confirmDialog( `Delete ${name}.json? This removes the file on disk.${warning}`, - ) + { confirmLabel: 'Delete' }, + )) ) return try { diff --git a/ui/src/lib/pages/ServerPage.svelte b/ui/src/lib/pages/ServerPage.svelte index 89e6178..58979e6 100644 --- a/ui/src/lib/pages/ServerPage.svelte +++ b/ui/src/lib/pages/ServerPage.svelte @@ -18,6 +18,7 @@ } from '../serverinfo' import type { HealthInfo, ServerInfo } from '../types' import { notify } from '../toast' + import { confirmDialog } from '../confirm.svelte' import { DEFAULT_WORKSPACE, invalidateWorkspaces, @@ -96,7 +97,12 @@ await api.deleteWorkspace(name) } catch (e) { const detail = e instanceof Error ? e.message : String(e) - if (!window.confirm(`${detail}\n\nDelete workspace "${name}"?`)) return + if ( + !(await confirmDialog(`${detail}\n\nDelete workspace "${name}"?`, { + confirmLabel: 'Delete', + })) + ) + return try { await api.deleteWorkspace(name, true) if (workspace.current === name) selectWorkspace(DEFAULT_WORKSPACE) diff --git a/ui/src/lib/pages/WorkflowPage.svelte b/ui/src/lib/pages/WorkflowPage.svelte index 846be49..cd6f92e 100644 --- a/ui/src/lib/pages/WorkflowPage.svelte +++ b/ui/src/lib/pages/WorkflowPage.svelte @@ -8,6 +8,7 @@ import { loadPromptLibrary, promptLibrary } from '../promptlib.svelte' import { PROMPT_LIST_ID } from '../prompts' import { notify } from '../toast' + import { confirmDialog } from '../confirm.svelte' import type { GalleryFile, WorkflowDefinition } from '../types' let { name }: { name: string } = $props() @@ -77,7 +78,14 @@ } async function remove() { - if (!window.confirm(`Delete ${name}.json? This removes the file on disk.`)) + if ( + !(await confirmDialog( + `Delete ${name}.json? This removes the file on disk.`, + { + confirmLabel: 'Delete', + }, + )) + ) return try { await api.deleteWorkflow(name)