From 50e5a3b6c22ca4087f698175b60526d23b85c29d Mon Sep 17 00:00:00 2001 From: zzheng Date: Sat, 19 Sep 2026 14:43:48 -0700 Subject: [PATCH 01/21] Split Dex Web v2 into Run and Work queue modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One /v2 screen served two jobs at once: driving a run on the definition canvas, and clearing work that needs a person. Separate them. /v2/run keeps today's behaviour — listing, Display, Actions and the canvas — and gains a link to the v1 run page, which owns Timeline, the event browser, Stop and Time Travel. /v2/queue draws no canvas. Clearing a queue does not need the shape of the process, so "See the process" opens the same run in Run mode instead. The listing, the filter builder and the Display/Actions panel are shared rather than duplicated, so the two modes cannot drift apart. No contract, API or analyzer change. --- web/app/App.tsx | 13 +- web/app/components/AppHeader.tsx | 28 +- web/app/globals.css | 46 ++ web/app/v2/RunWorkspace.tsx | 134 ++++++ web/app/v2/V2Workspace.tsx | 530 ---------------------- web/app/v2/contract.test.ts | 21 +- web/app/v2/contract.ts | 24 +- web/app/v2/css/v2.css | 33 ++ web/app/v2/queue/QueueWorkspace.tsx | 80 ++++ web/app/v2/workspace/FlowListing.tsx | 244 ++++++++++ web/app/v2/workspace/SelectedRunPanel.tsx | 262 +++++++++++ web/app/v2/workspace/filters.ts | 82 ++++ web/app/v2/workspace/useFlowSearch.ts | 106 +++++ 13 files changed, 1057 insertions(+), 546 deletions(-) create mode 100644 web/app/v2/RunWorkspace.tsx delete mode 100644 web/app/v2/V2Workspace.tsx create mode 100644 web/app/v2/queue/QueueWorkspace.tsx create mode 100644 web/app/v2/workspace/FlowListing.tsx create mode 100644 web/app/v2/workspace/SelectedRunPanel.tsx create mode 100644 web/app/v2/workspace/filters.ts create mode 100644 web/app/v2/workspace/useFlowSearch.ts diff --git a/web/app/App.tsx b/web/app/App.tsx index d8600c8a8..0907cc87c 100644 --- a/web/app/App.tsx +++ b/web/app/App.tsx @@ -14,7 +14,8 @@ import { RunDetailsPage } from './flows/RunDetailsPage'; import { PreferencesProvider } from './providers'; import { FlowRenderingPage } from './rendering/FlowRenderingPage'; import { ThemeProvider } from './theme'; -import { HomePage, V2Workspace } from './v2/V2Workspace'; +import { QueueWorkspace } from './v2/queue/QueueWorkspace'; +import { HomePage, RunWorkspace } from './v2/RunWorkspace'; import { WebCatalogProvider } from './v2/WebCatalogProvider'; export function App() { @@ -26,9 +27,13 @@ export function App() {
} /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/web/app/components/AppHeader.tsx b/web/app/components/AppHeader.tsx index 90ae1f82b..03a37698b 100644 --- a/web/app/components/AppHeader.tsx +++ b/web/app/components/AppHeader.tsx @@ -8,16 +8,23 @@ import { Link, useLocation, useNavigate } from 'react-router-dom'; import { usePreferences } from '../providers'; +import { v2HomePath, v2ModePath, type V2Mode } from '../v2/contract'; import { useWebCatalog } from '../v2/WebCatalogProvider'; import { ThemeToggle } from './ThemeToggle'; +const V2_MODES: { mode: V2Mode; label: string }[] = [ + { mode: 'run', label: 'Run' }, + { mode: 'queue', label: 'Work queue' }, +]; + export function AppHeader() { const { timezone, setTimezone } = usePreferences(); const { canUseV2 } = useWebCatalog(); const location = useLocation(); const navigate = useNavigate(); const isV2 = location.pathname === '/v2' || location.pathname.startsWith('/v2/'); - const home = canUseV2 && isV2 ? '/v2' : '/v1/flows'; + const activeMode: V2Mode = location.pathname.startsWith('/v2/queue') ? 'queue' : 'run'; + const home = canUseV2 && isV2 ? v2HomePath(canUseV2) : '/v1/flows'; return (
@@ -43,6 +50,21 @@ export function AppHeader() { Flow Rendering )} + {isV2 && canUseV2 && ( +
+ {V2_MODES.map(({ mode, label }) => ( + + ))} +
+ )} Dex server @@ -53,7 +75,9 @@ export function AppHeader() { setFilters(filters.map((item) => ( - item.id === filter.id ? { ...item, field: event.target.value, operator: 'eq', value: '' } : item - )))}> - {fields.map((field) => )} - - - setFilters(updateFilter(filters, filter.id, 'value', event.target.value))} - /> - -
- ))} - -
- - -
- {searchError &&

{searchError}

} -
    - {flows.map((flow) => ( -
  1. - -
  2. - ))} - {!loading && flows.length === 0 &&
  3. No matching Flows
  4. } -
-
- - Page {page + 1} - -
- {flowId ? ( - <> - - - - ) : ( -

Select a run to edit fields and invoke Actions.

- )} - - -
- -
- - - ); -} - -function SelectedRunPanel({ - flowType, - flowId, - definition, -}: { - flowType: string; - flowId: string; - definition: FlowV2Definition; -}) { - const [result, setResult] = useState(null); - const [error, setError] = useState(''); - const [busyKey, setBusyKey] = useState(''); - const [editingKey, setEditingKey] = useState(''); - const [editValue, setEditValue] = useState(''); - const [fieldErrors, setFieldErrors] = useState>({}); - const [actionValues, setActionValues] = useState>>({}); - - const loadDisplay = useCallback(async () => { - setError(''); - try { - const query = new URLSearchParams({ flowType, flowId }); - const response = await fetch(`/api/v2/display?${query}`); - setResult(await readResponseJSON(response)); - } catch (loadError) { - setError(loadError instanceof Error ? loadError.message : 'Display failed to load'); - } - }, [flowId, flowType]); - - useEffect(() => { void loadDisplay(); }, [loadDisplay]); - - async function saveField(attributeKey: string, valueType: V2ValueType) { - setBusyKey(attributeKey); - setError(''); - setFieldErrors((current) => ({ ...current, [attributeKey]: '' })); - try { - const response = await fetch('/api/v2/display', { - method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - flowType, flowId, attributeKey, - value: parseTypedValue(editValue, valueType), - }), - }); - await readResponseJSON(response); - setEditingKey(''); - await loadDisplay(); - } catch (saveError) { - setFieldErrors((current) => ({ - ...current, - [attributeKey]: saveError instanceof Error ? saveError.message : 'Edit failed', - })); - } finally { - setBusyKey(''); - } - } - - async function invokeAction(action: FlowV2Action) { - setBusyKey(action.rpcName); - setError(''); - try { - const input = v2ActionUserInput(action, actionValues[action.rpcName] ?? {}); - const response = await fetch('/api/v2/actions', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - flowType, flowId, rpcName: action.rpcName, - input, attributeSnapshot: result?.attributeSnapshot ?? {}, - }), - }); - await readResponseJSON(response); - await loadDisplay(); - } catch (actionError) { - setError(actionError instanceof Error ? actionError.message : 'Action failed'); - } finally { - setBusyKey(''); - } - } - - return ( -
-
- {flowId} - {result && {result.flowStatus}} -
- {error &&

{error}

} - {!result && !error &&

Loading Display…

} - {result && ( - <> -
-
Display
-
- {definition.display.fields.map((field) => { - const isEditing = editingKey === field.attributeKey; - return ( -
-
{field.description}
-
- {isEditing ? ( - <> - - - - {fieldErrors[field.attributeKey] && {fieldErrors[field.attributeKey]}} - - ) : ( - <> - {displayValue(result.display[field.attributeKey])} - {field.editable && result.isActive && ( - - )} - - )} -
-
- ); - })} -
-
-
-
Actions
- {visibleV2Actions(definition.actions, result.eligibleActions).map((action) => { - const userFields = v2ActionUserFields(action); - return ( -
{ - event.preventDefault(); - void invokeAction(action); - }}> - {userFields.map((field) => ( - - ))} - -
- ); - })} - {definition.actions.length > 0 && result.eligibleActions.length === 0 && ( -

No Actions are available in the current state.

- )} -
- - )} -
- ); -} - -function filterFields(definition: FlowV2Definition) { - return [ - { key: 'flowId', label: 'Flow ID' }, - { key: 'executionStatus', label: 'Execution status' }, - { key: 'startTime', label: 'Start time' }, - { key: 'closeTime', label: 'Close time' }, - ...definition.indexedAttributes.map((attribute) => ({ key: attribute.attributeKey, label: attribute.description })), - ]; -} - -function filterValueType(field: string, definition: FlowV2Definition): V2ValueType { - if (field === 'startTime' || field === 'closeTime') return 'datetime'; - if (field === 'flowId' || field === 'executionStatus') return 'string'; - return definition.indexedAttributes.find((attribute) => attribute.attributeKey === field)?.valueType ?? 'string'; -} - -function filterIndexType(field: string, definition: FlowV2Definition) { - if (field === 'startTime' || field === 'closeTime') return 'datetime'; - if (field === 'flowId' || field === 'executionStatus') return 'keyword'; - return definition.indexedAttributes.find((attribute) => attribute.attributeKey === field)?.indexType ?? 'keyword'; -} - -function filterOperators(indexType: FlowV2Definition['indexedAttributes'][number]['indexType']) { - const equality = [{ value: 'eq', label: 'equals' }, { value: 'in', label: 'is one of' }]; - if (indexType === 'fulltext') return [...equality, { value: 'contains', label: 'contains' }]; - if (indexType === 'datetime' || indexType === 'int' || indexType === 'double') { - return [ - ...equality, - { value: 'gt', label: 'greater than' }, - { value: 'gte', label: 'at least' }, - { value: 'lt', label: 'less than' }, - { value: 'lte', label: 'at most' }, - ]; - } - return equality; -} - -function parseFilterValues(value: string, valueType: V2ValueType): unknown[] { - return value.split(',').map((part) => part.trim()).filter(Boolean).map((part) => parseTypedValue(part, valueType)); -} - -function FilterInput({ operator, value, valueType, onChange }: { - operator: string; - value: string; - valueType: V2ValueType; - onChange: ChangeEventHandler; -}) { - if (valueType === 'bool') { - return ; - } - const allowsAlternatives = operator === 'in'; - const type = allowsAlternatives - ? 'text' - : valueType === 'int64' || valueType === 'double' - ? 'number' - : valueType === 'datetime' - ? 'datetime-local' - : 'text'; - return ; -} - -function updateFilter(filters: FilterRow[], id: string, key: keyof FilterRow, value: string) { - return filters.map((filter) => filter.id === id ? { ...filter, [key]: value } : filter); -} - -function editableValue(value: unknown, valueType: V2ValueType): string { - if (value === null || value === undefined) return ''; - const text = typeof value === 'string' ? value : String(value); - if (valueType !== 'datetime') return text; - const date = new Date(text); - if (Number.isNaN(date.getTime())) return ''; - const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60_000); - return localDate.toISOString().slice(0, 16); -} - -function TypedInput({ field, value, onChange, required = false }: { - field: { valueType: V2ValueType; description: string }; - value: string; - onChange: (value: string) => void; - required?: boolean; -}) { - if (field.valueType === 'bool') { - return ; - } - return onChange(event.target.value)} />; -} - -function ActionInput({ field, value, onChange }: { - field: FlowV2ActionInputField; - value: string; - onChange: (value: string) => void; -}) { - return ; -} diff --git a/web/app/v2/contract.test.ts b/web/app/v2/contract.test.ts index 7d731c774..0b9ce4e23 100644 --- a/web/app/v2/contract.test.ts +++ b/web/app/v2/contract.test.ts @@ -12,17 +12,19 @@ import type { FlowV2Definition, } from '@superdurable/flow-definition-renderer'; import { + v1RunPath, v2ActionUserFields, v2ActionUserInput, - v2FlowPath, v2HomePath, v2ListColumns, + v2QueuePath, + v2RunPath, visibleV2Actions, } from './contract'; describe('Dex Web v2 contract helpers', () => { - it('defaults to v2 only when a JSON directory is configured', () => { - expect(v2HomePath(true)).toBe('/v2'); + it('defaults to v2 Run only when a JSON directory is configured', () => { + expect(v2HomePath(true)).toBe('/v2/run'); expect(v2HomePath(false)).toBe('/v1/flows'); }); @@ -35,10 +37,15 @@ describe('Dex Web v2 contract helpers', () => { ]); }); - it('builds Flow-ID-only v2 routes', () => { - const path = v2FlowPath('Refund Flow', 'refund/42'); - expect(path).toBe('/v2/Refund%20Flow/refund%2F42'); - expect(path).not.toContain('run'); + it('builds Flow-ID-only routes per mode', () => { + expect(v2RunPath('Refund Flow', 'refund/42')).toBe('/v2/run/Refund%20Flow/refund%2F42'); + expect(v2QueuePath('Refund Flow', 'refund/42')).toBe('/v2/queue/Refund%20Flow/refund%2F42'); + expect(v2RunPath()).toBe('/v2/run'); + expect(v2QueuePath('Refund Flow')).toBe('/v2/queue/Refund%20Flow'); + }); + + it('links a run to the v1 page that owns Timeline and controls', () => { + expect(v1RunPath('refund/42')).toBe('/v1/flows/refund%2F42'); }); it('shows only eligible Actions and hides Attribute-sourced inputs', () => { diff --git a/web/app/v2/contract.ts b/web/app/v2/contract.ts index ff10295a5..fb534ef47 100644 --- a/web/app/v2/contract.ts +++ b/web/app/v2/contract.ts @@ -11,16 +11,34 @@ import type { FlowV2Definition, } from '@superdurable/flow-definition-renderer'; +/** Run drives one run on the definition canvas; Queue clears work without a diagram. */ +export type V2Mode = 'run' | 'queue'; + export function v2HomePath(canUseV2: boolean) { - return canUseV2 ? '/v2' : '/v1/flows'; + return canUseV2 ? v2ModePath('run') : '/v1/flows'; } -export function v2FlowPath(flowType: string, flowID?: string) { - const typePath = `/v2/${encodeURIComponent(flowType)}`; +export function v2ModePath(mode: V2Mode, flowType?: string, flowID?: string) { + const modePath = `/v2/${mode}`; + if (flowType === undefined) return modePath; + const typePath = `${modePath}/${encodeURIComponent(flowType)}`; if (flowID === undefined) return typePath; return `${typePath}/${encodeURIComponent(flowID)}`; } +export function v2RunPath(flowType?: string, flowID?: string) { + return v2ModePath('run', flowType, flowID); +} + +export function v2QueuePath(flowType?: string, flowID?: string) { + return v2ModePath('queue', flowType, flowID); +} + +/** The v1 run page is where Timeline, event details, Stop and Time Travel live. */ +export function v1RunPath(flowID: string) { + return `/v1/flows/${encodeURIComponent(flowID)}`; +} + export function v2ListColumns(definition: FlowV2Definition) { return [ ...definition.indexedAttributes.map((attribute) => ({ diff --git a/web/app/v2/css/v2.css b/web/app/v2/css/v2.css index 443b4f501..3248daeba 100644 --- a/web/app/v2/css/v2.css +++ b/web/app/v2/css/v2.css @@ -221,3 +221,36 @@ color: var(--p-ink-3); font-size: 12px; } + +/* -------------------------------------------------- queue mode, no canvas */ + +.v2-shell.v2-queue { + display: flex; + flex-direction: column; +} + +.v2-queue .sq { + border-right: 1px solid var(--p-line-soft); +} + +.v2-queue .v2-case { + max-height: none; + height: 100%; + border-top: none; + padding: 16px 20px 20px; +} + +.v2-queue .sc-none { + padding: 18px 20px; +} + +.v2-seemore { + margin-left: auto; + color: var(--p-ink-2); + font-size: 11px; + white-space: nowrap; +} + +.v2-seemore:hover { + color: var(--p-ink-1); +} diff --git a/web/app/v2/queue/QueueWorkspace.tsx b/web/app/v2/queue/QueueWorkspace.tsx new file mode 100644 index 000000000..9c15daafd --- /dev/null +++ b/web/app/v2/queue/QueueWorkspace.tsx @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'; +import { v2QueuePath, v2RunPath } from '../contract'; +import '../css/v2.css'; +import { useWebCatalog } from '../WebCatalogProvider'; +import { FlowListing } from '../workspace/FlowListing'; +import { SelectedRunPanel } from '../workspace/SelectedRunPanel'; +import { useFlowSearch } from '../workspace/useFlowSearch'; + +/** + * Clearing a queue does not need the shape of the process, so this mode draws no + * canvas. "See the process" opens the same run in Run mode. + */ +export function QueueWorkspace() { + const { flowType = '', flowId = '' } = useParams(); + const navigate = useNavigate(); + const { ready, canUseV2, catalog, error } = useWebCatalog(); + const entry = catalog?.flows.find((candidate) => candidate.flowType === flowType); + const search = useFlowSearch(flowType || undefined, entry?.definition); + + if (!ready) return
Loading Dex Web…
; + if (!canUseV2) return ; + if (error) return
{error}
; + if (!catalog) return
Loading Dex Web…
; + if (catalog.flows.length === 0) { + return ( +
+
Load a valid Flow Definition Graph 2.0 file to work a queue in v2.
+
+ ); + } + if (!flowType) return ; + if (!entry) return ; + + return ( +
+
+

Work queue

+

What needs a person, read from the process itself.

+

+ No process diagram here by design: this view shows the work, not the shape of the process. +

+
+
+ + {flowId ? ( + + See the process + + )} + /> + ) : ( +

Select an item to see what it turns on and decide it.

+ )} +
+
+ ); +} diff --git a/web/app/v2/workspace/FlowListing.tsx b/web/app/v2/workspace/FlowListing.tsx new file mode 100644 index 000000000..a110ed957 --- /dev/null +++ b/web/app/v2/workspace/FlowListing.tsx @@ -0,0 +1,244 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import type { ChangeEventHandler, ReactNode } from 'react'; +import type { FlowV2Definition, V2ValueType } from '@superdurable/flow-definition-renderer'; +import { displayValue, formatDate } from '@/lib/format'; +import type { V2CatalogEntry, V2Flow } from '@/lib/types'; +import { usePreferences } from '../../providers'; +import { v2ListColumns } from '../contract'; +import { + filterFields, + filterIndexType, + filterOperators, + filterValueType, + newFilterRow, + updateFilter, + type FilterRow, +} from './filters'; +import type { FlowSearch } from './useFlowSearch'; + +export function FlowListing({ + entry, + flowTypes, + selectedFlowID, + search, + headerNote, + onSelectFlowType, + onSelectRun, + children, +}: { + entry: V2CatalogEntry; + flowTypes: V2CatalogEntry[]; + selectedFlowID: string; + search: FlowSearch; + headerNote: string; + onSelectFlowType: (flowType: string) => void; + onSelectRun: (flowID: string) => void; + children?: ReactNode; +}) { + const { timezone } = usePreferences(); + const { filters, setFilters, flows, loading, searchError } = search; + const fields = filterFields(entry.definition); + const columns = v2ListColumns(entry.definition); + return ( + <> +
+ {entry.flowType} + {headerNote} +
+ {flowTypes.length > 1 && ( +
+ Flow type + {flowTypes.map((candidate) => ( + + ))} +
+ )} +
+ {filters.map((filter) => ( + + ))} +
+
+ + +
+ {searchError &&

{searchError}

} +
    + {flows.map((flow) => ( +
  1. + +
  2. + ))} + {!loading && flows.length === 0 &&
  3. No matching Flows
  4. } +
+
+ + Page {search.page + 1} + +
+ {children} + + ); +} + +function FlowColumns({ + columns, + flow, +}: { + columns: ReturnType; + flow: V2Flow; +}) { + return ( + + {columns.map((column) => ( + + {column.source === 'summary' && flow.summaryError + ? 'Unavailable' + : displayValue(column.source === 'indexed' + ? flow.indexedAttributes[column.key] + : flow.summary?.[column.key])} + + ))} + + ); +} + +function FilterRowControls({ + definition, + fields, + filter, + filters, + setFilters, +}: { + definition: FlowV2Definition; + fields: { key: string; label: string }[]; + filter: FilterRow; + filters: FilterRow[]; + setFilters: (filters: FilterRow[]) => void; +}) { + return ( +
+ + + setFilters( + updateFilter(filters, filter.id, 'value', event.target.value), + )} + /> + +
+ ); +} + +function FilterInput({ operator, value, valueType, onChange }: { + operator: string; + value: string; + valueType: V2ValueType; + onChange: ChangeEventHandler; +}) { + if (valueType === 'bool') { + return ( + + ); + } + const allowsAlternatives = operator === 'in'; + const type = allowsAlternatives + ? 'text' + : valueType === 'int64' || valueType === 'double' + ? 'number' + : valueType === 'datetime' + ? 'datetime-local' + : 'text'; + return ( + + ); +} diff --git a/web/app/v2/workspace/SelectedRunPanel.tsx b/web/app/v2/workspace/SelectedRunPanel.tsx new file mode 100644 index 000000000..ba0d24611 --- /dev/null +++ b/web/app/v2/workspace/SelectedRunPanel.tsx @@ -0,0 +1,262 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { useCallback, useEffect, useState, type ReactNode } from 'react'; +import type { + FlowV2Action, + FlowV2ActionInputField, + FlowV2Definition, + V2ValueType, +} from '@superdurable/flow-definition-renderer'; +import { displayValue } from '@/lib/format'; +import { readResponseJSON } from '@/lib/http'; +import type { V2Display } from '@/lib/types'; +import { parseTypedValue, v2ActionUserFields, v2ActionUserInput, visibleV2Actions } from '../contract'; + +export function SelectedRunPanel({ + flowType, + flowId, + definition, + footer, +}: { + flowType: string; + flowId: string; + definition: FlowV2Definition; + footer?: ReactNode; +}) { + const [result, setResult] = useState(null); + const [error, setError] = useState(''); + const [busyKey, setBusyKey] = useState(''); + const [editingKey, setEditingKey] = useState(''); + const [editValue, setEditValue] = useState(''); + const [fieldErrors, setFieldErrors] = useState>({}); + const [actionValues, setActionValues] = useState>>({}); + + const loadDisplay = useCallback(async () => { + setError(''); + try { + const query = new URLSearchParams({ flowType, flowId }); + const response = await fetch(`/api/v2/display?${query}`); + setResult(await readResponseJSON(response)); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : 'Display failed to load'); + } + }, [flowId, flowType]); + + useEffect(() => { void loadDisplay(); }, [loadDisplay]); + + async function saveField(attributeKey: string, valueType: V2ValueType) { + setBusyKey(attributeKey); + setError(''); + setFieldErrors((current) => ({ ...current, [attributeKey]: '' })); + try { + const response = await fetch('/api/v2/display', { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + flowType, flowId, attributeKey, + value: parseTypedValue(editValue, valueType), + }), + }); + await readResponseJSON(response); + setEditingKey(''); + await loadDisplay(); + } catch (saveError) { + setFieldErrors((current) => ({ + ...current, + [attributeKey]: saveError instanceof Error ? saveError.message : 'Edit failed', + })); + } finally { + setBusyKey(''); + } + } + + async function invokeAction(action: FlowV2Action) { + setBusyKey(action.rpcName); + setError(''); + try { + const input = v2ActionUserInput(action, actionValues[action.rpcName] ?? {}); + const response = await fetch('/api/v2/actions', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + flowType, flowId, rpcName: action.rpcName, + input, attributeSnapshot: result?.attributeSnapshot ?? {}, + }), + }); + await readResponseJSON(response); + await loadDisplay(); + } catch (actionError) { + setError(actionError instanceof Error ? actionError.message : 'Action failed'); + } finally { + setBusyKey(''); + } + } + + return ( +
+
+ {flowId} + {result && {result.flowStatus}} + {footer} +
+ {error &&

{error}

} + {!result && !error &&

Loading Display…

} + {result && ( + <> +
+
Display
+
+ {definition.display.fields.map((field) => { + const isEditing = editingKey === field.attributeKey; + return ( +
+
{field.description}
+
+ {isEditing ? ( + <> + + + + {fieldErrors[field.attributeKey] && ( + {fieldErrors[field.attributeKey]} + )} + + ) : ( + <> + {displayValue(result.display[field.attributeKey])} + {field.editable && result.isActive && ( + + )} + + )} +
+
+ ); + })} +
+
+
+
Actions
+ {visibleV2Actions(definition.actions, result.eligibleActions).map((action) => { + const userFields = v2ActionUserFields(action); + return ( +
{ + event.preventDefault(); + void invokeAction(action); + }} + > + {userFields.map((field) => ( + + ))} + +
+ ); + })} + {definition.actions.length > 0 && result.eligibleActions.length === 0 && ( +

No Actions are available in the current state.

+ )} +
+ + )} +
+ ); +} + +function editableValue(value: unknown, valueType: V2ValueType): string { + if (value === null || value === undefined) return ''; + const text = typeof value === 'string' ? value : String(value); + if (valueType !== 'datetime') return text; + const date = new Date(text); + if (Number.isNaN(date.getTime())) return ''; + const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60_000); + return localDate.toISOString().slice(0, 16); +} + +function TypedInput({ field, value, onChange, required = false }: { + field: { valueType: V2ValueType; description: string }; + value: string; + onChange: (value: string) => void; + required?: boolean; +}) { + if (field.valueType === 'bool') { + return ( + + ); + } + return ( + onChange(event.target.value)} + /> + ); +} + +function ActionInput({ field, value, onChange }: { + field: FlowV2ActionInputField; + value: string; + onChange: (value: string) => void; +}) { + return ; +} diff --git a/web/app/v2/workspace/filters.ts b/web/app/v2/workspace/filters.ts new file mode 100644 index 000000000..962756f86 --- /dev/null +++ b/web/app/v2/workspace/filters.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import type { FlowV2Definition, V2ValueType } from '@superdurable/flow-definition-renderer'; +import { parseTypedValue } from '../contract'; + +export interface FilterRow { + id: string; + field: string; + operator: string; + value: string; +} + +export function filterFields(definition: FlowV2Definition) { + return [ + { key: 'flowId', label: 'Flow ID' }, + { key: 'executionStatus', label: 'Execution status' }, + { key: 'startTime', label: 'Start time' }, + { key: 'closeTime', label: 'Close time' }, + ...definition.indexedAttributes.map((attribute) => ({ + key: attribute.attributeKey, + label: attribute.description, + })), + ]; +} + +export function filterValueType(field: string, definition: FlowV2Definition): V2ValueType { + if (field === 'startTime' || field === 'closeTime') return 'datetime'; + if (field === 'flowId' || field === 'executionStatus') return 'string'; + return definition.indexedAttributes + .find((attribute) => attribute.attributeKey === field)?.valueType ?? 'string'; +} + +export function filterIndexType(field: string, definition: FlowV2Definition) { + if (field === 'startTime' || field === 'closeTime') return 'datetime'; + if (field === 'flowId' || field === 'executionStatus') return 'keyword'; + return definition.indexedAttributes + .find((attribute) => attribute.attributeKey === field)?.indexType ?? 'keyword'; +} + +export function filterOperators( + indexType: FlowV2Definition['indexedAttributes'][number]['indexType'], +) { + const equality = [{ value: 'eq', label: 'equals' }, { value: 'in', label: 'is one of' }]; + if (indexType === 'fulltext') return [...equality, { value: 'contains', label: 'contains' }]; + if (indexType === 'datetime' || indexType === 'int' || indexType === 'double') { + return [ + ...equality, + { value: 'gt', label: 'greater than' }, + { value: 'gte', label: 'at least' }, + { value: 'lt', label: 'less than' }, + { value: 'lte', label: 'at most' }, + ]; + } + return equality; +} + +export function parseFilterValues(value: string, valueType: V2ValueType): unknown[] { + return value + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => parseTypedValue(part, valueType)); +} + +export function updateFilter( + filters: FilterRow[], + id: string, + key: keyof FilterRow, + value: string, +) { + return filters.map((filter) => (filter.id === id ? { ...filter, [key]: value } : filter)); +} + +export function newFilterRow(field: string, operator: string, value: string): FilterRow { + return { id: `${Date.now()}-${Math.random()}`, field, operator, value }; +} diff --git a/web/app/v2/workspace/useFlowSearch.ts b/web/app/v2/workspace/useFlowSearch.ts new file mode 100644 index 000000000..9c9d2e0b4 --- /dev/null +++ b/web/app/v2/workspace/useFlowSearch.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { useCallback, useEffect, useState } from 'react'; +import type { FlowV2Definition } from '@superdurable/flow-definition-renderer'; +import { readResponseJSON } from '@/lib/http'; +import type { V2Flow, V2SearchResult } from '@/lib/types'; +import { filterValueType, parseFilterValues, type FilterRow } from './filters'; + +export interface FlowSearch { + filters: FilterRow[]; + setFilters: (filters: FilterRow[]) => void; + flows: V2Flow[]; + loading: boolean; + searchError: string; + page: number; + hasNextPage: boolean; + runSearch: () => void; + goToNextPage: () => void; + goToPreviousPage: () => void; +} + +export function useFlowSearch( + flowType: string | undefined, + definition: FlowV2Definition | undefined, + initialFilters: FilterRow[] = [], +): FlowSearch { + const [filters, setFilters] = useState(initialFilters); + const [flows, setFlows] = useState([]); + const [loading, setLoading] = useState(false); + const [searchError, setSearchError] = useState(''); + const [nextPageToken, setNextPageToken] = useState(''); + const [pageTokens, setPageTokens] = useState(['']); + const [page, setPage] = useState(0); + + const executeSearch = useCallback(async (token = '', nextPage = 0) => { + if (!flowType || !definition) return; + setLoading(true); + setSearchError(''); + try { + const response = await fetch('/api/v2/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + flowType, + filters: filters.map((filter) => ({ + field: filter.field, + operator: filter.operator, + values: parseFilterValues(filter.value, filterValueType(filter.field, definition)), + })), + pageSize: 50, + nextPageToken: token, + }), + }); + const result = await readResponseJSON(response); + setFlows(result.flows); + setNextPageToken(result.nextPageToken); + setPage(nextPage); + } catch (failedSearch) { + setSearchError(failedSearch instanceof Error ? failedSearch.message : 'Search failed'); + setFlows([]); + } finally { + setLoading(false); + } + }, [definition, filters, flowType]); + + // Re-run on Flow type only; editing a filter should not fire a request per keystroke. + useEffect(() => { + if (flowType && definition) void executeSearch(); + }, [definition, flowType]); + + const runSearch = useCallback(() => { + setPageTokens(['']); + void executeSearch(); + }, [executeSearch]); + + const goToNextPage = useCallback(() => { + setPageTokens([...pageTokens, nextPageToken]); + void executeSearch(nextPageToken, page + 1); + }, [executeSearch, nextPageToken, page, pageTokens]); + + const goToPreviousPage = useCallback(() => { + const previous = page - 1; + const tokens = pageTokens.slice(0, -1); + setPageTokens(tokens); + void executeSearch(tokens[previous] ?? '', previous); + }, [executeSearch, page, pageTokens]); + + return { + filters, + setFilters, + flows, + loading, + searchError, + page, + hasNextPage: nextPageToken !== '', + runSearch, + goToNextPage, + goToPreviousPage, + }; +} From 0a8f1fe5f25bd9d8e0414c20c157030ad863e6fa Mon Sep 17 00:00:00 2001 From: zzheng Date: Sat, 19 Sep 2026 15:18:39 -0700 Subject: [PATCH 02/21] Tell the work queue's four answers apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The queue blanked its rows whenever a refresh failed, so "nothing is open" and "we cannot see the process" rendered identically while calling for opposite actions. Fold reads through a held value instead: a failed refresh keeps the rows that were true a moment ago and says they are stale. A run whose owning worker has exited is a third answer again — permanently unreadable while the server stays healthy. Measured rather than assumed: that surfaces as FailedPrecondition, the same code the server uses for "Flow is not active", and a closed run produces a byte-identical error. Neither the code nor the message separates them, so stranded is (running run, dial failure) and is only knowable once somebody opens the run. The row is then marked, not dropped, because it is still unresolved. readResponseJSON parsed grpcCode and discarded it; it now reaches the caller on a DexAPIError without altering any existing message. Queue mode narrows to open runs through a real server-side filter rather than dropping rows after the server paginated, and states its own scope: which status it filtered on, that counts describe one page, and that Action availability is decided per run when opened. Deliberately not here: no attention kinds, no tally, and no claim that a person is needed. A dex:when condition says when an Action may be offered, not who must act, and the search index it would be evaluated against is eventually consistent. That needs a declared role, which the contract does not yet carry. --- web/app/v2/queue/QueueWorkspace.tsx | 56 ++++++++-- web/app/v2/queue/copy.ts | 46 ++++++++ web/app/v2/queue/liveness.test.ts | 128 ++++++++++++++++++++++ web/app/v2/queue/liveness.ts | 85 ++++++++++++++ web/app/v2/workspace/FlowListing.tsx | 14 ++- web/app/v2/workspace/SelectedRunPanel.tsx | 25 ++++- web/app/v2/workspace/useFlowSearch.ts | 21 ++-- web/lib/http.test.ts | 28 ++++- web/lib/http.ts | 25 ++++- 9 files changed, 404 insertions(+), 24 deletions(-) create mode 100644 web/app/v2/queue/copy.ts create mode 100644 web/app/v2/queue/liveness.test.ts create mode 100644 web/app/v2/queue/liveness.ts diff --git a/web/app/v2/queue/QueueWorkspace.tsx b/web/app/v2/queue/QueueWorkspace.tsx index 9c15daafd..17310797a 100644 --- a/web/app/v2/queue/QueueWorkspace.tsx +++ b/web/app/v2/queue/QueueWorkspace.tsx @@ -6,24 +6,39 @@ // // SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 +import { useCallback, useState } from 'react'; import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'; import { v2QueuePath, v2RunPath } from '../contract'; import '../css/v2.css'; import { useWebCatalog } from '../WebCatalogProvider'; import { FlowListing } from '../workspace/FlowListing'; +import { newFilterRow } from '../workspace/filters'; import { SelectedRunPanel } from '../workspace/SelectedRunPanel'; import { useFlowSearch } from '../workspace/useFlowSearch'; +import { QUEUE_COPY } from './copy'; +import { openFlowStatusLabel } from './liveness'; /** * Clearing a queue does not need the shape of the process, so this mode draws no * canvas. "See the process" opens the same run in Run mode. + * + * The open-work filter is a real server-side filter row rather than a client-side drop, so + * paging stays correct and the reader can see and change what was narrowed. */ export function QueueWorkspace() { const { flowType = '', flowId = '' } = useParams(); const navigate = useNavigate(); const { ready, canUseV2, catalog, error } = useWebCatalog(); const entry = catalog?.flows.find((candidate) => candidate.flowType === flowType); - const search = useFlowSearch(flowType || undefined, entry?.definition); + const search = useFlowSearch(flowType || undefined, entry?.definition, [ + newFilterRow('executionStatus', 'eq', openFlowStatusLabel()), + ]); + const [strandedFlowIDs, setStrandedFlowIDs] = useState>(() => new Set()); + const rememberStranded = useCallback((strandedFlowID: string) => { + setStrandedFlowIDs((prior) => ( + prior.has(strandedFlowID) ? prior : new Set([...prior, strandedFlowID]) + )); + }, []); if (!ready) return
Loading Dex Web…
; if (!canUseV2) return ; @@ -39,14 +54,13 @@ export function QueueWorkspace() { if (!flowType) return ; if (!entry) return ; + const selectedFlow = search.flows.find((flow) => flow.flowId === flowId); return (
-

Work queue

-

What needs a person, read from the process itself.

-

- No process diagram here by design: this view shows the work, not the shape of the process. -

+

{QUEUE_COPY.appName}

+

{QUEUE_COPY.strapline}

+

{QUEUE_COPY.noGraph}

); } + +/** Four states, never collapsed: an empty page and an unreachable process call for opposite actions. */ +function QueueScope({ search }: { search: ReturnType }) { + const { liveness, flows } = search; + const headline = liveness === 'loading' + ? QUEUE_COPY.loading + : liveness === 'unreachable' + ? QUEUE_COPY.unreachable + : liveness === 'stale' + ? QUEUE_COPY.stale + : flows.length === 0 + ? QUEUE_COPY.clear + : QUEUE_COPY.onThisPage(flows.length); + return ( +

+ {headline} + {QUEUE_COPY.openOnly(openFlowStatusLabel())} + {QUEUE_COPY.actionsProvenance} + {search.searchError && {search.searchError}} +

+ ); +} diff --git a/web/app/v2/queue/copy.ts b/web/app/v2/queue/copy.ts new file mode 100644 index 000000000..46e03e56b --- /dev/null +++ b/web/app/v2/queue/copy.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +/** + * Every string the queue can say, in one table so the wording can be audited. + * + * Nothing here claims a person is needed. A Flow Definition Graph declares when an Action + * may be offered, not who must act, so the queue says what it read and where it read it. + */ +export const QUEUE_COPY = { + appName: 'Work queue', + strapline: 'Open runs of one Flow type, read from the running process.', + noGraph: 'No process diagram here by design: this view shows the work, not the shape of the process.', + + openOnly(statusLabel: string): string { + return `Showing runs with execution status ${statusLabel}. Closed runs are not work, so they are filtered out — edit the filter to see them.`; + }, + + /** Counts describe the page, never the queue: the server paginates and we do not total it. */ + onThisPage(count: number): string { + return count === 1 ? '1 run on this page' : `${count} runs on this page`; + }, + clear: 'Nothing open on this page.', + loading: 'Asking the process…', + unreachable: 'Cannot reach the process, so this list is not the whole picture.', + stale: 'Showing the last answer — the process did not respond just now.', + + /** Actions are gated on live Attributes, so the list cannot promise one is available. */ + actionsProvenance: 'Which Actions are available is decided per run when you open it.', + + selectPrompt: 'Select a run to see what it reports and which Actions are available.', + seeProcess: 'See the process', + + /** + * Dex routes a Flow RPC to the worker that owns the run, so this run is unreadable for + * good while the server itself is fine. + */ + stranded: + 'This one cannot be read or acted on: it was started by a worker that is no longer running, so nothing can reach it. The process itself is fine.', + strandedRow: 'cannot be reached', +} as const; diff --git a/web/app/v2/queue/liveness.test.ts b/web/app/v2/queue/liveness.test.ts new file mode 100644 index 000000000..94125e6b1 --- /dev/null +++ b/web/app/v2/queue/liveness.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { describe, expect, it } from 'vitest'; +import { DexAPIError } from '@/lib/http'; +import { + RUNNING_FLOW_STATUS_CODE, + absorb, + classifyReadFailure, + isOpenFlowStatusCode, + isStrandedRunFailure, + nothingHeld, + openFlowStatusLabel, + type Held, +} from './liveness'; + +const COMPLETED_FLOW_STATUS_CODE = 2; + +// Measured against a live server: a dead worker and a closed run share this code and status. +const workerGone = () => new DexAPIError( + 'connection error: desc = "transport: Error while dialing: dial tcp 127.0.0.1:8873: connect: connection refused"', + 409, + 9, +); +const inactiveFlow = () => new DexAPIError('Flow is not active', 409, 9); + +describe('absorb', () => { + it('starts as loading, which is not the same as an empty answer', () => { + expect(nothingHeld()).toEqual({ value: null, liveness: 'loading', reason: null }); + }); + + it('keeps the previous answer and marks it stale when a refresh fails', () => { + const ok = absorb(nothingHeld(), { state: 'ok', value: ['a'] }); + const failed = absorb(ok, { state: 'unreachable', reason: 'timeout' }); + expect(failed.value).toEqual(['a']); + expect(failed.liveness).toBe('stale'); + expect(failed.reason).toBe('timeout'); + }); + + it('reports unreachable with no value when nothing ever arrived', () => { + const failed = absorb(nothingHeld(), { state: 'unreachable', reason: 'refused' }); + expect(failed.value).toBeNull(); + expect(failed.liveness).toBe('unreachable'); + }); + + it('distinguishes an empty success from a failure', () => { + const empty = absorb(nothingHeld(), { state: 'ok', value: [] }); + expect(empty.liveness).toBe('ok'); + expect(empty.value).toEqual([]); + expect(empty.reason).toBeNull(); + }); + + it('does not call a stranded run stale, because nothing is coming back', () => { + const ok = absorb(nothingHeld(), { state: 'ok', value: ['a'] }); + const stranded = absorb(ok, { state: 'stranded', reason: 'worker gone' }); + expect(stranded.liveness).toBe('stranded'); + expect(stranded.value).toEqual(['a']); + }); + + it('clears a stale reason once a read succeeds again', () => { + const stale = absorb( + { value: ['a'], liveness: 'stale', reason: 'timeout' }, + { state: 'ok', value: ['b'] }, + ); + expect(stale).toEqual({ value: ['b'], liveness: 'ok', reason: null }); + }); + + it('is pure: it neither mutates the prior nor varies between identical calls', () => { + const prior: Held = { value: ['a'], liveness: 'ok', reason: null }; + const frozen = { ...prior, value: [...prior.value as string[]] }; + const first = absorb(prior, { state: 'unreachable', reason: 'timeout' }); + const second = absorb(prior, { state: 'unreachable', reason: 'timeout' }); + expect(first).toEqual(second); + expect(prior).toEqual(frozen); + }); +}); + +describe('isStrandedRunFailure', () => { + it('calls a running run whose worker exited stranded', () => { + expect(isStrandedRunFailure(workerGone(), RUNNING_FLOW_STATUS_CODE)).toBe(true); + }); + + it('does not call a closed run stranded, even though its Display is equally unreadable', () => { + expect(isStrandedRunFailure(workerGone(), COMPLETED_FLOW_STATUS_CODE)).toBe(false); + }); + + it('does not treat "Flow is not active" as stranded, though it shares the gRPC code', () => { + expect(isStrandedRunFailure(inactiveFlow(), RUNNING_FLOW_STATUS_CODE)).toBe(false); + }); + + it('ignores other gRPC codes and plain errors', () => { + const notFound = new DexAPIError('workflow not found for ID: nope', 404, 5); + expect(isStrandedRunFailure(notFound, RUNNING_FLOW_STATUS_CODE)).toBe(false); + expect(isStrandedRunFailure(new Error('network down'), RUNNING_FLOW_STATUS_CODE)).toBe(false); + }); + + it('is not stranded when the run status is unknown', () => { + expect(isStrandedRunFailure(workerGone(), undefined)).toBe(false); + }); +}); + +describe('classifyReadFailure', () => { + it('routes a stranded run and a general failure to different outcomes', () => { + expect(classifyReadFailure(workerGone(), RUNNING_FLOW_STATUS_CODE).state).toBe('stranded'); + expect(classifyReadFailure(workerGone(), COMPLETED_FLOW_STATUS_CODE).state).toBe('unreachable'); + const failure = classifyReadFailure(new Error('boom'), RUNNING_FLOW_STATUS_CODE); + expect(failure.state === 'ok' ? '' : failure.reason).toBe('boom'); + }); +}); + +describe('open flow status', () => { + it('treats only Running as open work', () => { + expect(isOpenFlowStatusCode(RUNNING_FLOW_STATUS_CODE)).toBe(true); + for (const closed of [0, 2, 3, 4, 5, 6, 7]) { + expect(isOpenFlowStatusCode(closed)).toBe(false); + } + expect(isOpenFlowStatusCode(undefined)).toBe(false); + }); + + it('names the status with the label the search filter accepts', () => { + expect(openFlowStatusLabel()).toBe('Running'); + }); +}); diff --git a/web/app/v2/queue/liveness.ts b/web/app/v2/queue/liveness.ts new file mode 100644 index 000000000..5482db4a8 --- /dev/null +++ b/web/app/v2/queue/liveness.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { DexAPIError } from '@/lib/http'; +import { FLOW_STATUS } from '@/lib/types'; + +/** + * An empty answer, a failed refresh over a good answer, and never having asked are + * three different things. Collapsing them sends a reader to the wrong place. + */ +export type Liveness = 'loading' | 'ok' | 'stale' | 'unreachable' | 'stranded'; + +export interface Held { + readonly value: T | null; + readonly liveness: Liveness; + readonly reason: string | null; +} + +export type ReadOutcome = + | { readonly state: 'ok'; readonly value: T } + | { readonly state: 'unreachable'; readonly reason: string } + | { readonly state: 'stranded'; readonly reason: string }; + +export function nothingHeld(): Held { + return { value: null, liveness: 'loading', reason: null }; +} + +export function absorb(prior: Held, next: ReadOutcome): Held { + if (next.state === 'ok') return { value: next.value, liveness: 'ok', reason: null }; + // A stranded run keeps its own liveness: nothing is coming back, so it is not stale. + if (next.state === 'stranded') { + return { value: prior.value, liveness: 'stranded', reason: next.reason }; + } + // Showing a true-a-moment-ago answer late beats blanking it; reporting it as fresh is worse. + if (prior.value !== null) return { value: prior.value, liveness: 'stale', reason: next.reason }; + return { value: null, liveness: 'unreachable', reason: next.reason }; +} + +export function readFailureReason(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Dex routes a Flow RPC to the worker that owns the run, so a worker that exited makes + * one run permanently unreadable while the server stays healthy. + * + * Measured, not assumed: that surfaces as FailedPrecondition, which the same code also + * uses for "Flow is not active". A closed run is therefore excluded by status — its + * Display is equally unreadable, but nothing is owed on it. + */ +const GRPC_FAILED_PRECONDITION = 9; +const INACTIVE_FLOW_MESSAGE = 'Flow is not active'; + +export function isStrandedRunFailure(error: unknown, flowStatusCode: number | undefined): boolean { + if (!(error instanceof DexAPIError)) return false; + if (error.grpcCode !== GRPC_FAILED_PRECONDITION) return false; + if (error.message.trim() === INACTIVE_FLOW_MESSAGE) return false; + return isOpenFlowStatusCode(flowStatusCode); +} + +export function classifyReadFailure( + error: unknown, + flowStatusCode: number | undefined, +): ReadOutcome { + const reason = readFailureReason(error); + return isStrandedRunFailure(error, flowStatusCode) + ? { state: 'stranded', reason } + : { state: 'unreachable', reason }; +} + +/** Running is the only non-terminal status. Gate on the code: the Go and TS labels disagree. */ +export const RUNNING_FLOW_STATUS_CODE = 1; + +export function isOpenFlowStatusCode(flowStatusCode: number | undefined): boolean { + return flowStatusCode === RUNNING_FLOW_STATUS_CODE; +} + +export function openFlowStatusLabel(): string { + return FLOW_STATUS[RUNNING_FLOW_STATUS_CODE]; +} diff --git a/web/app/v2/workspace/FlowListing.tsx b/web/app/v2/workspace/FlowListing.tsx index a110ed957..35408a437 100644 --- a/web/app/v2/workspace/FlowListing.tsx +++ b/web/app/v2/workspace/FlowListing.tsx @@ -12,6 +12,7 @@ import { displayValue, formatDate } from '@/lib/format'; import type { V2CatalogEntry, V2Flow } from '@/lib/types'; import { usePreferences } from '../../providers'; import { v2ListColumns } from '../contract'; +import { QUEUE_COPY } from '../queue/copy'; import { filterFields, filterIndexType, @@ -29,6 +30,8 @@ export function FlowListing({ selectedFlowID, search, headerNote, + scope, + strandedFlowIDs, onSelectFlowType, onSelectRun, children, @@ -38,6 +41,10 @@ export function FlowListing({ selectedFlowID: string; search: FlowSearch; headerNote: string; + /** What this list is and is not showing. Rendered above the filters. */ + scope?: ReactNode; + /** Runs this session found unreachable. Marked, not dropped: still unresolved work. */ + strandedFlowIDs?: ReadonlySet; onSelectFlowType: (flowType: string) => void; onSelectRun: (flowID: string) => void; children?: ReactNode; @@ -68,6 +75,7 @@ export function FlowListing({ ))} )} + {scope}
{filters.map((filter) => ( ))} - {!loading && flows.length === 0 &&
  • No matching Flows
  • } + {!loading && flows.length === 0 && !scope &&
  • No matching Flows
  • }
    + {isStranded &&

    {QUEUE_COPY.stranded}

    } {error &&

    {error}

    } - {!result && !error &&

    Loading Display…

    } - {result && ( + {!result && !error && !isStranded &&

    {QUEUE_COPY.loading}

    } + {result && !isStranded && ( <>
    Display
    diff --git a/web/app/v2/workspace/useFlowSearch.ts b/web/app/v2/workspace/useFlowSearch.ts index 9c9d2e0b4..8961e4506 100644 --- a/web/app/v2/workspace/useFlowSearch.ts +++ b/web/app/v2/workspace/useFlowSearch.ts @@ -10,6 +10,7 @@ import { useCallback, useEffect, useState } from 'react'; import type { FlowV2Definition } from '@superdurable/flow-definition-renderer'; import { readResponseJSON } from '@/lib/http'; import type { V2Flow, V2SearchResult } from '@/lib/types'; +import { absorb, nothingHeld, readFailureReason, type Liveness } from '../queue/liveness'; import { filterValueType, parseFilterValues, type FilterRow } from './filters'; export interface FlowSearch { @@ -18,6 +19,8 @@ export interface FlowSearch { flows: V2Flow[]; loading: boolean; searchError: string; + /** What the reader currently knows, which is not what the last request returned. */ + liveness: Liveness; page: number; hasNextPage: boolean; runSearch: () => void; @@ -31,9 +34,8 @@ export function useFlowSearch( initialFilters: FilterRow[] = [], ): FlowSearch { const [filters, setFilters] = useState(initialFilters); - const [flows, setFlows] = useState([]); + const [held, setHeld] = useState(() => nothingHeld()); const [loading, setLoading] = useState(false); - const [searchError, setSearchError] = useState(''); const [nextPageToken, setNextPageToken] = useState(''); const [pageTokens, setPageTokens] = useState(['']); const [page, setPage] = useState(0); @@ -41,7 +43,6 @@ export function useFlowSearch( const executeSearch = useCallback(async (token = '', nextPage = 0) => { if (!flowType || !definition) return; setLoading(true); - setSearchError(''); try { const response = await fetch('/api/v2/search', { method: 'POST', @@ -58,12 +59,15 @@ export function useFlowSearch( }), }); const result = await readResponseJSON(response); - setFlows(result.flows); + setHeld((prior) => absorb(prior, { state: 'ok', value: result.flows })); setNextPageToken(result.nextPageToken); setPage(nextPage); } catch (failedSearch) { - setSearchError(failedSearch instanceof Error ? failedSearch.message : 'Search failed'); - setFlows([]); + // Keep the rows that were true a moment ago; absorb marks them stale. + setHeld((prior) => absorb(prior, { + state: 'unreachable', + reason: readFailureReason(failedSearch), + })); } finally { setLoading(false); } @@ -94,9 +98,10 @@ export function useFlowSearch( return { filters, setFilters, - flows, + flows: held.value ?? [], loading, - searchError, + searchError: held.liveness === 'stale' || held.liveness === 'unreachable' ? held.reason ?? '' : '', + liveness: held.liveness, page, hasNextPage: nextPageToken !== '', runSearch, diff --git a/web/lib/http.test.ts b/web/lib/http.test.ts index 78bbd1f13..5669f69da 100644 --- a/web/lib/http.test.ts +++ b/web/lib/http.test.ts @@ -7,7 +7,7 @@ // SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 import { describe, expect, it } from 'vitest'; -import { isTransientGatewayResponse, readResponseJSON } from './http'; +import { DexAPIError, isTransientGatewayResponse, readResponseJSON } from './http'; describe('isTransientGatewayResponse', () => { it('recognizes temporary gateway responses', () => { @@ -59,6 +59,32 @@ describe('readResponseJSON', () => { 'Dex API returned a non-JSON response (HTTP 500) for /api/flows/search: Error: connect ECONNREFUSED 127.0.0.1:8802', ); }); + + it('carries the gRPC code and HTTP status a Dex error body reported', async () => { + const response = jsonResponse({ error: 'Flow is not active', grpcCode: 9 }, 409); + const failure = await readResponseJSON(response).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(DexAPIError); + expect(failure).toBeInstanceOf(Error); + const dexFailure = failure as DexAPIError; + expect(dexFailure.message).toBe('Flow is not active'); + expect(dexFailure.grpcCode).toBe(9); + expect(dexFailure.httpStatus).toBe(409); + }); + + it('leaves the gRPC code undefined rather than zero when the body omits it', async () => { + const response = jsonResponse({ error: 'Flow not found' }, 404); + const failure = (await readResponseJSON(response).catch((error: unknown) => error)) as DexAPIError; + expect(failure.grpcCode).toBeUndefined(); + expect(failure.httpStatus).toBe(404); + }); + + it('keeps the generated message when the body carries a code but no error text', async () => { + const response = jsonResponse({ grpcCode: 14 }, 502); + Object.defineProperty(response, 'url', { value: 'http://127.0.0.1:5173/api/v2/display' }); + const failure = (await readResponseJSON(response).catch((error: unknown) => error)) as DexAPIError; + expect(failure.message).toBe('Dex API returned an error (HTTP 502) for /api/v2/display'); + expect(failure.grpcCode).toBe(14); + }); }); function jsonResponse(body: unknown, status: number): Response { diff --git a/web/lib/http.ts b/web/lib/http.ts index 29433b81f..bdc0912e5 100644 --- a/web/lib/http.ts +++ b/web/lib/http.ts @@ -6,10 +6,31 @@ // // SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 +/** + * Carries the gRPC code so a caller can tell one unreadable run apart from an + * unreachable server. The code is absent, never 0, when the body omitted it. + */ +export class DexAPIError extends Error { + readonly httpStatus: number; + readonly grpcCode: number | undefined; + + constructor(message: string, httpStatus: number, grpcCode?: number) { + super(message); + this.name = 'DexAPIError'; + this.httpStatus = httpStatus; + this.grpcCode = grpcCode; + Object.setPrototypeOf(this, DexAPIError.prototype); + } +} + export async function readResponseJSON(response: Response): Promise { - const data = await parseResponseJSON(response); + const data = await parseResponseJSON(response); if (!response.ok) { - throw new Error(data.error?.trim() || failedRequestMessage(response)); + throw new DexAPIError( + data.error?.trim() || failedRequestMessage(response), + response.status, + typeof data.grpcCode === 'number' ? data.grpcCode : undefined, + ); } return data; } From 226fc9303fec081c31a91fe5197269a05f38ad84 Mon Sep 17 00:00:00 2001 From: zzheng Date: Sat, 19 Sep 2026 18:07:21 -0700 Subject: [PATCH 03/21] Fix what the two mode commits got wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects, all found by auditing the branch rather than by a test. A dead worker was only explained in Queue mode. Run rendered SelectedRunPanel without the run's status, so isStrandedRunFailure could never fire there and an operator saw a raw gRPC dial error instead of the sentence that says the process itself is fine. Both modes now pass the status and remember what they learn, so the row is marked in either one. The Display panel ignored the liveness fold the list beside it was already using, so half the view could tell a stale answer from a fresh one and half could not. It now holds its value the same way, and a write that fails is kept separate from a read that went stale — a rejected Action does not make the fields old. The queue's scope sentence was hardcoded while the filter row it described was user-deletable, so deleting the filter left the view claiming a scope it no longer had. It is now derived from the filters that are actually applied, and says plainly when nothing is narrowing the list. A failed search printed its reason twice. Also: nineteen classes ported from the prototype matched no markup. The ones whose markup this change restores are now used; the rest are deleted and will return with the commits that introduce theirs. sc-actions was the inverse — a class in use that nothing styled. --- web/app/v2/RunWorkspace.tsx | 6 + web/app/v2/css/listing.css | 145 +++------------------- web/app/v2/queue/QueueWorkspace.tsx | 27 ++-- web/app/v2/queue/copy.ts | 9 +- web/app/v2/workspace/FlowListing.tsx | 3 + web/app/v2/workspace/SelectedRunPanel.tsx | 41 +++--- web/app/v2/workspace/filters.test.ts | 61 +++++++++ web/app/v2/workspace/filters.ts | 25 ++++ web/app/v2/workspace/useStrandedRuns.ts | 23 ++++ 9 files changed, 179 insertions(+), 161 deletions(-) create mode 100644 web/app/v2/workspace/filters.test.ts create mode 100644 web/app/v2/workspace/useStrandedRuns.ts diff --git a/web/app/v2/RunWorkspace.tsx b/web/app/v2/RunWorkspace.tsx index 8445d49c9..d7b6c010e 100644 --- a/web/app/v2/RunWorkspace.tsx +++ b/web/app/v2/RunWorkspace.tsx @@ -23,6 +23,7 @@ import { useWebCatalog } from './WebCatalogProvider'; import { FlowListing } from './workspace/FlowListing'; import { SelectedRunPanel } from './workspace/SelectedRunPanel'; import { useFlowSearch } from './workspace/useFlowSearch'; +import { useStrandedRuns } from './workspace/useStrandedRuns'; export function HomePage() { const { ready, canUseV2, error } = useWebCatalog(); @@ -37,6 +38,7 @@ export function RunWorkspace() { const { ready, canUseV2, catalog, error } = useWebCatalog(); const entry = catalog?.flows.find((candidate) => candidate.flowType === flowType); const search = useFlowSearch(flowType || undefined, entry?.definition); + const { strandedFlowIDs, rememberStranded } = useStrandedRuns(); const shellRef = useRef(null); const bodyRef = useRef(null); const listPaneRef = useRef(null); @@ -72,6 +74,7 @@ export function RunWorkspace() { if (!flowType) return ; if (!entry) return ; + const selectedFlow = search.flows.find((flow) => flow.flowId === flowId); const paneStyle = { '--v2-list-w': `${listWidth}px`, ...(Number.isFinite(caseHeight) && caseHeight > 0 ? { '--v2-case-h': `${caseHeight}px` } : {}), @@ -86,6 +89,7 @@ export function RunWorkspace() { headerNote="current runs" search={search} selectedFlowID={flowId} + strandedFlowIDs={strandedFlowIDs} onSelectFlowType={(next) => navigate(v2RunPath(next))} onSelectRun={(nextFlowID) => navigate(v2RunPath(entry.flowType, nextFlowID))} > @@ -103,7 +107,9 @@ export function RunWorkspace() { Timeline, events and controls diff --git a/web/app/v2/css/listing.css b/web/app/v2/css/listing.css index 7827e6269..e2a848b4c 100644 --- a/web/app/v2/css/listing.css +++ b/web/app/v2/css/listing.css @@ -79,7 +79,6 @@ cursor: not-allowed; } -.sv-nonone, .sv-nograph { color: var(--p-ink-3); font-size: 10.5px; @@ -152,21 +151,6 @@ font-size: 9.5px; } -.sq-group { - margin-bottom: 14px; -} - -.sq-grouphead { - margin-bottom: 4px; - color: var(--p-ink-1); - font-size: 10.5px; -} - -/* Blocked work is the only kind that gets emphasis. The other two are real but not urgent. */ -.sq-group[data-kind='blocked'] .sq-grouphead { - color: var(--p-external); -} - .sq-list { display: flex; flex-direction: column; @@ -241,7 +225,6 @@ font-weight: 600; } -.sc-subtitle, .sc-status { color: var(--p-ink-2); font-size: 11px; @@ -280,22 +263,6 @@ text-transform: uppercase; } -.sc-reason, -.sc-recommend { - color: var(--p-ink-0); - font-size: 12px; - line-height: 1.5; -} - -.sc-verbatim { - padding-left: 10px; - border-left: 2px solid var(--p-line); - color: var(--p-ink-1); - font-size: 12px; - font-style: italic; - line-height: 1.5; -} - /* * THE ONE THING THIS VIEW HAS THAT A TRANSCRIPT DOES NOT: a table. These are quantities to weigh against each * other, and comparison wants columns — which was the open question the earlier finding could not settle. @@ -306,6 +273,22 @@ gap: 3px 14px; } +/* One Action per form; inputs stack above the button that submits them. */ +.sc-actions { + display: flex; + flex-direction: column; + gap: 6px; + align-items: flex-start; + margin-bottom: 12px; +} + +.sc-actions label { + display: flex; + flex-direction: column; + gap: 3px; + width: 100%; +} + .sc-fact { display: grid; grid-column: 1 / -1; @@ -327,104 +310,8 @@ font-weight: 600; } -.sc-rest { - margin-top: 16px; -} - -.sc-rest summary { - cursor: default; -} - -.sc-decide { - padding-top: 14px; - border-top: 1px solid var(--p-line-soft); -} - .sc-none { color: var(--p-ink-2); font-size: 11.5px; line-height: 1.5; } - -.sc-verdicts { - display: flex; - gap: 8px; -} - -.sc-verdict { - padding: 5px 14px; - border: 1px solid var(--p-line); - border-radius: 5px; - color: var(--p-ink-0); - font-size: 12px; -} - -.sc-verdict:hover:enabled { - background: var(--p-surface-3); -} - -.sc-verdict:disabled { - color: var(--p-ink-3); -} - -.sc-sending, -.sc-outcome { - margin-top: 6px; - font-size: 11px; - line-height: 1.5; -} - -.sc-sending { - color: var(--p-ink-2); -} - -.sc-outcome[data-outcome='accepted'] { - color: var(--p-run-done); -} - -/* Refused and lost are different facts and neither is a success. */ -.sc-outcome[data-outcome='refused'], -.sc-outcome[data-outcome='lost'] { - color: var(--p-run-failed); -} - -.sc-asked { - display: flex; - flex-direction: column; - gap: 8px; - margin-bottom: 8px; -} - -.sc-q { - color: var(--p-ink-2); - font-size: 11px; -} - -.sc-a { - color: var(--p-ink-0); - font-size: 12px; - line-height: 1.5; -} - -.sc-askrow { - display: flex; - gap: 8px; -} - -.sc-askbox { - flex: 1 1 auto; - padding: 5px 9px; - border: 1px solid var(--p-line); - border-radius: 5px; - background: var(--p-surface-1); - color: var(--p-ink-0); - font-size: 12px; -} - -.sc-asksend { - padding: 5px 12px; - border: 1px solid var(--p-line); - border-radius: 5px; - color: var(--p-ink-1); - font-size: 11.5px; -} diff --git a/web/app/v2/queue/QueueWorkspace.tsx b/web/app/v2/queue/QueueWorkspace.tsx index 17310797a..204e9908d 100644 --- a/web/app/v2/queue/QueueWorkspace.tsx +++ b/web/app/v2/queue/QueueWorkspace.tsx @@ -6,15 +6,16 @@ // // SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 -import { useCallback, useState } from 'react'; import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'; +import type { FlowV2Definition } from '@superdurable/flow-definition-renderer'; import { v2QueuePath, v2RunPath } from '../contract'; import '../css/v2.css'; import { useWebCatalog } from '../WebCatalogProvider'; import { FlowListing } from '../workspace/FlowListing'; -import { newFilterRow } from '../workspace/filters'; +import { describeFilters, newFilterRow } from '../workspace/filters'; import { SelectedRunPanel } from '../workspace/SelectedRunPanel'; import { useFlowSearch } from '../workspace/useFlowSearch'; +import { useStrandedRuns } from '../workspace/useStrandedRuns'; import { QUEUE_COPY } from './copy'; import { openFlowStatusLabel } from './liveness'; @@ -33,12 +34,7 @@ export function QueueWorkspace() { const search = useFlowSearch(flowType || undefined, entry?.definition, [ newFilterRow('executionStatus', 'eq', openFlowStatusLabel()), ]); - const [strandedFlowIDs, setStrandedFlowIDs] = useState>(() => new Set()); - const rememberStranded = useCallback((strandedFlowID: string) => { - setStrandedFlowIDs((prior) => ( - prior.has(strandedFlowID) ? prior : new Set([...prior, strandedFlowID]) - )); - }, []); + const { strandedFlowIDs, rememberStranded } = useStrandedRuns(); if (!ready) return
    Loading Dex Web…
    ; if (!canUseV2) return ; @@ -68,7 +64,7 @@ export function QueueWorkspace() { entry={entry} flowTypes={catalog.flows} headerNote="live — read from a running process" - scope={} + scope={} search={search} selectedFlowID={flowId} strandedFlowIDs={strandedFlowIDs} @@ -98,8 +94,15 @@ export function QueueWorkspace() { } /** Four states, never collapsed: an empty page and an unreachable process call for opposite actions. */ -function QueueScope({ search }: { search: ReturnType }) { +function QueueScope({ + definition, + search, +}: { + definition: FlowV2Definition; + search: ReturnType; +}) { const { liveness, flows } = search; + const clauses = describeFilters(search.filters, definition); const headline = liveness === 'loading' ? QUEUE_COPY.loading : liveness === 'unreachable' @@ -112,9 +115,9 @@ function QueueScope({ search }: { search: ReturnType }) { return (

    {headline} - {QUEUE_COPY.openOnly(openFlowStatusLabel())} + {QUEUE_COPY.scope(clauses)} + {clauses.length === 0 && {QUEUE_COPY.unfilteredHint}} {QUEUE_COPY.actionsProvenance} - {search.searchError && {search.searchError}}

    ); } diff --git a/web/app/v2/queue/copy.ts b/web/app/v2/queue/copy.ts index 46e03e56b..d64d4483e 100644 --- a/web/app/v2/queue/copy.ts +++ b/web/app/v2/queue/copy.ts @@ -17,9 +17,12 @@ export const QUEUE_COPY = { strapline: 'Open runs of one Flow type, read from the running process.', noGraph: 'No process diagram here by design: this view shows the work, not the shape of the process.', - openOnly(statusLabel: string): string { - return `Showing runs with execution status ${statusLabel}. Closed runs are not work, so they are filtered out — edit the filter to see them.`; + /** Derived from the live filter rows: the sentence must not outlive a filter the reader deleted. */ + scope(clauses: readonly string[]): string { + if (clauses.length === 0) return 'Showing every run of this Flow type, open or closed.'; + return `Showing runs where ${clauses.join(' and ')}.`; }, + unfilteredHint: 'Closed runs are not work. Filter on execution status to hide them.', /** Counts describe the page, never the queue: the server paginates and we do not total it. */ onThisPage(count: number): string { @@ -29,6 +32,8 @@ export const QUEUE_COPY = { loading: 'Asking the process…', unreachable: 'Cannot reach the process, so this list is not the whole picture.', stale: 'Showing the last answer — the process did not respond just now.', + staleShort: 'stale', + refresh: 'Ask again', /** Actions are gated on live Attributes, so the list cannot promise one is available. */ actionsProvenance: 'Which Actions are available is decided per run when you open it.', diff --git a/web/app/v2/workspace/FlowListing.tsx b/web/app/v2/workspace/FlowListing.tsx index 35408a437..6daf2658c 100644 --- a/web/app/v2/workspace/FlowListing.tsx +++ b/web/app/v2/workspace/FlowListing.tsx @@ -58,6 +58,9 @@ export function FlowListing({
    {entry.flowType} {headerNote} +
    {flowTypes.length > 1 && (
    diff --git a/web/app/v2/workspace/SelectedRunPanel.tsx b/web/app/v2/workspace/SelectedRunPanel.tsx index b333da811..b24c6e69e 100644 --- a/web/app/v2/workspace/SelectedRunPanel.tsx +++ b/web/app/v2/workspace/SelectedRunPanel.tsx @@ -18,7 +18,7 @@ import { readResponseJSON } from '@/lib/http'; import type { V2Display } from '@/lib/types'; import { parseTypedValue, v2ActionUserFields, v2ActionUserInput, visibleV2Actions } from '../contract'; import { QUEUE_COPY } from '../queue/copy'; -import { isStrandedRunFailure, readFailureReason } from '../queue/liveness'; +import { absorb, classifyReadFailure, nothingHeld, readFailureReason } from '../queue/liveness'; export function SelectedRunPanel({ flowType, @@ -37,9 +37,9 @@ export function SelectedRunPanel({ /** Reported up so the list can mark the row; a search cannot discover this. */ onStranded?: (flowID: string) => void; }) { - const [result, setResult] = useState(null); - const [error, setError] = useState(''); - const [isStranded, setIsStranded] = useState(false); + const [held, setHeld] = useState(() => nothingHeld()); + /** A write that failed is not a stale read, so it does not touch held. */ + const [actionError, setActionError] = useState(''); const [busyKey, setBusyKey] = useState(''); const [editingKey, setEditingKey] = useState(''); const [editValue, setEditValue] = useState(''); @@ -47,28 +47,30 @@ export function SelectedRunPanel({ const [actionValues, setActionValues] = useState>>({}); const loadDisplay = useCallback(async () => { - setError(''); - setIsStranded(false); try { const query = new URLSearchParams({ flowType, flowId }); const response = await fetch(`/api/v2/display?${query}`); - setResult(await readResponseJSON(response)); + const display = await readResponseJSON(response); + setHeld((prior) => absorb(prior, { state: 'ok', value: display })); } catch (loadError) { // Only knowable once somebody opens the run: a search says nothing about its worker. - if (isStrandedRunFailure(loadError, flowStatusCode)) { - setIsStranded(true); - onStranded?.(flowId); - return; - } - setError(readFailureReason(loadError)); + const outcome = classifyReadFailure(loadError, flowStatusCode); + if (outcome.state === 'stranded') onStranded?.(flowId); + setHeld((prior) => absorb(prior, outcome)); } }, [flowId, flowStatusCode, flowType, onStranded]); + // A new run must not inherit the previous run's values while its own read is in flight. + useEffect(() => { setHeld(nothingHeld()); }, [flowId, flowType]); + useEffect(() => { void loadDisplay(); }, [loadDisplay]); + const result = held.value; + const isStranded = held.liveness === 'stranded'; + async function saveField(attributeKey: string, valueType: V2ValueType) { setBusyKey(attributeKey); - setError(''); + setActionError(''); setFieldErrors((current) => ({ ...current, [attributeKey]: '' })); try { const response = await fetch('/api/v2/display', { @@ -93,7 +95,7 @@ export function SelectedRunPanel({ async function invokeAction(action: FlowV2Action) { setBusyKey(action.rpcName); - setError(''); + setActionError(''); try { const input = v2ActionUserInput(action, actionValues[action.rpcName] ?? {}); const response = await fetch('/api/v2/actions', { @@ -106,7 +108,7 @@ export function SelectedRunPanel({ await readResponseJSON(response); await loadDisplay(); } catch (actionError) { - setError(actionError instanceof Error ? actionError.message : 'Action failed'); + setActionError(readFailureReason(actionError)); } finally { setBusyKey(''); } @@ -117,11 +119,14 @@ export function SelectedRunPanel({
    {flowId} {result && {result.flowStatus}} + {held.liveness === 'stale' && {QUEUE_COPY.staleShort}} {footer}
    {isStranded &&

    {QUEUE_COPY.stranded}

    } - {error &&

    {error}

    } - {!result && !error && !isStranded &&

    {QUEUE_COPY.loading}

    } + {held.liveness === 'unreachable' &&

    {held.reason}

    } + {held.liveness === 'stale' &&

    {held.reason}

    } + {held.liveness === 'loading' &&

    {QUEUE_COPY.loading}

    } + {actionError &&

    {actionError}

    } {result && !isStranded && ( <>
    diff --git a/web/app/v2/workspace/filters.test.ts b/web/app/v2/workspace/filters.test.ts new file mode 100644 index 000000000..981a2bb8c --- /dev/null +++ b/web/app/v2/workspace/filters.test.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { describe, expect, it } from 'vitest'; +import type { FlowV2Definition } from '@superdurable/flow-definition-renderer'; +import { QUEUE_COPY } from '../queue/copy'; +import { describeFilters, type FilterRow } from './filters'; + +const definition: FlowV2Definition = { + indexedAttributes: [{ + attributeKey: 'case-status', indexKey: 'case-status', indexType: 'keyword', + valueType: 'string', description: 'Current case status', + }], + summary: { rpcName: 'GetDexSummary', fields: [] }, + display: { rpcName: 'GetDexDisplay', fields: [] }, + actions: [], +}; + +const row = (field: string, operator: string, value: string): FilterRow => ({ + id: `${field}-${operator}`, field, operator, value, +}); + +describe('describeFilters', () => { + it('names the field by the description the contract supplies', () => { + expect(describeFilters([row('case-status', 'eq', 'awaiting-manager-rule')], definition)) + .toEqual(['current case status is awaiting-manager-rule']); + }); + + it('describes the built-in fields the contract does not declare', () => { + expect(describeFilters([row('executionStatus', 'eq', 'Running')], definition)) + .toEqual(['execution status is Running']); + }); + + it('ignores a filter with no value, because the server ignores it too', () => { + expect(describeFilters([row('executionStatus', 'eq', ' ')], definition)).toEqual([]); + }); + + it('joins several filters the way the server ANDs them', () => { + const clauses = describeFilters([ + row('executionStatus', 'eq', 'Running'), + row('case-status', 'in', 'a,b'), + ], definition); + expect(QUEUE_COPY.scope(clauses)) + .toBe('Showing runs where execution status is Running and current case status is one of a,b.'); + }); + + it('stops claiming a scope once the reader deletes every filter', () => { + expect(QUEUE_COPY.scope(describeFilters([], definition))) + .toBe('Showing every run of this Flow type, open or closed.'); + }); + + it('falls back to the raw operator rather than inventing a phrase', () => { + expect(describeFilters([row('case-status', 'weird', 'x')], definition)) + .toEqual(['current case status weird x']); + }); +}); diff --git a/web/app/v2/workspace/filters.ts b/web/app/v2/workspace/filters.ts index 962756f86..9bcfcfc5a 100644 --- a/web/app/v2/workspace/filters.ts +++ b/web/app/v2/workspace/filters.ts @@ -80,3 +80,28 @@ export function updateFilter( export function newFilterRow(field: string, operator: string, value: string): FilterRow { return { id: `${Date.now()}-${Math.random()}`, field, operator, value }; } + +const OPERATOR_PHRASE: Record = { + eq: 'is', + in: 'is one of', + contains: 'contains', + gt: 'is after', + gte: 'is at least', + lt: 'is before', + lte: 'is at most', +}; + +/** One clause per filter the reader can actually see, so the scope sentence cannot overstate. */ +export function describeFilters( + filters: readonly FilterRow[], + definition: FlowV2Definition, +): string[] { + const labels = new Map(filterFields(definition).map((field) => [field.key, field.label])); + return filters + .filter((filter) => filter.value.trim() !== '') + .map((filter) => { + const field = labels.get(filter.field) ?? filter.field; + const operator = OPERATOR_PHRASE[filter.operator] ?? filter.operator; + return `${field.toLowerCase()} ${operator} ${filter.value.trim()}`; + }); +} diff --git a/web/app/v2/workspace/useStrandedRuns.ts b/web/app/v2/workspace/useStrandedRuns.ts new file mode 100644 index 000000000..5f0ba7102 --- /dev/null +++ b/web/app/v2/workspace/useStrandedRuns.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { useCallback, useState } from 'react'; + +/** + * Learned, never searched: a run reports itself unreachable only when somebody opens it. + * Session-scoped because a worker could in principle come back. + */ +export function useStrandedRuns() { + const [strandedFlowIDs, setStrandedFlowIDs] = useState>(() => new Set()); + const rememberStranded = useCallback((strandedFlowID: string) => { + setStrandedFlowIDs((prior) => ( + prior.has(strandedFlowID) ? prior : new Set([...prior, strandedFlowID]) + )); + }, []); + return { strandedFlowIDs, rememberStranded }; +} From 3da1c61a9f2f0ad9e4a14e6e4b73eb8ace0f3ac6 Mon Sep 17 00:00:00 2001 From: zzheng Date: Sat, 19 Sep 2026 18:42:06 -0700 Subject: [PATCH 04/21] Nest the v1 detailed view as the v2 Deep Dive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /v2/run/:flowType/:flowId/debug/:runId renders the v1 run-details components unchanged inside the v2 shell. Layout and information density are preserved deliberately: this is the view an engineer drills into, and its four tabs are already the comprehensive record. Keyed per run, because Time Travel and continue-as-new navigate the run chain while the Run view only ever shows the current run. With no runId in the URL it resolves the current one. /v1 keeps serving the same components, so globals.css cannot be rewritten in place. debug.css is therefore a colour-and-type layer scoped under .v2-debug, the same shape as the .v2-shell .ppan block already in canvas.css. globals.css still owns the layout. Mapped by meaning, not by literal. v1 spells its palette as 74 distinct hex values and uses green for three different things; v2 reserves green for done, so interactive affordances move to blue. v2 has no interactive token at all — every --p-* is a status — so the accent is aliased once and named. RunDetailsPage now takes its breadcrumb and its run-chain path from whichever shell hosts it, passed explicitly at both call sites rather than defaulted. Verified by measurement rather than by eye: a probe walks every element in all four tabs in dark mode looking for a light background, and reports zero. The first version of that probe was wrong — it split rgba alpha on \d+ and read 0.97 as 0 — which is how a white panel header survived two passes. Known remaining: the Deep Dive's own deep links (SubFlow drill-downs, previous runs) still point at /v1/flows. They are reachable and correct there; routing them back into v2 needs a context rather than nine more props. --- web/app/App.tsx | 21 +- web/app/flows/RunDetailsPage.tsx | 27 +- web/app/v2/RunWorkspace.tsx | 7 +- web/app/v2/contract.test.ts | 9 +- web/app/v2/contract.ts | 7 +- web/app/v2/css/debug.css | 434 ++++++++++++++++++++++++++++ web/app/v2/debug/DebugWorkspace.tsx | 74 +++++ web/app/v2/debug/copy.ts | 16 + 8 files changed, 573 insertions(+), 22 deletions(-) create mode 100644 web/app/v2/css/debug.css create mode 100644 web/app/v2/debug/DebugWorkspace.tsx create mode 100644 web/app/v2/debug/copy.ts diff --git a/web/app/App.tsx b/web/app/App.tsx index 0907cc87c..a2988886a 100644 --- a/web/app/App.tsx +++ b/web/app/App.tsx @@ -6,8 +6,9 @@ // // SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 -import { Navigate, Route, Routes, useParams } from 'react-router-dom'; +import { Link, Navigate, Route, Routes, useParams } from 'react-router-dom'; import { AppHeader } from './components/AppHeader'; +import { DebugWorkspace } from './v2/debug/DebugWorkspace'; import { CurrentRunRedirect } from './flows/CurrentRunRedirect'; import { FlowSearchPage } from './flows/FlowSearchPage'; import { RunDetailsPage } from './flows/RunDetailsPage'; @@ -31,6 +32,8 @@ export function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -55,5 +58,19 @@ function CurrentFlowRoute() { function FlowRunRoute() { const { flowId = '', runId = '' } = useParams(); - return ; + const flowPath = `/v1/flows/${encodeURIComponent(flowId)}`; + return ( + + Flows/ + {flowId}/ + {runId} +
    + )} + flowId={flowId} + runId={runId} + runPath={(chainRunID) => `${flowPath}/${encodeURIComponent(chainRunID)}`} + /> + ); } diff --git a/web/app/flows/RunDetailsPage.tsx b/web/app/flows/RunDetailsPage.tsx index 9963be847..11273ef06 100644 --- a/web/app/flows/RunDetailsPage.tsx +++ b/web/app/flows/RunDetailsPage.tsx @@ -7,7 +7,7 @@ // SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 import { Link } from 'react-router-dom'; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, @@ -98,7 +98,19 @@ function selectedEventConnectorTone(event: FlowHistoryEvent): SelectedEventConne return 'default'; } -export function RunDetailsPage({ flowId, runId }: { flowId: string; runId: string }) { +export function RunDetailsPage({ + flowId, + runId, + breadcrumb, + runPath, +}: { + flowId: string; + runId: string; + /** Supplied by the host shell, which owns where "up" goes. */ + breadcrumb: ReactNode; + /** Where a sibling run in this chain lives under the host's routes. */ + runPath: (chainRunID: string) => string; +}) { const { timezone } = usePreferences(); const [summary, setSummary] = useState(null); const [history, setHistory] = useState([]); @@ -449,11 +461,7 @@ export function RunDetailsPage({ flowId, runId }: { flowId: string; runId: strin return (
    -
    - Flows/ - {flowId}/ - {runId} -
    + {breadcrumb}

    {summary?.flowType || 'Flow execution'}

    @@ -461,10 +469,7 @@ export function RunDetailsPage({ flowId, runId }: { flowId: string; runId: strin
    {continuedToRunId && ( - + Next run )} diff --git a/web/app/v2/RunWorkspace.tsx b/web/app/v2/RunWorkspace.tsx index d7b6c010e..db4f014fe 100644 --- a/web/app/v2/RunWorkspace.tsx +++ b/web/app/v2/RunWorkspace.tsx @@ -8,8 +8,9 @@ import { useCallback, useRef, useState, type CSSProperties } from 'react'; import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'; -import { v1RunPath, v2HomePath, v2RunPath } from './contract'; +import { v2DebugPath, v2HomePath, v2RunPath } from './contract'; import './css/v2.css'; +import { DEBUG_COPY } from './debug/copy'; import { V2Canvas } from './V2Canvas'; import { CASE_HEIGHT_KEY, @@ -111,8 +112,8 @@ export function RunWorkspace() { flowType={entry.flowType} onStranded={rememberStranded} footer={( - - Timeline, events and controls + + {DEBUG_COPY.openLabel} )} /> diff --git a/web/app/v2/contract.test.ts b/web/app/v2/contract.test.ts index 0b9ce4e23..40c30377d 100644 --- a/web/app/v2/contract.test.ts +++ b/web/app/v2/contract.test.ts @@ -12,10 +12,10 @@ import type { FlowV2Definition, } from '@superdurable/flow-definition-renderer'; import { - v1RunPath, v2ActionUserFields, v2ActionUserInput, v2HomePath, + v2DebugPath, v2ListColumns, v2QueuePath, v2RunPath, @@ -44,8 +44,11 @@ describe('Dex Web v2 contract helpers', () => { expect(v2QueuePath('Refund Flow')).toBe('/v2/queue/Refund%20Flow'); }); - it('links a run to the v1 page that owns Timeline and controls', () => { - expect(v1RunPath('refund/42')).toBe('/v1/flows/refund%2F42'); + it('nests the Deep Dive under the run it belongs to, optionally keyed by run', () => { + expect(v2DebugPath('Refund Flow', 'refund/42')) + .toBe('/v2/run/Refund%20Flow/refund%2F42/debug'); + expect(v2DebugPath('Refund Flow', 'refund/42', 'run/7')) + .toBe('/v2/run/Refund%20Flow/refund%2F42/debug/run%2F7'); }); it('shows only eligible Actions and hides Attribute-sourced inputs', () => { diff --git a/web/app/v2/contract.ts b/web/app/v2/contract.ts index fb534ef47..b55178962 100644 --- a/web/app/v2/contract.ts +++ b/web/app/v2/contract.ts @@ -34,9 +34,10 @@ export function v2QueuePath(flowType?: string, flowID?: string) { return v2ModePath('queue', flowType, flowID); } -/** The v1 run page is where Timeline, event details, Stop and Time Travel live. */ -export function v1RunPath(flowID: string) { - return `/v1/flows/${encodeURIComponent(flowID)}`; +/** The Deep Dive is keyed per run: Time Travel and continue-as-new walk the run chain. */ +export function v2DebugPath(flowType: string, flowID: string, runID?: string) { + const base = `${v2RunPath(flowType, flowID)}/debug`; + return runID === undefined ? base : `${base}/${encodeURIComponent(runID)}`; } export function v2ListColumns(definition: FlowV2Definition) { diff --git a/web/app/v2/css/debug.css b/web/app/v2/css/debug.css new file mode 100644 index 000000000..0064156b3 --- /dev/null +++ b/web/app/v2/css/debug.css @@ -0,0 +1,434 @@ +/* + * Copyright (c) 2026 Super Durable, Inc. + * + * Licensed under the Sustainable Use License 1.0. + * You may not use this file except in compliance with the License. + * See the LICENSE file in the repository root. + * + * SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + */ + +/* + * Colour and type for the Deep Dive, which reuses the v1 detail components. + * + * globals.css still owns their LAYOUT and still serves /v1 unchanged, so this file + * only re-points colour and type, scoped under .v2-debug. Same precedent as the + * .v2-shell .ppan block in canvas.css. + * + * Mapped by semantic family, not by literal: v1's greens carry three different + * meanings (interactive, succeeded, emphasis) and v2 reserves green for done. + */ + +.v2-debug { + /* + * v2 has no interactive token: --p-* are all status colours and --p-external means + * "external party", not "clickable". Its own chrome already treats this blue as + * interactive via --accent-wash and --focus-ring, so name it once here. + */ + --v2-debug-accent: var(--hue-progress-ink); + + overflow-y: auto; + height: 100%; + background: var(--p-surface-1); + color: var(--p-ink-1); + font-family: var(--font-sans); +} + +.v2-debug .run-page { + max-width: none; + padding: 0 20px 28px; +} + +.v2-debug-head { + display: flex; + align-items: baseline; + gap: 10px; + padding: 10px 20px; + border-bottom: 1px solid var(--p-line-soft); +} + +.v2-debug-back { + color: var(--p-ink-2); + font-size: 11px; +} + +.v2-debug-back:hover { + color: var(--p-ink-0); +} + +.v2-debug-label { + color: var(--p-ink-0); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.v2-debug-note { + color: var(--p-ink-3); + font-size: 10px; +} + +/* ------------------------------------------------------------------ type */ + +/* + * v1's h1 is clamp(28px, 4vw, 42px) against v2's 15px ceiling. Left large enough + * to head a page, small enough to sit beside v2 chrome. + */ +.v2-debug h1 { + color: var(--p-ink-0); + font-size: 20px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.v2-debug h2 { + color: var(--p-ink-0); + font-size: 14px; + font-weight: 600; +} + +.v2-debug h3 { + color: var(--p-ink-1); + font-size: 12px; + font-weight: 600; +} + +.v2-debug .eyebrow { + color: var(--p-ink-3); + font-size: 9.5px; + letter-spacing: 0.08em; +} + +.v2-debug .muted { + color: var(--p-ink-2); +} + +.v2-debug .mono, +.v2-debug code, +.v2-debug pre { + font-family: var(--font-mono); + font-variant-ligatures: none; +} + +/* --------------------------------------------------------------- surfaces */ + +.v2-debug .card, +.v2-debug .overview-card, +.v2-debug .summary-strip, +.v2-debug .graph-view, +.v2-debug .timeline-wrap, +.v2-debug .run-sidebar { + border: 1px solid var(--p-line-soft); + border-radius: var(--p-radius); + background: var(--p-surface-2); + box-shadow: none; +} + +.v2-debug .live-state-block, +.v2-debug .active-step-card, +.v2-debug .event-card, +.v2-debug .sub-flow-record, +.v2-debug .channel-message-row { + border-color: var(--p-line-soft); + background: var(--p-surface-3); +} + +.v2-debug pre, +.v2-debug .definition-list, +.v2-debug .metric-grid { + border-color: var(--p-line-soft); + background: var(--p-surface-3); + color: var(--p-ink-1); +} + +.v2-debug hr, +.v2-debug .run-header, +.v2-debug .run-tabs { + border-color: var(--p-line-soft); +} + +/* ---------------------------------------------------------------- controls */ + +/* Neutral by default: green here would collide with v2 reserving green for done. */ +.v2-debug .button, +.v2-debug .section-expand-toggle, +.v2-debug .graph-minimap-toggle { + border: 1px solid var(--p-line); + border-radius: 5px; + background: var(--p-surface-3); + color: var(--p-ink-1); + box-shadow: none; +} + +.v2-debug .button:hover:enabled { + border-color: var(--p-ink-3); + color: var(--p-ink-0); +} + +.v2-debug .button.primary { + border-color: var(--v2-debug-accent); + background: var(--p-surface-3); + color: var(--v2-debug-accent); +} + +.v2-debug .button.danger { + border-color: var(--p-run-failed); + background: var(--p-surface-3); + color: var(--p-run-failed); +} + +.v2-debug .button:disabled { + color: var(--p-ink-3); +} + +.v2-debug .run-tabs button { + border: 0; + background: transparent; + color: var(--p-ink-2); + font-size: 11.5px; +} + +.v2-debug .run-tabs button:hover { + color: var(--p-ink-0); +} + +.v2-debug .run-tabs button.active { + color: var(--p-ink-0); +} + +.v2-debug .run-tabs button.active::after { + background: var(--v2-debug-accent); +} + +.v2-debug a { + color: var(--v2-debug-accent); +} + +.v2-debug .breadcrumbs, +.v2-debug .breadcrumbs a { + color: var(--p-ink-2); + font-size: 10.5px; +} + +/* ------------------------------------------------------- run-state tones */ + +/* + * One row per meaning. v1 spelled these as 74 distinct literals; v2 already has + * a token per run state and the graph/timeline tones reuse them. + */ +.v2-debug .status-badge, +.v2-debug .event-type, +.v2-debug .timeline-dot, +.v2-debug .phase { + border-color: var(--p-line-soft); + background: var(--p-surface-3); + color: var(--p-ink-2); +} + +.v2-debug .status-running, +.v2-debug .phase-active, +.v2-debug .tone-execute, +.v2-debug .event-type.tone-execute { + border-color: var(--p-run-active); + color: var(--p-run-active); +} + +.v2-debug .status-completed, +.v2-debug .event-type.tone-completed { + border-color: var(--p-run-done); + color: var(--p-run-done); +} + +.v2-debug .status-failed, +.v2-debug .status-terminated, +.v2-debug .event-type.tone-failed, +.v2-debug .run-error { + border-color: var(--p-run-failed); + color: var(--p-run-failed); +} + +.v2-debug .phase-waiting, +.v2-debug .tone-wait-for, +.v2-debug .event-type.tone-wait-for { + border-color: var(--p-clock); + color: var(--p-clock); +} + +.v2-debug .status-canceled, +.v2-debug .status-timed-out { + border-color: var(--p-unknown); + color: var(--p-unknown); +} + +/* ------------------------------------------------------------ graph nodes */ + +.v2-debug .react-flow__node.step-flow-node { + border-color: var(--p-line); + background: var(--p-surface-2); + color: var(--p-ink-1); +} + +.v2-debug .react-flow__node.node-running, +.v2-debug .graph-node-current { + border-color: var(--p-run-active); +} + +.v2-debug .react-flow__node.node-completed { + border-color: var(--p-run-done); +} + +.v2-debug .react-flow__node.node-failed { + border-color: var(--p-run-failed); +} + +.v2-debug .react-flow__node.node-waiting { + border-color: var(--p-clock); +} + +.v2-debug .react-flow__edge-path { + stroke: var(--p-edge-control); +} + +.v2-debug .graph-canvas, +.v2-debug .graph-minimap { + background: var(--p-bg); +} + +.v2-debug .graph-legend, +.v2-debug .view-toolbar { + border-color: var(--p-line-soft); + background: var(--p-surface-2); + color: var(--p-ink-2); +} + +/* --------------------------------------------------------------- timeline */ + +.v2-debug .timeline-rail, +.v2-debug .timeline-step-link { + border-color: var(--p-line); + background: var(--p-line-soft); +} + +.v2-debug .timeline-time, +.v2-debug .event-id, +.v2-debug .timeline-step-duration { + color: var(--p-ink-3); +} + +.v2-debug .timeline-row.selected .event-card { + border-color: var(--v2-debug-accent); +} + +.v2-debug .selected-event-connector path { + stroke: var(--v2-debug-accent); +} + +/* ----------------------------------------------------------------- modals */ + +.v2-debug .modal, +.v2-debug .modal-card { + border-color: var(--p-line); + background: var(--p-surface-2); + color: var(--p-ink-1); +} + +.v2-debug .modal-backdrop { + background: color-mix(in oklch, var(--p-bg) 78%, transparent); +} + +.v2-debug input, +.v2-debug select, +.v2-debug textarea { + border: 1px solid var(--p-line); + border-radius: 4px; + background: var(--p-surface-1); + color: var(--p-ink-0); + font-size: 11.5px; +} + +.v2-debug .error-banner { + border-color: var(--p-run-failed); + background: var(--p-surface-3); + color: var(--p-run-failed); +} + +/* + * The rest, found by probing for a near-white background in dark mode rather than by + * reading selectors — these are the families globals.css paints with literals. + */ +.v2-debug .semantic-record, +.v2-debug .semantic-section, +.v2-debug .channel-record, +.v2-debug .sidebar-stack, +.v2-debug .selected-event-anchor, +.v2-debug .overview-selected-event, +.v2-debug .event-detail-tabs, +.v2-debug code { + border-color: var(--p-line-soft); + background: var(--p-surface-3); + color: var(--p-ink-1); +} + +.v2-debug .event-detail-tabs button.active { + background: var(--p-surface-2); + color: var(--p-ink-0); +} + +.v2-debug .graph-methods { + background: transparent; +} + +.v2-debug .graph-method { + border-color: var(--p-line-soft); + background: var(--p-surface-3); + color: var(--p-ink-2); +} + +.v2-debug .graph-method-wait-for { + border-color: var(--p-clock); + color: var(--p-clock); +} + +.v2-debug .graph-method-execute { + border-color: var(--p-run-done); + color: var(--p-run-done); +} + +.v2-debug .legend-source, +.v2-debug .legend-subflow { + background: var(--p-surface-3); +} + +.v2-debug .react-flow__controls-button { + border-color: var(--p-line-soft); + background: var(--p-surface-2); + fill: var(--p-ink-1); +} + +/* Every .tone-* variant carries its own light tint, so outrank them all at once. */ +.v2-debug .event-type[class*='tone-'], +.v2-debug .timeline-dot[class*='tone-'] { + background: var(--p-surface-3); +} + +.v2-debug .json-toggle, +.v2-debug .json-view-body, +.v2-debug .json-view-body .event-detail-tabs, +.v2-debug .json-view-raw { + border-color: var(--p-line-soft); + background: var(--p-surface-3); + color: var(--p-ink-1); +} + +.v2-debug .json-toggle span { + color: var(--p-ink-2); +} + +/* Unclassed chips: .event-highlights > span, and the Overview metric cells. */ +.v2-debug .event-highlights > span, +.v2-debug .metric-grid > div, +.v2-debug .definition-list > div { + border-color: var(--p-line-soft); + background: var(--p-surface-2); + color: var(--p-ink-2); +} diff --git a/web/app/v2/debug/DebugWorkspace.tsx b/web/app/v2/debug/DebugWorkspace.tsx new file mode 100644 index 000000000..440ec4210 --- /dev/null +++ b/web/app/v2/debug/DebugWorkspace.tsx @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { useEffect, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { RunDetailsPage } from '@/app/flows/RunDetailsPage'; +import { readResponseJSON } from '@/lib/http'; +import type { FlowSummary } from '@/lib/types'; +import { v2DebugPath, v2RunPath } from '../contract'; +import '../css/v2.css'; +import '../css/debug.css'; +import { DEBUG_COPY } from './copy'; + +/** + * The Deep Dive: v1's detail components, unchanged, inside the v2 shell. + * + * Its own runId, because Time Travel and continue-as-new navigate the run chain and the + * Run view only ever shows the current run. + */ +export function DebugWorkspace() { + const { flowType = '', flowId = '', runId = '' } = useParams(); + const [resolvedRunID, setResolvedRunID] = useState(runId); + const [error, setError] = useState(''); + + useEffect(() => { + if (runId) { + setResolvedRunID(runId); + return undefined; + } + const controller = new AbortController(); + void fetch(`/api/flows/summary?flowId=${encodeURIComponent(flowId)}`, { + signal: controller.signal, + }) + .then((response) => readResponseJSON(response)) + .then((summary) => setResolvedRunID(summary.runId)) + .catch((loadError: unknown) => { + if (!controller.signal.aborted) { + setError(loadError instanceof Error ? loadError.message : DEBUG_COPY.runUnresolved); + } + }); + return () => controller.abort(); + }, [flowId, runId]); + + return ( +
    +
    + {DEBUG_COPY.back} + {DEBUG_COPY.label} + {DEBUG_COPY.note} +
    + {error &&
    {error}
    } + {!error && !resolvedRunID &&
    {DEBUG_COPY.resolving}
    } + {resolvedRunID && ( + + {flowType}/ + {flowId}/ + {resolvedRunID} +
    + )} + flowId={flowId} + runId={resolvedRunID} + runPath={(chainRunID) => v2DebugPath(flowType, flowId, chainRunID)} + /> + )} +
    + ); +} diff --git a/web/app/v2/debug/copy.ts b/web/app/v2/debug/copy.ts new file mode 100644 index 000000000..e42cd6595 --- /dev/null +++ b/web/app/v2/debug/copy.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +export const DEBUG_COPY = { + label: 'Deep dive', + note: 'Everything the run recorded. The Run view shows what it means.', + back: '← Run', + resolving: 'Finding the current run…', + runUnresolved: 'Could not find a run for this Flow ID.', + openLabel: 'Deep dive', +} as const; From 7bb3bf37a4f42f6217146c60077ffd964ba0ae6e Mon Sep 17 00:00:00 2001 From: zzheng Date: Sat, 19 Sep 2026 19:27:02 -0700 Subject: [PATCH 05/21] Make the Run view the semantic Admin view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three panes with the weights the job wants: a narrow run switcher, the canvas wide, and a drawer for details and actions. Before this, one left aside stacked a flow-type picker, a filter builder, a result list, the Display fields and the Actions, and the canvas — the thing the view is about — got what was left. Choosing a run now lands on where it stopped. The canvas reveals and selects the Step the run is waiting on, and the drawer's top band names it with the templated why-line the canvas already had. Clicking another Step moves the band and nothing else: the run's facts and its Actions do not move, because Actions are why an Admin opened the run. They are sticky to the drawer's bottom edge. The Display pane was capped at 42% of the aside it shared, which is why its Reject button fell off the bottom of the window. Its own column has no cap. Promoted: the run's identity — run ID, started, elapsed — which v2 never rendered even though the canvas already fetched it; and Stop, which v2 could not do at all. Scoping a search left for the Queue. One clock. The canvas owned a 5s run poll while the Display pane was a one-shot read, so the diagram moved and the fields silently did not. The canvas now reports each poll and the drawer refreshes on the same beat. Caught while verifying: suppressing nothing left FOUR columns, because the canvas still rendered its own Step panel beside the new drawer — the canvas was squeezed worse than before. The canvas now yields the detail surface to its host. Deferred, deliberately: the Step's WaitFor conditions and Execute branches are definition structure and stay in the Deep Dive. The band carries the Step's dex:explanation, which is the semantic half. --- web/app/v2/RunWorkspace.tsx | 151 +++++++++-------- web/app/v2/V2Canvas.tsx | 77 ++++++++- web/app/v2/V2SplitHandle.tsx | 12 +- web/app/v2/css/v2.css | 191 ++++++++++++++++++++++ web/app/v2/run/RunDetailDrawer.tsx | 75 +++++++++ web/app/v2/run/RunHeader.tsx | 80 +++++++++ web/app/v2/run/RunSwitcher.tsx | 101 ++++++++++++ web/app/v2/run/copy.ts | 24 +++ web/app/v2/workspace/SelectedRunPanel.tsx | 5 +- 9 files changed, 627 insertions(+), 89 deletions(-) create mode 100644 web/app/v2/run/RunDetailDrawer.tsx create mode 100644 web/app/v2/run/RunHeader.tsx create mode 100644 web/app/v2/run/RunSwitcher.tsx create mode 100644 web/app/v2/run/copy.ts diff --git a/web/app/v2/RunWorkspace.tsx b/web/app/v2/RunWorkspace.tsx index db4f014fe..d14fd209b 100644 --- a/web/app/v2/RunWorkspace.tsx +++ b/web/app/v2/RunWorkspace.tsx @@ -7,22 +7,22 @@ // SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 import { useCallback, useRef, useState, type CSSProperties } from 'react'; -import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'; -import { v2DebugPath, v2HomePath, v2RunPath } from './contract'; +import { Navigate, useNavigate, useParams } from 'react-router-dom'; +import type { FlowSummary } from '@/lib/types'; +import { v2HomePath, v2RunPath } from './contract'; import './css/v2.css'; -import { DEBUG_COPY } from './debug/copy'; +import { RunDetailDrawer, type StepBand } from './run/RunDetailDrawer'; +import { RUN_COPY } from './run/copy'; +import { RunSwitcher } from './run/RunSwitcher'; import { V2Canvas } from './V2Canvas'; import { - CASE_HEIGHT_KEY, - LIST_WIDTH_DEFAULT, - LIST_WIDTH_KEY, + DRAWER_WIDTH_DEFAULT, + DRAWER_WIDTH_KEY, V2SplitHandle, readStoredPixels, writeStoredPixels, } from './V2SplitHandle'; import { useWebCatalog } from './WebCatalogProvider'; -import { FlowListing } from './workspace/FlowListing'; -import { SelectedRunPanel } from './workspace/SelectedRunPanel'; import { useFlowSearch } from './workspace/useFlowSearch'; import { useStrandedRuns } from './workspace/useStrandedRuns'; @@ -33,6 +33,12 @@ export function HomePage() { return ; } +/** + * The Admin semantic view: pick a run, see where it is on the canvas, act on it in the drawer. + * + * Selection is narrow and the canvas is wide on purpose. Scoping a search belongs to the Queue, + * and the technical record belongs to the Deep Dive. + */ export function RunWorkspace() { const { flowType = '', flowId = '' } = useParams(); const navigate = useNavigate(); @@ -40,26 +46,24 @@ export function RunWorkspace() { const entry = catalog?.flows.find((candidate) => candidate.flowType === flowType); const search = useFlowSearch(flowType || undefined, entry?.definition); const { strandedFlowIDs, rememberStranded } = useStrandedRuns(); + const [band, setBand] = useState(null); + const [summary, setSummary] = useState(null); + const [tick, setTick] = useState(0); const shellRef = useRef(null); const bodyRef = useRef(null); - const listPaneRef = useRef(null); - const [listWidth, setListWidth] = useState(() => { - const stored = readStoredPixels(LIST_WIDTH_KEY); - return Number.isFinite(stored) ? stored : LIST_WIDTH_DEFAULT; + const [drawerWidth, setDrawerWidth] = useState(() => { + const stored = readStoredPixels(DRAWER_WIDTH_KEY); + return Number.isFinite(stored) ? stored : DRAWER_WIDTH_DEFAULT; }); - const [caseHeight, setCaseHeight] = useState(() => readStoredPixels(CASE_HEIGHT_KEY)); - const commitListWidth = useCallback((width: number) => { + const commitDrawerWidth = useCallback((width: number) => { const next = Math.round(width); - setListWidth(next); - writeStoredPixels(LIST_WIDTH_KEY, next); + setDrawerWidth(next); + writeStoredPixels(DRAWER_WIDTH_KEY, next); }, []); - const commitCaseHeight = useCallback((height: number) => { - const next = Math.round(height); - setCaseHeight(next); - writeStoredPixels(CASE_HEIGHT_KEY, next); - }, []); + // One clock: the canvas owns the run poll and the drawer refreshes on the same beat. + const onTick = useCallback(() => setTick((previous) => previous + 1), []); if (!ready) return
    Loading Dex Web…
    ; if (!canUseV2) return ; @@ -76,65 +80,58 @@ export function RunWorkspace() { if (!entry) return ; const selectedFlow = search.flows.find((flow) => flow.flowId === flowId); - const paneStyle = { - '--v2-list-w': `${listWidth}px`, - ...(Number.isFinite(caseHeight) && caseHeight > 0 ? { '--v2-case-h': `${caseHeight}px` } : {}), - } as CSSProperties; + const paneStyle = { '--v2-drawer-w': `${drawerWidth}px` } as CSSProperties; return ( -
    -
    - +
    +
    + navigate(v2RunPath(next))} + onSelectRun={(nextFlowID) => navigate(v2RunPath(entry.flowType, nextFlowID))} + />
    - +
    + {flowId ? ( + <> + + + + ) : ( +

    {RUN_COPY.selectPrompt}

    + )}
    ); diff --git a/web/app/v2/V2Canvas.tsx b/web/app/v2/V2Canvas.tsx index 6cd8eeee2..437846098 100644 --- a/web/app/v2/V2Canvas.tsx +++ b/web/app/v2/V2Canvas.tsx @@ -9,9 +9,14 @@ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { hydrateBlobs } from '@/lib/blobs'; import { readResponseJSON } from '@/lib/http'; -import type { FlowDefinitionCatalog, FlowHistoryEvent } from '@/lib/types'; +import type { FlowDefinitionCatalog, FlowHistoryEvent, FlowSummary } from '@/lib/types'; import { safeDecode } from './canvas/model/decode'; -import type { RunOverlay } from './canvas/model/run'; +import { + activeStepTypes, + executionsOf, + reasonLine, + type RunOverlay, +} from './canvas/model/run'; import { loadCurrentRun, loadRunHistory, @@ -26,11 +31,13 @@ import { DetailPanel } from './canvas/panel/DetailPanel'; import { buildPanel, type SectionId } from './canvas/panel/panelModel'; import { ArrowDefs } from './canvas/render/ArrowDefs'; import { Stage } from './canvas/render/Stage'; +import type { CanvasViewportHandle } from './canvas/render/viewport'; import { viewById } from './canvas/views'; import type { Detail, Direction } from './canvas/views/types'; import { Controls } from './flow/Controls'; import { Legend } from './flow/Legend'; import { groupsFromDefinition } from './groupsFromGraph'; +import type { StepBand } from './run/RunDetailDrawer'; import { PANEL_WIDTH_DEFAULT, PANEL_WIDTH_KEY, @@ -39,8 +46,26 @@ import { writeStoredPixels, } from './V2SplitHandle'; -export function V2Canvas({ flowType, flowId = '' }: { flowType: string; flowId?: string }) { +export function V2Canvas({ + flowType, + flowId = '', + onBand, + onSummary, + onTick, + showStepPanel = true, +}: { + flowType: string; + flowId?: string; + /** False when the host renders step detail itself, so the canvas keeps its width. */ + showStepPanel?: boolean; + /** What the canvas is showing, so a host drawer can label it without owning selection. */ + onBand?: (band: StepBand | null) => void; + onSummary?: (summary: FlowSummary | null) => void; + /** Fired on every run poll, so a host can refresh on the same beat. */ + onTick?: () => void; +}) { const canvasRef = useRef(null); + const viewportRef = useRef(null); const [catalog, setCatalog] = useState(null); const [detail, setDetail] = useState('collapsed'); const [direction, setDirection] = useState('tb'); @@ -104,6 +129,8 @@ export function V2Canvas({ flowType, flowId = '' }: { flowType: string; flowId?: activeSteps: state?.activeStepExecutions ?? [], })); setRunError(''); + onSummary?.(summary); + onTick?.(); if (summary.flowStatusCode === 1 && timer === undefined) { timer = window.setInterval(() => { void load(); }, 5000); } @@ -149,6 +176,40 @@ export function V2Canvas({ flowType, flowId = '' }: { flowType: string; flowId?: const overlay: RunOverlay | null = bundle?.overlay ?? null; const selectedStep = flow?.steps.find((step) => step.id === selectedId) ?? null; + /** The Step the run is actually waiting on, which is what an Admin came to see. */ + const blockingStepType = overlay ? (activeStepTypes(overlay)[0] ?? null) : null; + + // Choosing a run should land on where it stopped, once, without fighting later clicks. + const revealedFor = useRef(''); + useEffect(() => { + if (!flow || blockingStepType === null) return; + if (revealedFor.current === `${flowId}|${blockingStepType}`) return; + const step = flow.steps.find((candidate) => candidate.stepType === blockingStepType); + if (!step) return; + revealedFor.current = `${flowId}|${blockingStepType}`; + setSelectedId(step.id); + setSelectedGroupId(null); + viewportRef.current?.reveal(step.id); + }, [blockingStepType, flow, flowId]); + + useEffect(() => { + if (!onBand) return; + if (!selectedStep || !overlay) { + onBand(null); + return; + } + const executions = executionsOf(overlay, selectedStep.stepType); + const latest = executions[executions.length - 1]; + const reason = latest ? reasonLine(latest, Date.now()) : null; + onBand({ + stepType: selectedStep.stepType, + explanation: selectedStep.explanation ?? null, + reason: reason?.text ?? null, + tone: reason?.tone ?? null, + isBlocking: selectedStep.stepType === blockingStepType, + }); + }, [blockingStepType, onBand, overlay, selectedStep]); + const scene = useMemo(() => { if (!flow) return null; return viewById('control').layout(flow, { @@ -271,7 +332,8 @@ export function V2Canvas({ flowType, flowId = '' }: { flowType: string; flowId?: return
    No valid Flow Definition Graph 2.0 file for this Flow type.
    ; } - const canvasStyle = panel + const ownsPanel = showStepPanel && panel !== null; + const canvasStyle = ownsPanel ? ({ '--v2-panel-w': `${Math.round(panelWidth)}px` } as CSSProperties) : undefined; @@ -283,12 +345,13 @@ export function V2Canvas({ flowType, flowId = '' }: { flowType: string; flowId?:
    {runError ?

    {runError}

    : null} { setSelectedGroupId(id); setSelectedId(null); @@ -329,9 +392,9 @@ export function V2Canvas({ flowType, flowId = '' }: { flowType: string; flowId?: ) : null}
    )} - fitKey={`${flowType}|${flowId}|${detail}|${direction}|${selected.file}|${overlay?.executions.length ?? 0}|${panel ? `panel:${Math.round(panelWidth)}` : 'graph'}`} + fitKey={`${flowType}|${flowId}|${detail}|${direction}|${selected.file}|${overlay?.executions.length ?? 0}|${ownsPanel ? `panel:${Math.round(panelWidth)}` : 'graph'}`} /> - {panel ? ( + {ownsPanel && panel ? ( <> ; measureRef: RefObject; value: number; @@ -113,7 +115,9 @@ export function V2SplitHandle({ const clampLive = useCallback((desired: number) => { const box = measureRef.current?.getBoundingClientRect(); if (!box) return desired; - if (cssVariable === '--v2-panel-w') return clampPanelWidth(desired, box.width); + if (cssVariable === '--v2-panel-w' || cssVariable === '--v2-drawer-w') { + return clampPanelWidth(desired, box.width); + } if (cssVariable === '--v2-def-h') return clampDefHeight(desired, box.height); return axis === 'column' ? clampListWidth(desired, box.width) @@ -127,7 +131,7 @@ export function V2SplitHandle({ if (!box) return; let floor = LIST_WIDTH_MIN; let ceiling = box.width; - if (cssVariable === '--v2-panel-w') { + if (cssVariable === '--v2-panel-w' || cssVariable === '--v2-drawer-w') { floor = PANEL_WIDTH_MIN; ceiling = Math.max(floor, Math.min(box.width * 0.7, box.width - PANEL_CANVAS_REMAIN_MIN)); } else if (cssVariable === '--v2-def-h') { @@ -148,7 +152,7 @@ export function V2SplitHandle({ if (event.button !== 0) return; const box = measureRef.current?.getBoundingClientRect(); if (!box) return; - const fallback = cssVariable === '--v2-panel-w' + const fallback = cssVariable === '--v2-panel-w' || cssVariable === '--v2-drawer-w' ? PANEL_WIDTH_DEFAULT : cssVariable === '--v2-def-h' ? Math.min(DEF_HEIGHT_DEFAULT, Math.max(DEF_HEIGHT_MIN, box.height - EXEC_REMAIN_MIN)) diff --git a/web/app/v2/css/v2.css b/web/app/v2/css/v2.css index 3248daeba..55dd1b5cb 100644 --- a/web/app/v2/css/v2.css +++ b/web/app/v2/css/v2.css @@ -222,6 +222,191 @@ font-size: 12px; } +/* ------------------------------ run mode: switcher | canvas | run drawer */ + +.v2-shell.v2-run { + min-height: 0; + height: 100%; + font-family: var(--font-sans); +} + +.v2-run-body { + position: relative; + display: grid; + grid-template-columns: 15rem minmax(0, 1fr); + height: 100%; + min-height: 0; +} + +.v2-run-body[data-has-run='true'] { + grid-template-columns: 15rem minmax(0, 1fr) var(--v2-drawer-w, 25rem); +} + +.rsw { + display: flex; + overflow: hidden; + flex-direction: column; + min-width: 0; + min-height: 0; + padding: 14px 12px; + border-right: 1px solid var(--p-line-soft); +} + +.rsw .sv-choose { + flex-direction: column; + align-items: stretch; +} + +.rsw-list { + flex: 1 1 auto; + min-height: 0; + overflow: auto; +} + +.rsw-row { + display: grid; + width: 100%; + gap: 2px; + padding: 6px 8px; + border-radius: 5px; + text-align: left; +} + +.rsw-row:hover { + background: var(--p-surface-2); +} + +.sq-item[data-selected='true'] .rsw-row { + background: var(--p-surface-3); +} + +.rsw-state { + color: var(--p-ink-2); + font-size: 10px; +} + +.v2-run-empty { + padding: 18px 20px; +} + +/* The drawer scrolls; the Actions block does not leave the viewport. */ +.rdw { + display: flex; + overflow-y: auto; + flex-direction: column; + min-width: 0; + min-height: 0; + border-left: 1px solid var(--p-line-soft); +} + +.rdw .v2-case { + flex: 1 1 auto; + max-height: none; + height: auto; + border-top: 0; +} + +.rdw .sc-block:last-of-type { + position: sticky; + bottom: 0; + margin-top: auto; + padding-top: 10px; + border-top: 1px solid var(--p-line-soft); + background: var(--p-surface-1); +} + +.rhd { + position: sticky; + top: 0; + z-index: 1; + padding: 12px 16px 10px; + border-bottom: 1px solid var(--p-line-soft); + background: var(--p-surface-1); +} + +.rhd-line { + display: flex; + align-items: baseline; + gap: 8px; +} + +.rhd-id { + color: var(--p-ink-0); + font-size: 12.5px; + font-weight: 600; + overflow-wrap: anywhere; +} + +.rhd-facts { + display: grid; + margin-top: 8px; + grid-template-columns: max-content 1fr; + gap: 2px 12px; +} + +.rhd-facts > div { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; +} + +.rhd-facts dt { + color: var(--p-ink-3); + font-size: 9.5px; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.rhd-facts dd { + color: var(--p-ink-1); + font-size: 10.5px; + overflow-wrap: anywhere; +} + +.rhd-stop { + margin-top: 10px; + padding: 4px 12px; + border: 1px solid var(--p-run-failed); + border-radius: 5px; + color: var(--p-run-failed); + font-size: 11px; +} + +/* Follows the canvas selection; the facts and Actions below it do not move. */ +.rdw-band { + display: grid; + gap: 2px; + padding: 10px 16px; + border-bottom: 1px solid var(--p-line-soft); + background: var(--p-surface-2); +} + +.rdw-bandlabel { + color: var(--p-ink-3); + font-size: 9.5px; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.rdw-band[data-tone='blocked'] .rdw-bandlabel { + color: var(--p-external); +} + +.rdw-band[data-tone='failed'] .rdw-bandlabel { + color: var(--p-run-failed); +} + +.rdw-bandstep { + color: var(--p-ink-0); + font-size: 11.5px; +} + +.rdw-bandwhy { + color: var(--p-ink-2); + font-size: 10px; + line-height: 1.45; +} + /* -------------------------------------------------- queue mode, no canvas */ .v2-shell.v2-queue { @@ -254,3 +439,9 @@ .v2-seemore:hover { color: var(--p-ink-1); } + +.rdw-bandwhat { + color: var(--p-ink-1); + font-size: 10.5px; + line-height: 1.45; +} diff --git a/web/app/v2/run/RunDetailDrawer.tsx b/web/app/v2/run/RunDetailDrawer.tsx new file mode 100644 index 000000000..dbf51ed39 --- /dev/null +++ b/web/app/v2/run/RunDetailDrawer.tsx @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import type { FlowV2Definition } from '@superdurable/flow-definition-renderer'; +import type { FlowSummary } from '@/lib/types'; +import { SelectedRunPanel } from '../workspace/SelectedRunPanel'; +import { RUN_COPY } from './copy'; +import { RunHeader } from './RunHeader'; + +/** What the canvas is currently showing, so the drawer can say so without owning selection. */ +export interface StepBand { + stepType: string; + /** One-sentence purpose from dex:explanation, when the Flow declares it. */ + explanation: string | null; + /** The templated why-line the canvas already renders, or null for a healthy Step. */ + reason: string | null; + tone: 'blocked' | 'failed' | 'terminal' | null; + /** True when this is the Step the run is actually waiting on. */ + isBlocking: boolean; +} + +/** + * Run facts and Actions stay put; only the top band follows the canvas. Actions are never + * hidden behind a tab or a scroll, because they are the reason an Admin opened the run. + */ +export function RunDetailDrawer({ + flowType, + flowId, + definition, + summary, + flowStatusCode, + band, + reloadKey, + onStranded, + onStopped, +}: { + flowType: string; + flowId: string; + definition: FlowV2Definition; + summary: FlowSummary | null; + flowStatusCode?: number; + band: StepBand | null; + reloadKey: number; + onStranded?: (flowID: string) => void; + onStopped: () => void; +}) { + return ( +
    + + {band && ( +
    + + {band.isBlocking ? RUN_COPY.waitingAt : RUN_COPY.showingStep} + + {band.stepType} + {band.explanation && {band.explanation}} + {band.reason && {band.reason}} +
    + )} + +
    + ); +} diff --git a/web/app/v2/run/RunHeader.tsx b/web/app/v2/run/RunHeader.tsx new file mode 100644 index 000000000..62b76ec8e --- /dev/null +++ b/web/app/v2/run/RunHeader.tsx @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { StopFlowDialog } from '@/app/flows/details/StopFlowDialog'; +import { formatDate, formatDuration } from '@/lib/format'; +import type { FlowSummary } from '@/lib/types'; +import { usePreferences } from '../../providers'; +import { v2DebugPath } from '../contract'; +import { DEBUG_COPY } from '../debug/copy'; +import { isOpenFlowStatusCode } from '../queue/liveness'; +import { RUN_COPY } from './copy'; + +/** + * The run's own identity, which v2 never rendered even though the canvas already fetches it. + * Stop lives here because v2 could not stop a run at all before. + */ +export function RunHeader({ + flowType, + flowId, + summary, + onStopped, +}: { + flowType: string; + flowId: string; + summary: FlowSummary | null; + onStopped: () => void; +}) { + const { timezone } = usePreferences(); + const [stopOpen, setStopOpen] = useState(false); + const isOpen = isOpenFlowStatusCode(summary?.flowStatusCode); + return ( +
    +
    + {flowId} + {summary && {summary.flowStatus}} + + {DEBUG_COPY.openLabel} + +
    + {summary && ( +
    +
    Run
    {summary.runId}
    +
    Started
    {formatDate(summary.startTime, timezone)}
    +
    +
    {isOpen ? RUN_COPY.elapsed : RUN_COPY.closed}
    +
    + {isOpen + ? formatDuration(summary.startTime, null) + : formatDate(summary.closeTime, timezone)} +
    +
    +
    + )} + {summary && isOpen && ( + + )} + {summary && ( + setStopOpen(false)} + onStopped={() => { + setStopOpen(false); + onStopped(); + }} + /> + )} +
    + ); +} + diff --git a/web/app/v2/run/RunSwitcher.tsx b/web/app/v2/run/RunSwitcher.tsx new file mode 100644 index 000000000..a2d10aa0e --- /dev/null +++ b/web/app/v2/run/RunSwitcher.tsx @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import type { V2CatalogEntry, V2Flow } from '@/lib/types'; +import { QUEUE_COPY } from '../queue/copy'; +import type { FlowSearch } from '../workspace/useFlowSearch'; +import { RUN_COPY } from './copy'; + +/** + * Pick a run, nothing more. The filter builder, Search and pager belong to the Queue: + * an Admin driving one run does not scope a search, and they cost the canvas its width. + */ +export function RunSwitcher({ + entry, + flowTypes, + search, + selectedFlowID, + strandedFlowIDs, + attentionAttributeKey, + onSelectFlowType, + onSelectRun, +}: { + entry: V2CatalogEntry; + flowTypes: V2CatalogEntry[]; + search: FlowSearch; + selectedFlowID: string; + strandedFlowIDs: ReadonlySet; + /** First indexed Attribute, shown under the run id as its own value. */ + attentionAttributeKey: string | null; + onSelectFlowType: (flowType: string) => void; + onSelectRun: (flowID: string) => void; +}) { + const { flows, liveness, loading } = search; + return ( + + ); +} + +/** The Flow's own indexed value when it has one, else the execution status. */ +function runStateText(flow: V2Flow, attentionAttributeKey: string | null): string { + if (attentionAttributeKey === null) return flow.flowStatus; + const value = flow.indexedAttributes[attentionAttributeKey]; + if (value === null || value === undefined || value === '') return flow.flowStatus; + return String(value); +} diff --git a/web/app/v2/run/copy.ts b/web/app/v2/run/copy.ts new file mode 100644 index 000000000..decd57c3c --- /dev/null +++ b/web/app/v2/run/copy.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +export const RUN_COPY = { + runsHeading: 'Runs', + noRuns: 'No runs of this Flow type.', + selectPrompt: 'Select a run to see where it is and what it needs.', + /** The step the canvas is showing, which is not always the one that needs somebody. */ + showingStep: 'Showing', + waitingAt: 'Waiting at', + actionsHeading: 'Actions', + displayHeading: 'Reported', + noActions: 'No Actions are available in the current state.', + stop: 'Stop', + stopped: 'Stopping…', + deepDive: 'Deep dive', + elapsed: 'Running for', + closed: 'Closed', +} as const; diff --git a/web/app/v2/workspace/SelectedRunPanel.tsx b/web/app/v2/workspace/SelectedRunPanel.tsx index b24c6e69e..7ed194048 100644 --- a/web/app/v2/workspace/SelectedRunPanel.tsx +++ b/web/app/v2/workspace/SelectedRunPanel.tsx @@ -26,6 +26,7 @@ export function SelectedRunPanel({ definition, flowStatusCode, footer, + reloadKey = 0, onStranded, }: { flowType: string; @@ -34,6 +35,8 @@ export function SelectedRunPanel({ /** From the search row, so a dead worker can be told apart from a closed run. */ flowStatusCode?: number; footer?: ReactNode; + /** Bumped by the view's shared clock, so the fields and the canvas move together. */ + reloadKey?: number; /** Reported up so the list can mark the row; a search cannot discover this. */ onStranded?: (flowID: string) => void; }) { @@ -63,7 +66,7 @@ export function SelectedRunPanel({ // A new run must not inherit the previous run's values while its own read is in flight. useEffect(() => { setHeld(nothingHeld()); }, [flowId, flowType]); - useEffect(() => { void loadDisplay(); }, [loadDisplay]); + useEffect(() => { void loadDisplay(); }, [loadDisplay, reloadKey]); const result = held.value; const isStranded = held.liveness === 'stranded'; From 7a842bcf6db697fd30bccccea7dc6926e7d18063 Mon Sep 17 00:00:00 2001 From: zzheng Date: Sat, 19 Sep 2026 21:06:59 -0700 Subject: [PATCH 06/21] Read the run list as a list, and decide before reading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things the Run view got wrong. The runs were numbered 1 to 4. Nothing chose those numbers: the list was an
      and only the queue's own class reset list-style, so they were browser ordinal markers standing in for an order they did not describe. The real order was already there — the search handler sorts by start time descending — so the fix is to state it rather than decorate it, and to lift open runs above closed ones. Which run most needs somebody still cannot be ranked: that wants a declared role. Each run was drawn as a bordered, filled card, so four runs read as four objects competing with the canvas. Now hairlines between rows, a heavier rule between the open and closed groups, and selection as a left accent plus a faint fill. A run is an entry in a list. Actions were below the reported fields and pinned to the drawer's bottom edge. They now sit directly under the band that says what the run is waiting at, which is where somebody who already knows the case looks. The pinning goes with them, because nothing is below the fold any more. The Queue deliberately keeps the other order: a participant needs the evidence before the decision, which is the argument the prototype's panel was built around. One prop, stated at both call sites. "Deep dive" said nothing about what was behind it. "Inspect" borrows the devtools convention, so it signals advanced without a label claiming to, and a bordered mono chip reads as an engineering surface in a design system that already spells technical values in mono. Also: the drawer named the run twice, once in its header and once in the panel below it. --- web/app/v2/css/v2.css | 96 ++++++++++++++--- web/app/v2/queue/QueueWorkspace.tsx | 1 + web/app/v2/run/RunDetailDrawer.tsx | 2 + web/app/v2/run/RunHeader.tsx | 10 +- web/app/v2/run/RunSwitcher.tsx | 49 +++++---- web/app/v2/run/copy.ts | 5 +- web/app/v2/run/runOrder.ts | 40 +++++++ web/app/v2/workspace/SelectedRunPanel.tsx | 123 ++++++++++++++++++++-- web/lib/format.ts | 11 ++ 9 files changed, 296 insertions(+), 41 deletions(-) create mode 100644 web/app/v2/run/runOrder.ts diff --git a/web/app/v2/css/v2.css b/web/app/v2/css/v2.css index 55dd1b5cb..34d2f75fa 100644 --- a/web/app/v2/css/v2.css +++ b/web/app/v2/css/v2.css @@ -257,18 +257,50 @@ align-items: stretch; } -.rsw-list { +.rsw-scroll { flex: 1 1 auto; min-height: 0; overflow: auto; } +/* A stronger rule between groups than between rows: the split is the coarser fact. */ +.rsw-group + .rsw-group { + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid var(--p-line); +} + +.rsw-grouphead { + margin-bottom: 2px; + color: var(--p-ink-3); + font-size: 9px; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.rsw-list { + list-style: none; + margin: 0; + padding: 0; +} + +/* Lines, not boxes: a run is an entry in a list, not a card competing with the canvas. */ +.rsw-item + .rsw-item { + border-top: 1px solid var(--p-line-soft); +} + .rsw-row { display: grid; width: 100%; - gap: 2px; - padding: 6px 8px; - border-radius: 5px; + border: 0; + border-left: 2px solid transparent; + padding: 7px 8px; + background: transparent; + color: inherit; + cursor: pointer; + gap: 1px 8px; + grid-template-columns: minmax(0, 1fr) auto; text-align: left; } @@ -276,13 +308,39 @@ background: var(--p-surface-2); } -.sq-item[data-selected='true'] .rsw-row { +.rsw-item[data-selected='true'] .rsw-row { + border-left-color: var(--p-run-active); background: var(--p-surface-3); } +.rsw-id { + color: var(--p-ink-0); + font-size: 10.5px; + overflow-wrap: anywhere; +} + +.rsw-time { + color: var(--p-ink-3); + font-size: 9.5px; + text-align: right; + white-space: nowrap; +} + .rsw-state { color: var(--p-ink-2); font-size: 10px; + grid-column: 1 / -1; +} + +.rsw-stranded { + color: var(--p-run-failed); + font-size: 9.5px; + grid-column: 1 / -1; +} + +.rsw-item[data-stranded='true'] .rsw-id, +.rsw-item[data-stranded='true'] .rsw-state { + color: var(--p-ink-3); } .v2-run-empty { @@ -306,14 +364,6 @@ border-top: 0; } -.rdw .sc-block:last-of-type { - position: sticky; - bottom: 0; - margin-top: auto; - padding-top: 10px; - border-top: 1px solid var(--p-line-soft); - background: var(--p-surface-1); -} .rhd { position: sticky; @@ -337,6 +387,26 @@ overflow-wrap: anywhere; } +/* Mono plus a border reads as an engineering surface without a label saying so. */ +.rhd-inspect { + display: inline-flex; + margin-left: auto; + align-items: baseline; + gap: 4px; + padding: 2px 7px; + border: 1px solid var(--p-line); + border-radius: 4px; + color: var(--p-ink-2); + font-size: 10px; + letter-spacing: 0.02em; + white-space: nowrap; +} + +.rhd-inspect:hover { + border-color: var(--p-ink-3); + color: var(--p-ink-0); +} + .rhd-facts { display: grid; margin-top: 8px; diff --git a/web/app/v2/queue/QueueWorkspace.tsx b/web/app/v2/queue/QueueWorkspace.tsx index 204e9908d..69efff935 100644 --- a/web/app/v2/queue/QueueWorkspace.tsx +++ b/web/app/v2/queue/QueueWorkspace.tsx @@ -78,6 +78,7 @@ export function QueueWorkspace() { flowId={flowId} flowStatusCode={selectedFlow?.flowStatusCode} flowType={entry.flowType} + order="evidence-first" onStranded={rememberStranded} footer={( diff --git a/web/app/v2/run/RunDetailDrawer.tsx b/web/app/v2/run/RunDetailDrawer.tsx index dbf51ed39..b3d14f30f 100644 --- a/web/app/v2/run/RunDetailDrawer.tsx +++ b/web/app/v2/run/RunDetailDrawer.tsx @@ -67,6 +67,8 @@ export function RunDetailDrawer({ flowId={flowId} flowStatusCode={flowStatusCode} flowType={flowType} + order="actions-first" + showHeading={false} reloadKey={reloadKey} onStranded={onStranded} /> diff --git a/web/app/v2/run/RunHeader.tsx b/web/app/v2/run/RunHeader.tsx index 62b76ec8e..f95361f44 100644 --- a/web/app/v2/run/RunHeader.tsx +++ b/web/app/v2/run/RunHeader.tsx @@ -13,7 +13,6 @@ import { formatDate, formatDuration } from '@/lib/format'; import type { FlowSummary } from '@/lib/types'; import { usePreferences } from '../../providers'; import { v2DebugPath } from '../contract'; -import { DEBUG_COPY } from '../debug/copy'; import { isOpenFlowStatusCode } from '../queue/liveness'; import { RUN_COPY } from './copy'; @@ -40,8 +39,13 @@ export function RunHeader({
      {flowId} {summary && {summary.flowStatus}} - - {DEBUG_COPY.openLabel} + + {RUN_COPY.inspect} +
      {summary && ( diff --git a/web/app/v2/run/RunSwitcher.tsx b/web/app/v2/run/RunSwitcher.tsx index a2d10aa0e..a58f26c2f 100644 --- a/web/app/v2/run/RunSwitcher.tsx +++ b/web/app/v2/run/RunSwitcher.tsx @@ -6,10 +6,13 @@ // // SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 +import { formatTimeOfDay } from '@/lib/format'; import type { V2CatalogEntry, V2Flow } from '@/lib/types'; +import { usePreferences } from '../../providers'; import { QUEUE_COPY } from '../queue/copy'; import type { FlowSearch } from '../workspace/useFlowSearch'; import { RUN_COPY } from './copy'; +import { groupRuns } from './runOrder'; /** * Pick a run, nothing more. The filter builder, Search and pager belong to the Queue: @@ -35,7 +38,9 @@ export function RunSwitcher({ onSelectFlowType: (flowType: string) => void; onSelectRun: (flowID: string) => void; }) { + const { timezone } = usePreferences(); const { flows, liveness, loading } = search; + const groups = groupRuns(flows); return ( ); } diff --git a/web/app/v2/run/copy.ts b/web/app/v2/run/copy.ts index decd57c3c..1802f7e50 100644 --- a/web/app/v2/run/copy.ts +++ b/web/app/v2/run/copy.ts @@ -9,6 +9,8 @@ export const RUN_COPY = { runsHeading: 'Runs', noRuns: 'No runs of this Flow type.', + /** The order is stated rather than left for the reader to infer. */ + order: 'Open first, then newest.', selectPrompt: 'Select a run to see where it is and what it needs.', /** The step the canvas is showing, which is not always the one that needs somebody. */ showingStep: 'Showing', @@ -18,7 +20,8 @@ export const RUN_COPY = { noActions: 'No Actions are available in the current state.', stop: 'Stop', stopped: 'Stopping…', - deepDive: 'Deep dive', + inspect: 'Inspect', + inspectHint: 'The full technical record for this run', elapsed: 'Running for', closed: 'Closed', } as const; diff --git a/web/app/v2/run/runOrder.ts b/web/app/v2/run/runOrder.ts new file mode 100644 index 000000000..5fd036a67 --- /dev/null +++ b/web/app/v2/run/runOrder.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import type { V2Flow } from '@/lib/types'; +import { isOpenFlowStatusCode } from '../queue/liveness'; + +export type RunGroupKey = 'open' | 'closed'; + +export interface RunGroup { + key: RunGroupKey; + label: string; + flows: V2Flow[]; +} + +const GROUP_LABEL: Record = { open: 'Open', closed: 'Closed' }; + +/** + * Open runs first, then the server's own newest-first order within each group. + * + * A stable partition, so the order is never invented: /api/v2/search already sorts by start + * time descending, and status is read from the run rather than inferred. Which run most needs + * somebody cannot be ranked until the contract declares a role. + * + * Per page only, which is honest here because Run mode has no pager. + */ +export function groupRuns(flows: readonly V2Flow[]): RunGroup[] { + const open: V2Flow[] = []; + const closed: V2Flow[] = []; + for (const flow of flows) { + (isOpenFlowStatusCode(flow.flowStatusCode) ? open : closed).push(flow); + } + return ([['open', open], ['closed', closed]] as const) + .filter(([, group]) => group.length > 0) + .map(([key, group]) => ({ key, label: GROUP_LABEL[key], flows: group })); +} diff --git a/web/app/v2/workspace/SelectedRunPanel.tsx b/web/app/v2/workspace/SelectedRunPanel.tsx index 7ed194048..cd51c7d37 100644 --- a/web/app/v2/workspace/SelectedRunPanel.tsx +++ b/web/app/v2/workspace/SelectedRunPanel.tsx @@ -27,6 +27,8 @@ export function SelectedRunPanel({ flowStatusCode, footer, reloadKey = 0, + order, + showHeading = true, onStranded, }: { flowType: string; @@ -37,6 +39,13 @@ export function SelectedRunPanel({ footer?: ReactNode; /** Bumped by the view's shared clock, so the fields and the canvas move together. */ reloadKey?: number; + /** + * Admin already knows the case, so Run puts the decision first. A participant needs the + * evidence before the decision, so the Queue reads the other way. + */ + order: 'actions-first' | 'evidence-first'; + /** False when the host already names the run, so it is not named twice. */ + showHeading?: boolean; /** Reported up so the list can mark the row; a search cannot discover this. */ onStranded?: (flowID: string) => void; }) { @@ -119,12 +128,14 @@ export function SelectedRunPanel({ return (
      -
      - {flowId} - {result && {result.flowStatus}} - {held.liveness === 'stale' && {QUEUE_COPY.staleShort}} - {footer} -
      + {(showHeading || footer || held.liveness === 'stale') && ( +
      + {showHeading && {flowId}} + {showHeading && result && {result.flowStatus}} + {held.liveness === 'stale' && {QUEUE_COPY.staleShort}} + {footer} +
      + )} {isStranded &&

      {QUEUE_COPY.stranded}

      } {held.liveness === 'unreachable' &&

      {held.reason}

      } {held.liveness === 'stale' &&

      {held.reason}

      } @@ -132,6 +143,104 @@ export function SelectedRunPanel({ {actionError &&

      {actionError}

      } {result && !isStranded && ( <> + {order === 'actions-first' ? (<> +
      +
      Actions
      + {visibleV2Actions(definition.actions, result.eligibleActions).map((action) => { + const userFields = v2ActionUserFields(action); + return ( +
      { + event.preventDefault(); + void invokeAction(action); + }} + > + {userFields.map((field) => ( + + ))} + +
      + ); + })} + {definition.actions.length > 0 && result.eligibleActions.length === 0 && ( +

      No Actions are available in the current state.

      + )} +
      + +
      +
      Display
      +
      + {definition.display.fields.map((field) => { + const isEditing = editingKey === field.attributeKey; + return ( +
      +
      {field.description}
      +
      + {isEditing ? ( + <> + + + + {fieldErrors[field.attributeKey] && ( + {fieldErrors[field.attributeKey]} + )} + + ) : ( + <> + {displayValue(result.display[field.attributeKey])} + {field.editable && result.isActive && ( + + )} + + )} +
      +
      + ); + })} +
      +
      +) : (<>
      Display
      @@ -184,6 +293,7 @@ export function SelectedRunPanel({ })}
      +
      Actions
      {visibleV2Actions(definition.actions, result.eligibleActions).map((action) => { @@ -227,6 +337,7 @@ export function SelectedRunPanel({

      No Actions are available in the current state.

      )}
      +)} )}
      diff --git a/web/lib/format.ts b/web/lib/format.ts index 4000ae437..6d60b9f8d 100644 --- a/web/lib/format.ts +++ b/web/lib/format.ts @@ -19,6 +19,17 @@ export function formatDate(value: string | null, timezone: TimezonePreference): }).format(date); } +/** Time only, for a narrow column where the date would not fit and rarely differs. */ +export function formatTimeOfDay(value: string | null, timezone: TimezonePreference): string { + if (!value) return '—'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '—'; + return new Intl.DateTimeFormat(undefined, { + timeStyle: 'short', + timeZone: timezone === 'UTC' ? 'UTC' : undefined, + }).format(date); +} + export function formatDuration(start: string | null, close: string | null): string { if (!start) return '—'; const startMs = Date.parse(start); From 7193fdf5a519a5525938ab59276bfb578a925098 Mon Sep 17 00:00:00 2001 From: zzheng Date: Sat, 19 Sep 2026 22:25:04 -0700 Subject: [PATCH 07/21] Fit and finish across the Run view and the canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type. The v2 ramp ran 9 to 11.5px, which is below comfortable reading size for prose values like a recommendation rationale. Everything moves up one tier. Fit. Fit was capped at 1:1 on the reasoning that a three-Step flow blown up to fill a wall reads as a mistake, but the cap also left a wide canvas mostly empty whenever the graph already fitted. Raised, and the padding trimmed — the top reserved 22% for a control bar that needs about 6%. Measured: the graph now uses 89% of the width and 80% of the height, up from 82% and 73%. Arrowheads. They are markerUnits="userSpaceOnUse", so unlike the stroke they do not take the zoom compensation — a 7-unit head renders as 3.5 screen pixels at half zoom, which is why direction was unreadable without zooming in. Enlarged. A dimmed edge was at 0.24 opacity, so selecting a Step made the rest of the skeleton vanish rather than recede; it now stays a visible line. Choosing a run with an open Action zooms in on the Step it stopped at, rather than panning to it at whatever zoom was current, and closing the panel gives the whole graph back. The zoom is a prop rather than an imperative call, because the drawer opening resizes the pane and the resulting refit was racing the focus and winning. The drawer has a close button, and taking an Action closes it: the thing it was opened for has been resolved, and the run's status updates in both panels. Timezone follows absolute timestamps into the Deep Dive, which is the only view that turns on them. "Work queue" is now "Inbox" — the most familiar name for a place things arrive for you, and role-specific in a way a word like "queue" is not. A stripe under the canvas was the run-selection prompt: a third grid child with nowhere to sit, so it wrapped onto its own row. It is hover help on the Runs heading now, the canvas reaches the bottom, and the order sentence goes with it. Flow type is a select rather than a stack of buttons. --- web/app/components/AppHeader.tsx | 6 ++- web/app/v2/RunWorkspace.tsx | 19 ++++--- web/app/v2/V2Canvas.tsx | 21 ++++++-- web/app/v2/canvas/render/ArrowDefs.tsx | 22 ++++---- web/app/v2/canvas/render/Stage.tsx | 36 +++++++++++-- web/app/v2/canvas/render/viewport.ts | 4 +- web/app/v2/canvas/render/zoom.ts | 10 +++- web/app/v2/css/canvas.css | 6 +-- web/app/v2/css/listing.css | 40 +++++++------- web/app/v2/css/v2.css | 63 +++++++++++++++-------- web/app/v2/queue/copy.ts | 4 +- web/app/v2/run/RunDetailDrawer.tsx | 11 +++- web/app/v2/run/RunHeader.tsx | 5 ++ web/app/v2/run/RunSwitcher.tsx | 42 +++++++-------- web/app/v2/run/copy.ts | 1 + web/app/v2/workspace/SelectedRunPanel.tsx | 4 ++ 16 files changed, 197 insertions(+), 97 deletions(-) diff --git a/web/app/components/AppHeader.tsx b/web/app/components/AppHeader.tsx index 03a37698b..c67d916e6 100644 --- a/web/app/components/AppHeader.tsx +++ b/web/app/components/AppHeader.tsx @@ -14,7 +14,7 @@ import { ThemeToggle } from './ThemeToggle'; const V2_MODES: { mode: V2Mode; label: string }[] = [ { mode: 'run', label: 'Run' }, - { mode: 'queue', label: 'Work queue' }, + { mode: 'queue', label: 'Inbox' }, ]; export function AppHeader() { @@ -24,6 +24,8 @@ export function AppHeader() { const navigate = useNavigate(); const isV2 = location.pathname === '/v2' || location.pathname.startsWith('/v2/'); const activeMode: V2Mode = location.pathname.startsWith('/v2/queue') ? 'queue' : 'run'; + // Absolute timestamps are a Deep Dive concern; the other views show relative or local time. + const isDebug = location.pathname.includes('/debug'); const home = canUseV2 && isV2 ? v2HomePath(canUseV2) : '/v1/flows'; return (
      @@ -84,6 +86,7 @@ export function AppHeader() { )} + {(!isV2 || isDebug) && ( + )}
      diff --git a/web/app/v2/RunWorkspace.tsx b/web/app/v2/RunWorkspace.tsx index d14fd209b..9ca41a6b9 100644 --- a/web/app/v2/RunWorkspace.tsx +++ b/web/app/v2/RunWorkspace.tsx @@ -6,13 +6,12 @@ // // SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 -import { useCallback, useRef, useState, type CSSProperties } from 'react'; +import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'; import { Navigate, useNavigate, useParams } from 'react-router-dom'; import type { FlowSummary } from '@/lib/types'; import { v2HomePath, v2RunPath } from './contract'; import './css/v2.css'; import { RunDetailDrawer, type StepBand } from './run/RunDetailDrawer'; -import { RUN_COPY } from './run/copy'; import { RunSwitcher } from './run/RunSwitcher'; import { V2Canvas } from './V2Canvas'; import { @@ -49,6 +48,8 @@ export function RunWorkspace() { const [band, setBand] = useState(null); const [summary, setSummary] = useState(null); const [tick, setTick] = useState(0); + /** Dismissing the drawer keeps the run on the canvas; it just hands the width back. */ + const [drawerOpen, setDrawerOpen] = useState(true); const shellRef = useRef(null); const bodyRef = useRef(null); const [drawerWidth, setDrawerWidth] = useState(() => { @@ -65,6 +66,9 @@ export function RunWorkspace() { // One clock: the canvas owns the run poll and the drawer refreshes on the same beat. const onTick = useCallback(() => setTick((previous) => previous + 1), []); + // A newly chosen run always opens its drawer, even if the last one was dismissed. + useEffect(() => { setDrawerOpen(true); }, [flowId]); + if (!ready) return
      Loading Dex Web…
      ; if (!canUseV2) return ; if (error) return
      {error}
      ; @@ -80,10 +84,11 @@ export function RunWorkspace() { if (!entry) return ; const selectedFlow = search.flows.find((flow) => flow.flowId === flowId); + const showDrawer = Boolean(flowId) && drawerOpen; const paneStyle = { '--v2-drawer-w': `${drawerWidth}px` } as CSSProperties; return (
      -
      +
    - {flowId ? ( + {showDrawer ? ( <> setDrawerOpen(false)} onStopped={search.runSearch} onStranded={rememberStranded} /> - ) : ( -

    {RUN_COPY.selectPrompt}

    - )} + ) : null}
    ); diff --git a/web/app/v2/V2Canvas.tsx b/web/app/v2/V2Canvas.tsx index 437846098..1858ed01a 100644 --- a/web/app/v2/V2Canvas.tsx +++ b/web/app/v2/V2Canvas.tsx @@ -52,10 +52,13 @@ export function V2Canvas({ onBand, onSummary, onTick, + focusBlockingStep = false, showStepPanel = true, }: { flowType: string; flowId?: string; + /** Zoom in on the waiting Step instead of merely panning to it. */ + focusBlockingStep?: boolean; /** False when the host renders step detail itself, so the canvas keeps its width. */ showStepPanel?: boolean; /** What the canvas is showing, so a host drawer can label it without owning selection. */ @@ -183,14 +186,21 @@ export function V2Canvas({ const revealedFor = useRef(''); useEffect(() => { if (!flow || blockingStepType === null) return; - if (revealedFor.current === `${flowId}|${blockingStepType}`) return; + const key = `${flowId}|${blockingStepType}|${String(focusBlockingStep)}`; + if (revealedFor.current === key) return; const step = flow.steps.find((candidate) => candidate.stepType === blockingStepType); if (!step) return; - revealedFor.current = `${flowId}|${blockingStepType}`; - setSelectedId(step.id); + revealedFor.current = key; + // Selection only. The zoom is declarative via focusNodeId, so the pane resize that + // follows the drawer opening refits to the Step instead of racing an imperative call. + setSelectedId(focusBlockingStep ? step.id : null); setSelectedGroupId(null); - viewportRef.current?.reveal(step.id); - }, [blockingStepType, flow, flowId]); + }, [blockingStepType, flow, flowId, focusBlockingStep]); + + const focusNodeId = useMemo(() => { + if (!focusBlockingStep || !flow || blockingStepType === null) return null; + return flow.steps.find((step) => step.stepType === blockingStepType)?.id ?? null; + }, [blockingStepType, flow, focusBlockingStep]); useEffect(() => { if (!onBand) return; @@ -345,6 +355,7 @@ export function V2Canvas({
    {runError ?

    {runError}

    : null} @@ -71,8 +75,8 @@ export function ArrowDefs(): JSX.Element { viewBox="0 0 12 12" refX="10" refY="6" - markerWidth="9" - markerHeight="9" + markerWidth="13" + markerHeight="13" markerUnits="userSpaceOnUse" orient="auto-start-reverse" > @@ -90,8 +94,8 @@ export function ArrowDefs(): JSX.Element { viewBox="0 0 10 10" refX="5" refY="5" - markerWidth="6" - markerHeight="6" + markerWidth="9" + markerHeight="9" markerUnits="userSpaceOnUse" orient="auto" > @@ -115,8 +119,8 @@ export function ArrowDefs(): JSX.Element { viewBox="0 0 10 10" refX="8" refY="5" - markerWidth="7" - markerHeight="7" + markerWidth="11" + markerHeight="11" markerUnits="userSpaceOnUse" orient="auto-start-reverse" > diff --git a/web/app/v2/canvas/render/Stage.tsx b/web/app/v2/canvas/render/Stage.tsx index 3a211c4ed..545af79e1 100644 --- a/web/app/v2/canvas/render/Stage.tsx +++ b/web/app/v2/canvas/render/Stage.tsx @@ -35,7 +35,14 @@ import { LEGEND_BOX_H, LEGEND_W, legendAnchor } from './legendAnchor' import type { CanvasViewportHandle } from './viewport' import { EDGE_MARKER, RPC_TAIL } from './markers' import { NodeBox } from './NodeBox' -import { chromeCompensation, edgeContrastBoost, FIT_MAX_ZOOM, MAX_ZOOM, MIN_ZOOM } from './zoom' +import { + chromeCompensation, + edgeContrastBoost, + FIT_MAX_ZOOM, + FOCUS_ZOOM, + MAX_ZOOM, + MIN_ZOOM, +} from './zoom' import '@xyflow/react/dist/style.css' /** @@ -172,7 +179,7 @@ const NODE_TYPES = { pbox: BoxNode, band: BandNode, legend: LegendNode } * the layout — so a fit that used an even padding put the first Step underneath it. */ const FIT_BASE = { - padding: { top: '22%', bottom: '6%', left: '6%', right: '6%' }, + padding: { top: '8%', bottom: '4%', left: '4%', right: '4%' }, minZoom: MIN_ZOOM, maxZoom: FIT_MAX_ZOOM, } as const @@ -181,7 +188,7 @@ function fitOptions(insetRightPx: number | null | undefined, paneWidth: number) if (insetRightPx == null || insetRightPx <= 0 || paneWidth <= 0) return FIT_BASE const rightPct = Math.min(58, Math.max(18, (insetRightPx / paneWidth) * 100 + 4)) return { - padding: { top: '22%', bottom: '6%', left: '6%', right: `${rightPct}%` }, + padding: { top: '8%', bottom: '4%', left: '4%', right: `${rightPct}%` }, minZoom: MIN_ZOOM, maxZoom: FIT_MAX_ZOOM, } as const @@ -191,6 +198,7 @@ function Inner({ scene, detail, direction, + focusNodeId, insetRightPx, legend, handleRef, @@ -228,6 +236,8 @@ function Inner({ onInspect?: (id: string) => void /** Rendered as a node at the diagram's top-left, so it zooms and pans with the drawing. */ legend?: () => JSX.Element + /** When set, fit targets this node at reading zoom instead of the whole graph. */ + focusNodeId?: string | null /** Publishes fit and zoom so a keyboard shortcut has something to drive. */ handleRef?: Ref /** A click inside a group band's own space, outside any card. `additive` is Cmd (macOS) or Ctrl. */ @@ -480,6 +490,11 @@ function Inner({ zoomOut: () => void rf.zoomOut({ duration: 120 }), fit: () => void rf.fitView(fitOptions(insetRightPx, paneRef.current?.clientWidth ?? 0)), zoom: () => rf.getZoom(), + focus: (nodeId: string) => { + const id = rf.getNode(nodeId) !== undefined ? nodeId : `band:${nodeId}` + if (rf.getNode(id) === undefined) return + void rf.fitView({ nodes: [{ id }], padding: 0.45, maxZoom: FOCUS_ZOOM, duration: 260 }) + }, reveal: (nodeId: string) => { /** * A BAND IS REGISTERED UNDER A PREFIX, and resolving that is this function's job rather than the @@ -495,6 +510,9 @@ function Inner({ [rf, insetRightPx], ) + const focusNodeIdRef = useRef(focusNodeId) + useEffect(() => { focusNodeIdRef.current = focusNodeId }, [focusNodeId]) + /** * Refit when the thing being shown changes — never on zoom. * @@ -509,6 +527,14 @@ function Inner({ cancelAnimationFrame(raf) raf = requestAnimationFrame(() => { if (el.clientWidth < 8 || el.clientHeight < 8) return + const focusId = focusNodeIdRef.current ?? null + const resolved = focusId === null + ? null + : (rf.getNode(focusId) !== undefined ? focusId : `band:${focusId}`) + if (resolved !== null && rf.getNode(resolved) !== undefined) { + void rf.fitView({ nodes: [{ id: resolved }], padding: 0.45, maxZoom: FOCUS_ZOOM, duration: 160 }) + return + } void rf.fitView({ ...fitOptions(insetRightPx, el.clientWidth), duration: 160 }) }) } @@ -521,7 +547,7 @@ function Inner({ } /* `insetRightPx` is a dependency because it changes what "fits" MEANS: the reserved strip on the right is part of the fit, so a panel opening or resize has to refit rather than leave the drawing under it. */ - }, [rf, fitKey, insetRightPx]) + }, [rf, fitKey, insetRightPx, focusNodeId]) /** * Zoom compensation, written to CSS custom properties rather than React state. @@ -595,6 +621,8 @@ export function Stage(props: { /** Rendered as a node at the diagram's top-left, so it zooms and pans with the drawing. */ legend?: () => JSX.Element /** Publishes fit and zoom so a keyboard shortcut has something to drive. */ + /** When set, fit targets this node at reading zoom instead of the whole graph. */ + focusNodeId?: string | null handleRef?: Ref /** A click inside a group band's own space, outside any card. `additive` is Cmd (macOS) or Ctrl. */ onSelectGroup?: (id: string | null, additive: boolean) => void diff --git a/web/app/v2/canvas/render/viewport.ts b/web/app/v2/canvas/render/viewport.ts index 4fba6c3e1..56ddf7737 100644 --- a/web/app/v2/canvas/render/viewport.ts +++ b/web/app/v2/canvas/render/viewport.ts @@ -19,6 +19,8 @@ export interface CanvasViewportHandle { fit(): void /** Current zoom, for the readout. */ zoom(): number - /** Pan the minimum distance that brings a node fully into view. */ + /** Pan the minimum distance that brings a node fully into view. Never zooms. */ reveal(nodeId: string): void + /** Zoom in on one node, for when reading it is the point. */ + focus(nodeId: string): void } diff --git a/web/app/v2/canvas/render/zoom.ts b/web/app/v2/canvas/render/zoom.ts index e1d0fd83e..2905e3e92 100644 --- a/web/app/v2/canvas/render/zoom.ts +++ b/web/app/v2/canvas/render/zoom.ts @@ -24,8 +24,14 @@ export const MIN_ZOOM = 0.14 export const MAX_ZOOM = 2.2 -/** Fit never zooms IN past 1:1 — a three-Step flow blown up to fill a wall reads as a mistake. */ -export const FIT_MAX_ZOOM = 1 +/** + * Fit may zoom past 1:1, up to this. The cap used to be 1, which left a wide canvas mostly + * empty whenever the graph already fitted; a small flow still must not fill a wall. + */ +export const FIT_MAX_ZOOM = 1.7 + +/** Focus on one Step zooms in properly — the point is to read it without reaching for zoom. */ +export const FOCUS_ZOOM = 1.45 /** * Edge contrast RISES as you zoom out. diff --git a/web/app/v2/css/canvas.css b/web/app/v2/css/canvas.css index 0b60d356e..38d5eaa35 100644 --- a/web/app/v2/css/canvas.css +++ b/web/app/v2/css/canvas.css @@ -1096,12 +1096,12 @@ carrying the most important distinction. */ .react-flow__edge-path { - stroke-width: calc(1.5px * var(--zoom-comp)); + stroke-width: calc(1.9px * var(--zoom-comp)); } .pedge-control .react-flow__edge-path { stroke: var(--p-edge-control); /* Contrast RISES as the graph shrinks, so the skeleton survives being small. Clamps at 1. */ - opacity: calc(0.82 * var(--edge-boost)); + opacity: calc(0.92 * var(--edge-boost)); } /* Recovery is the thickest line on the canvas — always drawn, and routed to a side gutter rather than hidden behind a toggle. */ @@ -1127,7 +1127,7 @@ opacity: 1; } .pedge-dim .react-flow__edge-path { - opacity: 0.24; + opacity: 0.45; } /* React Flow's own theming surface. Bare --xy-* names; there is no `--xy-background-pattern-color-default`, the fallback is per-variant. */ diff --git a/web/app/v2/css/listing.css b/web/app/v2/css/listing.css index e2a848b4c..930365a4a 100644 --- a/web/app/v2/css/listing.css +++ b/web/app/v2/css/listing.css @@ -42,7 +42,7 @@ .sv-strap { margin-top: 2px; color: var(--p-ink-2); - font-size: 11.5px; + font-size: 12.5px; } .sv-choose { @@ -55,7 +55,7 @@ .sv-chooselabel { color: var(--p-ink-3); - font-size: 10px; + font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; } @@ -65,7 +65,7 @@ border: 1px solid var(--p-line); border-radius: 5px; color: var(--p-ink-1); - font-size: 11.5px; + font-size: 12.5px; } .sv-flow[aria-pressed='true'] { @@ -81,7 +81,7 @@ .sv-nograph { color: var(--p-ink-3); - font-size: 10.5px; + font-size: 11.5px; } .sv-nograph { @@ -110,7 +110,7 @@ } .sq-title { - font-size: 11px; + font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--p-ink-2); @@ -119,14 +119,14 @@ /* The opposite marker from everywhere else in this repo: this one has to say it IS real. */ .sq-live { color: var(--p-ink-3); - font-size: 9px; + font-size: 10px; letter-spacing: 0.06em; } .sq-refresh { margin-left: auto; color: var(--p-ink-2); - font-size: 10px; + font-size: 11px; text-decoration: underline; text-decoration-color: var(--p-line); text-underline-offset: 2px; @@ -135,7 +135,7 @@ .sq-state { margin: 6px 0 10px; color: var(--p-ink-2); - font-size: 10.5px; + font-size: 11.5px; line-height: 1.5; } @@ -148,7 +148,7 @@ .sq-why { display: block; color: var(--p-ink-3); - font-size: 9.5px; + font-size: 10.5px; } .sq-list { @@ -177,14 +177,14 @@ .sq-run { color: var(--p-ink-0); - font-size: 10.5px; + font-size: 11.5px; overflow-wrap: anywhere; } .sq-attention, .sq-step { color: var(--p-ink-2); - font-size: 10px; + font-size: 11px; } .sq-step { @@ -201,7 +201,7 @@ .sq-unknown { grid-column: 1 / -1; color: var(--p-run-failed); - font-size: 9.5px; + font-size: 10.5px; } /* ----------------------------------------------------------------- item */ @@ -227,18 +227,18 @@ .sc-status { color: var(--p-ink-2); - font-size: 11px; + font-size: 12px; } .sc-stale { margin-left: auto; color: var(--p-run-failed); - font-size: 10px; + font-size: 11px; } .sc-state { color: var(--p-ink-2); - font-size: 11px; + font-size: 12px; } .sc-state[data-liveness='unreachable'] { @@ -248,7 +248,7 @@ .sc-why { display: block; color: var(--p-ink-3); - font-size: 9.5px; + font-size: 10.5px; } .sc-block { @@ -258,7 +258,7 @@ .sc-blockhead { margin-bottom: 5px; color: var(--p-ink-3); - font-size: 10px; + font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; } @@ -297,12 +297,12 @@ .sc-fname { color: var(--p-ink-2); - font-size: 10.5px; + font-size: 11.5px; } .sc-fvalue { color: var(--p-ink-0); - font-size: 11.5px; + font-size: 12.5px; } .sc-facts[data-pivotal='true'] .sc-fvalue { @@ -312,6 +312,6 @@ .sc-none { color: var(--p-ink-2); - font-size: 11.5px; + font-size: 12.5px; line-height: 1.5; } diff --git a/web/app/v2/css/v2.css b/web/app/v2/css/v2.css index 34d2f75fa..8e064ac3b 100644 --- a/web/app/v2/css/v2.css +++ b/web/app/v2/css/v2.css @@ -158,7 +158,7 @@ .v2-primary { padding: 4px 9px; border-radius: 5px; - font-size: 11px; + font-size: 12px; } .v2-ghost { @@ -176,7 +176,7 @@ .v2-error { margin: 0 0 8px; color: var(--p-run-failed); - font-size: 11px; + font-size: 12px; } .v2-shell .sq-list { @@ -213,13 +213,13 @@ .v2-column { color: var(--p-ink-2); - font-size: 10px; + font-size: 11px; } .v2-empty { padding: 18px 8px; color: var(--p-ink-3); - font-size: 12px; + font-size: 13px; } /* ------------------------------ run mode: switcher | canvas | run drawer */ @@ -273,7 +273,7 @@ .rsw-grouphead { margin-bottom: 2px; color: var(--p-ink-3); - font-size: 9px; + font-size: 10px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; @@ -315,26 +315,26 @@ .rsw-id { color: var(--p-ink-0); - font-size: 10.5px; + font-size: 11.5px; overflow-wrap: anywhere; } .rsw-time { color: var(--p-ink-3); - font-size: 9.5px; + font-size: 10.5px; text-align: right; white-space: nowrap; } .rsw-state { color: var(--p-ink-2); - font-size: 10px; + font-size: 11px; grid-column: 1 / -1; } .rsw-stranded { color: var(--p-run-failed); - font-size: 9.5px; + font-size: 10.5px; grid-column: 1 / -1; } @@ -343,8 +343,15 @@ color: var(--p-ink-3); } -.v2-run-empty { - padding: 18px 20px; +.rsw-flowtype { + width: 100%; + margin: 8px 0 4px; + padding: 4px 6px; + border: 1px solid var(--p-line); + border-radius: 5px; + background: var(--p-surface-2); + color: var(--p-ink-0); + font-size: 11.5px; } /* The drawer scrolls; the Actions block does not leave the viewport. */ @@ -382,7 +389,7 @@ .rhd-id { color: var(--p-ink-0); - font-size: 12.5px; + font-size: 13.5px; font-weight: 600; overflow-wrap: anywhere; } @@ -397,7 +404,7 @@ border: 1px solid var(--p-line); border-radius: 4px; color: var(--p-ink-2); - font-size: 10px; + font-size: 11px; letter-spacing: 0.02em; white-space: nowrap; } @@ -407,6 +414,20 @@ color: var(--p-ink-0); } +.rhd-close { + padding: 0 4px; + border: 0; + background: transparent; + color: var(--p-ink-3); + font-size: 13px; + line-height: 1; + cursor: pointer; +} + +.rhd-close:hover { + color: var(--p-ink-0); +} + .rhd-facts { display: grid; margin-top: 8px; @@ -422,14 +443,14 @@ .rhd-facts dt { color: var(--p-ink-3); - font-size: 9.5px; + font-size: 10.5px; letter-spacing: 0.05em; text-transform: uppercase; } .rhd-facts dd { color: var(--p-ink-1); - font-size: 10.5px; + font-size: 11.5px; overflow-wrap: anywhere; } @@ -439,7 +460,7 @@ border: 1px solid var(--p-run-failed); border-radius: 5px; color: var(--p-run-failed); - font-size: 11px; + font-size: 12px; } /* Follows the canvas selection; the facts and Actions below it do not move. */ @@ -453,7 +474,7 @@ .rdw-bandlabel { color: var(--p-ink-3); - font-size: 9.5px; + font-size: 10.5px; letter-spacing: 0.05em; text-transform: uppercase; } @@ -468,12 +489,12 @@ .rdw-bandstep { color: var(--p-ink-0); - font-size: 11.5px; + font-size: 12.5px; } .rdw-bandwhy { color: var(--p-ink-2); - font-size: 10px; + font-size: 11px; line-height: 1.45; } @@ -502,7 +523,7 @@ .v2-seemore { margin-left: auto; color: var(--p-ink-2); - font-size: 11px; + font-size: 12px; white-space: nowrap; } @@ -512,6 +533,6 @@ .rdw-bandwhat { color: var(--p-ink-1); - font-size: 10.5px; + font-size: 11.5px; line-height: 1.45; } diff --git a/web/app/v2/queue/copy.ts b/web/app/v2/queue/copy.ts index d64d4483e..294b05f11 100644 --- a/web/app/v2/queue/copy.ts +++ b/web/app/v2/queue/copy.ts @@ -13,8 +13,8 @@ * may be offered, not who must act, so the queue says what it read and where it read it. */ export const QUEUE_COPY = { - appName: 'Work queue', - strapline: 'Open runs of one Flow type, read from the running process.', + appName: 'Inbox', + strapline: 'What has arrived for you, read from the running process.', noGraph: 'No process diagram here by design: this view shows the work, not the shape of the process.', /** Derived from the live filter rows: the sentence must not outlive a filter the reader deleted. */ diff --git a/web/app/v2/run/RunDetailDrawer.tsx b/web/app/v2/run/RunDetailDrawer.tsx index b3d14f30f..9efba30a1 100644 --- a/web/app/v2/run/RunDetailDrawer.tsx +++ b/web/app/v2/run/RunDetailDrawer.tsx @@ -38,6 +38,7 @@ export function RunDetailDrawer({ reloadKey, onStranded, onStopped, + onClose, }: { flowType: string; flowId: string; @@ -48,10 +49,17 @@ export function RunDetailDrawer({ reloadKey: number; onStranded?: (flowID: string) => void; onStopped: () => void; + onClose: () => void; }) { return (
    - + {band && (
    @@ -70,6 +78,7 @@ export function RunDetailDrawer({ order="actions-first" showHeading={false} reloadKey={reloadKey} + onActed={onClose} onStranded={onStranded} />
    diff --git a/web/app/v2/run/RunHeader.tsx b/web/app/v2/run/RunHeader.tsx index f95361f44..102cb463c 100644 --- a/web/app/v2/run/RunHeader.tsx +++ b/web/app/v2/run/RunHeader.tsx @@ -25,11 +25,13 @@ export function RunHeader({ flowId, summary, onStopped, + onClose, }: { flowType: string; flowId: string; summary: FlowSummary | null; onStopped: () => void; + onClose: () => void; }) { const { timezone } = usePreferences(); const [stopOpen, setStopOpen] = useState(false); @@ -47,6 +49,9 @@ export function RunHeader({ {RUN_COPY.inspect} +
    {summary && (
    diff --git a/web/app/v2/run/RunSwitcher.tsx b/web/app/v2/run/RunSwitcher.tsx index a58f26c2f..0e069f56c 100644 --- a/web/app/v2/run/RunSwitcher.tsx +++ b/web/app/v2/run/RunSwitcher.tsx @@ -41,40 +41,40 @@ export function RunSwitcher({ const { timezone } = usePreferences(); const { flows, liveness, loading } = search; const groups = groupRuns(flows); + const stateText = liveness === 'loading' + ? QUEUE_COPY.loading + : liveness === 'unreachable' + ? QUEUE_COPY.unreachable + : liveness === 'stale' + ? QUEUE_COPY.stale + : flows.length === 0 + ? RUN_COPY.noRuns + : ''; return (