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}
+
+
resolveConfirm(false)}>
+
e.stopPropagation()}
+ >
+
{confirmState.message}
+
+
+
+
+
+
+{/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 2ac8375..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'
@@ -29,6 +30,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(() => {
@@ -64,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)
@@ -145,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
@@ -179,19 +184,29 @@
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() {
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
@@ -346,7 +361,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/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/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}