From 40e47b5d4be8d7a76c9b0d9b675952b33e79a5a3 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 17:56:16 -0500 Subject: [PATCH 1/2] feat(ui): job page can show the workflow it ran as JSON The job page already fetched the workflow a job ran for the flow graph - that response is the realized copy when the run wrote one, and carried a `realized` flag the UI dropped. The Workflow section now names which copy it is (realized / as submitted) and offers a show/hide JSON toggle that renders the definition in a readonly editor, mounted only while open. Tests stub JsonEditor so the suite never boots Monaco in jsdom. Co-Authored-By: Claude --- ui/src/lib/pages/JobPage.svelte | 56 +++++++++++++++++++++++++- ui/src/lib/pages/JobPage.test.ts | 39 ++++++++++++++++-- ui/src/lib/pages/JsonEditorStub.svelte | 8 ++++ 3 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 ui/src/lib/pages/JsonEditorStub.svelte diff --git a/ui/src/lib/pages/JobPage.svelte b/ui/src/lib/pages/JobPage.svelte index 68e9e58..8c4f9eb 100644 --- a/ui/src/lib/pages/JobPage.svelte +++ b/ui/src/lib/pages/JobPage.svelte @@ -23,6 +23,7 @@ } from '../runstate' import { stepProgress } from '../progress' import FlowView from '../editor/FlowView.svelte' + import JsonEditor from '../editor/JsonEditor.svelte' import CopyButton from '../CopyButton.svelte' import DownloadLink from '../DownloadLink.svelte' import { notify } from '../toast' @@ -39,6 +40,13 @@ // a workflow that pins its seed to a literal or names none at all - // neither can be handed a different one, so the button stays away. let seedVariable = $state(null) + // Whether `definition` is the realized copy the run itself wrote (every + // mutable input pinned) or the definition as submitted - the run predates + // run tracking, or its run directory is gone. Named beside the JSON view. + let realized = $state(false) + // The JSON view of that definition - off until asked, since the flow graph + // already answers "what did this run do" for most readers + let showJson = $state(false) let events = $state([]) let error = $state('') // arrival clocks for pipeline_step events, for the ETA estimate @@ -51,6 +59,8 @@ events = [] definition = null seedVariable = null + realized = false + showJson = false // Under the flat output layout two runs write the same file names, so // a map keyed by name would show the last job's recipe for this one fileMeta = {} @@ -64,6 +74,7 @@ if (stopped) return definition = result.definition seedVariable = result.seed_variable + realized = result.realized }) .catch(() => { /* no definition on file - the graph just does not appear */ @@ -440,7 +451,27 @@ {#if definition}
-

Workflow

+
+

Workflow

+ {realized ? 'realized' : 'as submitted'} + + +
+ {#if showJson} +
+ +
+ {/if}
{/if} @@ -618,9 +658,21 @@ .flowsection { margin-bottom: 1rem; } - .flowsection h2 { + .flowhead { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem 1rem; margin-bottom: var(--space-2); } + .flowhead h2 { + margin: 0; + } + /* Which copy the definition is - what the engine resolves, so mono */ + .jsonmark { + font-family: var(--font-mono); + font-size: var(--t-xs); + } .warnings { color: var(--warn); } diff --git a/ui/src/lib/pages/JobPage.test.ts b/ui/src/lib/pages/JobPage.test.ts index eb0d654..fe39904 100644 --- a/ui/src/lib/pages/JobPage.test.ts +++ b/ui/src/lib/pages/JobPage.test.ts @@ -1,4 +1,4 @@ -import { cleanup, render, screen, waitFor } from '@testing-library/svelte' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte' import { afterEach, expect, it, vi } from 'vitest' import JobPage from './JobPage.svelte' import { api } from '../api' @@ -13,9 +13,17 @@ const stream = vi.hoisted(() => ({ const metadata = vi.hoisted(() => ({ byFile: {} as Record>, })) -// The definition the job ran, for the flow view and the unsaved reasons +// The definition the job ran, for the flow view and the unsaved reasons, +// and whether it is the realized copy or the definition as submitted const ran = vi.hoisted(() => ({ definition: null as Record | null, + realized: true, +})) + +// Monaco cannot boot in jsdom; the page's JSON view is tested through this +// stub, which renders the value it was handed as plain text +vi.mock('../editor/JsonEditor.svelte', async () => ({ + default: (await import('./JsonEditorStub.svelte')).default, })) vi.mock('../api', () => ({ @@ -23,7 +31,11 @@ vi.mock('../api', () => ({ api: { getJob: vi.fn(() => Promise.resolve(detail.job)), getJobWorkflow: vi.fn(() => - Promise.resolve({ definition: ran.definition, seed_variable: null }), + Promise.resolve({ + definition: ran.definition, + realized: ran.realized, + seed_variable: null, + }), ), galleryMetadata: vi.fn((name: string) => Promise.resolve({ @@ -67,6 +79,7 @@ afterEach(() => { stream.onEvent = null metadata.byFile = {} ran.definition = null + ran.realized = true vi.mocked(api.galleryMetadata).mockClear() }) @@ -351,3 +364,23 @@ it('says nothing about an acknowledgement a run did not carry', async () => { await waitFor(() => expect(screen.getByText('j1')).toBeTruthy()) expect(screen.queryByText(/acknowledged/)).toBeNull() }) + +it('offers the workflow JSON behind a toggle, labelled as the realized copy', async () => { + ran.definition = { steps: [{ name: 'base', pipeline: {} }] } + render(JobPage, { jobId: 'j1' }) + await waitFor(() => expect(screen.getByText('realized')).toBeTruthy()) + // The JSON is for the curious, not the default view + expect(screen.queryByTestId('json-editor')).toBeNull() + await fireEvent.click(screen.getByRole('button', { name: 'show JSON' })) + const shown = await screen.findByTestId('json-editor') + // What the viewer is handed is the definition the page fetched + expect(shown.textContent).toContain('"name": "base"') + expect(screen.getByRole('button', { name: 'hide JSON' })).toBeTruthy() +}) + +it('labels a definition with no realized copy on file as submitted', async () => { + ran.definition = { steps: [{ name: 'base', pipeline: {} }] } + ran.realized = false + render(JobPage, { jobId: 'j1' }) + await waitFor(() => expect(screen.getByText('as submitted')).toBeTruthy()) +}) diff --git a/ui/src/lib/pages/JsonEditorStub.svelte b/ui/src/lib/pages/JsonEditorStub.svelte new file mode 100644 index 0000000..b023d69 --- /dev/null +++ b/ui/src/lib/pages/JsonEditorStub.svelte @@ -0,0 +1,8 @@ + + +
{value}
\ No newline at end of file From 8b6b7835853c814d664957a1ece30b9ba2e8cdcb Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 18:04:21 -0500 Subject: [PATCH 2/2] fix(ui): JsonEditor soft-wraps long lines Monaco's default is no wrap, so a long prompt string in a JSON view forced a horizontal scroll. wordWrap: 'on' wraps at the container width for every view that uses the editor - the job page's realized JSON, the workflow page's definition, and the editable split views alike. Co-Authored-By: Claude --- ui/src/lib/editor/JsonEditor.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/src/lib/editor/JsonEditor.svelte b/ui/src/lib/editor/JsonEditor.svelte index 6ac0609..9ed833c 100644 --- a/ui/src/lib/editor/JsonEditor.svelte +++ b/ui/src/lib/editor/JsonEditor.svelte @@ -60,6 +60,7 @@ guides: { indentation: false }, automaticLayout: true, scrollBeyondLastLine: false, + wordWrap: 'on', fontSize: 13, tabSize: 2, fixedOverflowWidgets: true,