diff --git a/.changeset/11092-flow-label-reader.md b/.changeset/11092-flow-label-reader.md
new file mode 100644
index 0000000000..d41aa3d33c
--- /dev/null
+++ b/.changeset/11092-flow-label-reader.md
@@ -0,0 +1,19 @@
+---
+'@object-ui/app-shell': minor
+'@object-ui/console': patch
+---
+
+The screen-flow runner names the flow by its label, in the user's language (objectui#11092, the objectui half of objectstack#20318).
+
+Since `@objectstack/spec` 17.6.0 every answer that evaluated a flow carries the flow's authored label as `AutomationResult.flowLabel` (objectstack#20633). `FlowRunner` now resolves the flow's display name in this order: the active language's `flows.FLOW.label` from the app's translation bundle, then the served `flowLabel`, then the flow's API name. The bundle is the one the runner already reads for screen headings and field copy, and the lookup is the spec's own `translateFlow`.
+
+Where it shows:
+
+- **The runner header.** A line above the screen's heading names the flow. The heading is still the step's own title (or the `flowRunner.title` fallback), and it is still the dialog's accessible name.
+- **The completion toast.** `Flow "{{flow}}" completed` names the flow by the resolved label instead of its API name. A flow's own `successMessage` still takes precedence. The message key and its translations are unchanged.
+
+All three places that open the runner pass the label through: a flow action on a list or toolbar, a flow action on a record page, and the developer Flow Runs page's Test Run panel. A resume answer that pauses on a further screen carries the label forward.
+
+Against a server older than objectstack#20633 no label is served, so the header line and the toast show the flow's API name, unless the app's bundle translates `flows.FLOW.label` for the active language.
+
+**Clause-②: yes (widening).** The exported `ScreenFlowState` type gains one optional member, `flowLabel`, typed by the contract as `Pick` of `AutomationResult`'s `flowLabel` (an optional string). `FlowRunnerProps.state` accepts it through that type. No prop, export or i18n key is added or removed, and no existing member changes type.
diff --git a/apps/console/src/pages/developer/FlowRunsPage.test.tsx b/apps/console/src/pages/developer/FlowRunsPage.test.tsx
index da41ae3673..9295d58460 100644
--- a/apps/console/src/pages/developer/FlowRunsPage.test.tsx
+++ b/apps/console/src/pages/developer/FlowRunsPage.test.tsx
@@ -12,7 +12,7 @@
*/
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
-import { render, screen, cleanup, waitFor } from '@testing-library/react';
+import { render, screen, cleanup, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// Hoisted so the vi.mock factories below can close over them, and so the
@@ -30,6 +30,8 @@ const { execute, ADAPTER, authFetch } = vi.hoisted(() => {
status: 'paused',
runId: 'run_1',
durationMs: 3,
+ // The flow's served label (objectui#11092): the runner names the flow by it.
+ flowLabel: 'Reassign',
screen: {
nodeId: 'collect',
title: 'New Assignee',
@@ -123,4 +125,23 @@ describe('Flow Runs — screen flows are completable from the Test Run panel', (
await user.click(resume);
expect(await screen.findByRole('dialog')).toHaveTextContent('New Assignee');
});
+
+ it('names the flow by the trigger answer\'s served label, on the run and on Continue run (objectui#11092)', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await screen.findByRole('button', { name: /Run Flow/i });
+ await user.click(screen.getByRole('button', { name: /Run Flow/i }));
+ const first = within(await screen.findByRole('dialog'));
+ expect(first.getByText('Reassign')).toBeInTheDocument();
+ expect(first.queryByText('reassign_wizard')).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: 'Cancel' }));
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
+
+ await user.click(await screen.findByRole('button', { name: /Continue run/i }));
+ const reopened = within(await screen.findByRole('dialog'));
+ expect(reopened.getByText('Reassign')).toBeInTheDocument();
+ expect(reopened.queryByText('reassign_wizard')).not.toBeInTheDocument();
+ });
});
diff --git a/apps/console/src/pages/developer/FlowRunsPage.tsx b/apps/console/src/pages/developer/FlowRunsPage.tsx
index 7718dfda61..b5c6471b6c 100644
--- a/apps/console/src/pages/developer/FlowRunsPage.tsx
+++ b/apps/console/src/pages/developer/FlowRunsPage.tsx
@@ -303,9 +303,11 @@ function FlowTestRunner({
const res = await client.automation.execute(flow.name, { params });
setResult(res);
// Screen flow: the run is suspended at a `screen` node. Open the runner
- // so the tester can fill it in and the run can actually finish.
+ // so the tester can fill it in and the run can actually finish. `res` is
+ // the trigger door's `AutomationResult`, so it carries the flow's served
+ // `flowLabel` the runner names the flow by (objectui#11092).
if (res?.status === 'paused' && res?.screen && res?.runId) {
- setScreenFlow({ flowName: flow.name, runId: res.runId, screen: res.screen });
+ setScreenFlow({ flowName: flow.name, flowLabel: res.flowLabel, runId: res.runId, screen: res.screen });
}
onExecuted?.();
} catch (e: any) {
@@ -394,7 +396,14 @@ function FlowTestRunner({
diff --git a/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.refusedLaunch-9973.test.tsx b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.refusedLaunch-9973.test.tsx
index 19e8a95920..ade5e2b468 100644
--- a/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.refusedLaunch-9973.test.tsx
+++ b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.refusedLaunch-9973.test.tsx
@@ -138,3 +138,24 @@ describe('objectui#9973 — list-action flow launch that ends refused', () => {
expect(document.body.textContent).not.toContain(SENTENCE);
});
});
+
+// objectui#11092 — the same harness, on a launch that PAUSES: the provider's
+// flow handler hands the trigger answer's `flowLabel` to the `FlowRunner` it
+// mounts, and the runner names the flow by it rather than by its API name.
+describe('objectui#11092 — a paused list-action launch names the flow by its served label', () => {
+ it('opens the runner headed by the served flowLabel', async () => {
+ const { result } = await launch({
+ success: true,
+ status: 'paused',
+ runId: 'run-1',
+ flowLabel: 'Duplicate Screening',
+ screen: { nodeId: 'confirm', title: 'Confirm', fields: [] },
+ });
+
+ expect(result).toEqual({ success: true, silent: true });
+ const dialog = await screen.findByRole('dialog');
+ expect(within(dialog).getByRole('heading', { name: 'Confirm' })).toBeTruthy();
+ expect(within(dialog).getByText('Duplicate Screening')).toBeTruthy();
+ expect(within(dialog).queryByText('check_dup')).toBeNull();
+ });
+});
diff --git a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
index 57ec2c11fa..09acb8569b 100644
--- a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
+++ b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
@@ -648,7 +648,12 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
// Paused at a `screen` node: FlowRunner renders the form + resumes, and
// refreshes on completion.
if (judged.followUp?.kind === 'screen') {
- setScreenFlow({ flowName, runId: judged.followUp.runId, screen: judged.followUp.screen });
+ setScreenFlow({
+ flowName,
+ flowLabel: judged.followUp.flowLabel,
+ runId: judged.followUp.runId,
+ screen: judged.followUp.screen,
+ });
}
// Ended `refused`: the Close-only notice carries the engine's sentence,
// titled with the action the user clicked.
diff --git a/packages/app-shell/src/utils/__tests__/flowResponse.test.ts b/packages/app-shell/src/utils/__tests__/flowResponse.test.ts
index 24ec9dfed6..6b9da43215 100644
--- a/packages/app-shell/src/utils/__tests__/flowResponse.test.ts
+++ b/packages/app-shell/src/utils/__tests__/flowResponse.test.ts
@@ -349,3 +349,45 @@ describe('judgeFlowLaunch — one launch decision for both hosts', () => {
expect(out.refresh).toBe(false);
});
});
+
+// objectui#11092 — the flow's served label (`AutomationResult.flowLabel`) is
+// lifted here once, so both launch hosts and the runner's resume read it the
+// same way. The arms are asserted by kind, and the launch follow-up with
+// `toEqual`, as above: the label rides along with the screen, nothing else does.
+describe('the served flowLabel is lifted once (objectui#11092)', () => {
+ const screen = { nodeId: 'collect', title: 'New Assignee', fields: [] };
+
+ it('a paused answer carries it', () => {
+ const out = interpretFlowResponse(ok, {
+ success: true,
+ data: { success: true, status: 'paused', runId: 'run-7', screen, flowLabel: 'Reassign' },
+ }, 'Flow "reassign"');
+ expect(out).toMatchObject({ kind: 'paused', flowLabel: 'Reassign' });
+ });
+
+ it('a terminal-success answer carries it', () => {
+ const out = interpretFlowResponse(ok, {
+ success: true, data: { success: true, flowLabel: 'Reassign' },
+ }, 'Flow "reassign"');
+ expect(out).toMatchObject({ kind: 'done', flowLabel: 'Reassign' });
+ });
+
+ it('is undefined when the answer serves no label, and when what it serves is not a string', () => {
+ const none = interpretFlowResponse(ok, { success: true, data: { success: true } }, 'Flow "x"');
+ expect((none as { flowLabel?: unknown }).flowLabel).toBeUndefined();
+ const notString = interpretFlowResponse(ok, {
+ success: true, data: { success: true, status: 'paused', runId: 'r', screen, flowLabel: { en: 'x' } },
+ }, 'Flow "x"');
+ expect(notString.kind).toBe('paused');
+ expect((notString as { flowLabel?: unknown }).flowLabel).toBeUndefined();
+ });
+
+ it('a paused LAUNCH hands it to the runner with the screen', () => {
+ const out = judgeFlowLaunch(interpretFlowResponse(ok, {
+ success: true,
+ data: { success: true, status: 'paused', runId: 'run-7', screen, flowLabel: 'Reassign' },
+ }, 'Flow "reassign"'), undefined);
+ expect(out.followUp).toEqual({ kind: 'screen', runId: 'run-7', screen, flowLabel: 'Reassign' });
+ expect(out.result).toEqual({ success: true, silent: true });
+ });
+});
diff --git a/packages/app-shell/src/utils/flowResponse.ts b/packages/app-shell/src/utils/flowResponse.ts
index a8de1d0952..3aeb0451e1 100644
--- a/packages/app-shell/src/utils/flowResponse.ts
+++ b/packages/app-shell/src/utils/flowResponse.ts
@@ -22,7 +22,8 @@
* success: boolean, ← required; FALSE when the run failed
* status?: 'completed' | 'paused' | 'failed',
* runId?, screen?, ← set when paused at a `screen` node
- * error?, errorMessage?, successMessage?
+ * error?, errorMessage?, successMessage?,
+ * flowLabel? ← the flow's authored label (see below)
* }
* }
* ```
@@ -123,11 +124,23 @@
* objectstack#14945 ruling (Close only, no completion toast) broken on the one
* route `FlowRunner` never sees. {@link judgeFlowLaunch} is that decision,
* once, so the next change cannot land in only one of the two hosts.
+ *
+ * ## The flow's label is lifted here, once (objectui#11092)
+ *
+ * Every 200 that evaluated a registered flow carries `flowLabel`, the flow
+ * definition's authored label (`AutomationResult.flowLabel`, objectstack#20633).
+ * `FlowRunner` names the flow by it, so it has to reach the runner from both
+ * launch hosts and from the runner's own resume. It is read in ONE place,
+ * {@link servedFlowLabel}, and carried on the `paused` and `done` arms and on
+ * the `screen` follow-up, typed by the contract rather than by a local copy.
+ * The `refused` arm does not carry it: nothing that arm opens names the flow
+ * (the launch notice is titled with the action's own label).
*/
import { actionErrorDetail } from '@object-ui/core';
import type { ActionResult } from '@object-ui/core';
import { errorCodeIs } from '@object-ui/types';
+import type { AutomationResult } from '@objectstack/spec/contracts';
/**
* The `AutomationResult` fields the console reads. Deliberately loose: this is
@@ -193,8 +206,8 @@ export type FlowResponseOutcome =
*/
retryable: boolean;
}
- | { kind: 'paused'; runId?: string; screen: S; data: FlowRunResult }
- | { kind: 'done'; data: FlowRunResult | undefined; successMessage?: string };
+ | ({ kind: 'paused'; runId?: string; screen: S; data: FlowRunResult } & Pick)
+ | ({ kind: 'done'; data: FlowRunResult | undefined; successMessage?: string } & Pick);
/**
* A flow declares a friendly `errorMessage`; prefer it over the raw `error`,
@@ -212,6 +225,16 @@ function flowFailureMessage(data: FlowRunResult, fallback: string): string {
* has always been able to arrive as a bare string, and a non-2xx may carry no
* parseable body at all, so nothing here may assume an object.
*/
+/**
+ * The flow's authored label as the answer serves it (objectui#11092 — see the
+ * header). Read from the ONE member the contract declares, with no alias chain
+ * (commandment #0.1). `undefined` when the body carries no string there: a
+ * backend older than objectstack#20633, or a body that is not an evaluation.
+ */
+function servedFlowLabel(data: FlowRunResult): AutomationResult['flowLabel'] {
+ return typeof data.flowLabel === 'string' ? data.flowLabel : undefined;
+}
+
function errorEnvelopeDetails(json: unknown): FlowRunResult {
const envelope = (json as { error?: { details?: unknown } } | null | undefined)?.error;
const details = envelope?.details;
@@ -289,7 +312,7 @@ export function interpretFlowResponse(
}
if (data.status === 'paused' && data.screen) {
- return { kind: 'paused', runId: data.runId, screen: data.screen as S, data };
+ return { kind: 'paused', runId: data.runId, screen: data.screen as S, data, flowLabel: servedFlowLabel(data) };
}
// The run reached an `end` node declaring `outcome: 'refused'` (objectstack#14945):
@@ -314,6 +337,7 @@ export function interpretFlowResponse(
kind: 'done',
data: json?.data as FlowRunResult | undefined,
successMessage: typeof data.successMessage === 'string' ? data.successMessage : undefined,
+ flowLabel: servedFlowLabel(data),
};
}
@@ -322,8 +346,11 @@ export function interpretFlowResponse(
* caller's screen type, as on {@link FlowResponseOutcome}.
*/
export type FlowLaunchFollowUp =
- /** Paused at a `screen` node: the host opens `FlowRunner` on this run. */
- | { kind: 'screen'; runId: string; screen: S }
+ /**
+ * Paused at a `screen` node: the host opens `FlowRunner` on this run,
+ * handing it the served `flowLabel` with the screen (objectui#11092).
+ */
+ | ({ kind: 'screen'; runId: string; screen: S } & Pick)
/**
* Ended `refused` without pausing: the host opens its Close-only refusal
* notice. `message` is the engine-rendered sentence, `''` when the producer
@@ -373,7 +400,12 @@ export function judgeFlowLaunch(
case 'paused':
return {
result: { success: true, silent: true },
- followUp: { kind: 'screen', runId: outcome.runId ?? '', screen: outcome.screen },
+ followUp: {
+ kind: 'screen',
+ runId: outcome.runId ?? '',
+ screen: outcome.screen,
+ flowLabel: outcome.flowLabel,
+ },
refresh: false,
};
case 'refused':
diff --git a/packages/app-shell/src/views/FlowRunner.tsx b/packages/app-shell/src/views/FlowRunner.tsx
index 3215083b55..02bcd71f8b 100644
--- a/packages/app-shell/src/views/FlowRunner.tsx
+++ b/packages/app-shell/src/views/FlowRunner.tsx
@@ -110,8 +110,10 @@
* long as the spec's per-field face leaves it out of
* `FLOW_SCREEN_FIELD_COPY_KEYS`. The overlay walks that constant, so the day
* the spec lists it the help text is translated with no edit here;
- * - the runner chrome (Cancel / Submit / Submitting… / the terminal toast) —
- * the console's own words, ruled into its message catalog (objectstack#7646);
+ * - the runner chrome (Cancel / Submit / Submitting… / the terminal toast's
+ * sentence) — the console's own words, ruled into its message catalog
+ * (objectstack#7646). The flow NAME inside that toast is not chrome; see the
+ * objectui#11092 section below;
* - `FlowSchema.successMessage` — off the translation surface by design.
*
* ⚠️ One boundary of the client-side pick: the server interpolates `{var}`
@@ -119,6 +121,23 @@
* the variables, so a translated heading is drawn exactly as the bundle wrote
* it — a token inside it renders literally.
*
+ * ## The flow is named by its label, in the user's language (objectui#11092)
+ *
+ * The runner used to name the flow only by its API name, and only in the
+ * completion toast. Every answer that evaluated the flow now carries the
+ * flow's authored label (`AutomationResult.flowLabel`, objectstack#20633),
+ * lifted once by `interpretFlowResponse` and handed in on
+ * {@link ScreenFlowState}; a resume answer that pauses again replaces it. The
+ * name the user reads resolves as: the active language's `flows..label`,
+ * then the served `flowLabel`, then the API name (an answer from a backend
+ * that serves no label). The first two steps are the spec's `translateFlow`,
+ * read from the same bundle the screen copy above is, so the address is the
+ * spec's and there is still no second loader.
+ *
+ * It is drawn in two places: a line above the screen heading in the dialog
+ * header (the heading stays the step's own title, and stays the dialog's
+ * accessible name), and the completion toast, in place of the API name.
+ *
* Chrome goes through `@object-ui/i18n` (via the `@object-ui/react` re-export)
* like its neighbours; the only English left in this file is the inline
* `defaultValue` each key carries, which `check:i18n-keys` pins to its `en`
@@ -141,9 +160,11 @@ import { notifyDataChanged, useObjectTranslation } from '@object-ui/react';
import {
FLOW_SCREEN_FIELD_COPY_KEYS,
resolveFlowScreenTitle,
+ translateFlow,
type TranslationBundle,
type TranslationData,
} from '@objectstack/spec/system';
+import type { AutomationResult } from '@objectstack/spec/contracts';
import { toast } from 'sonner';
import {
ScreenView,
@@ -237,7 +258,29 @@ function localizeScreen(
return { ...screen, title, fields };
}
-export interface ScreenFlowState {
+/**
+ * The name the user reads for the flow (objectui#11092 — see the header): the
+ * active language's `flows..label`, then the served `flowLabel`, then
+ * `flowName`. `translateFlow` takes the first two steps: handed the served
+ * label as the authored one, it answers the bundle's string where the bundle
+ * has one and that label otherwise.
+ */
+function displayFlowLabel(
+ flowName: string,
+ flowLabel: string | undefined,
+ bundle: TranslationBundle | undefined,
+ language: string,
+): string {
+ return translateFlow({ name: flowName, label: flowLabel }, bundle, { locale: language }).label || flowName;
+}
+
+/**
+ * The paused run a host opens the runner on. `flowLabel` is the flow's
+ * authored label exactly as the trigger answer served it
+ * (`AutomationResult.flowLabel`), absent when the answer carried none; the
+ * runner localizes it (see the header).
+ */
+export interface ScreenFlowState extends Pick {
flowName: string;
runId: string;
screen: ScreenSpec;
@@ -301,6 +344,7 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat
const [screen, setScreen] = useState(null);
const [runId, setRunId] = useState('');
const [flowName, setFlowName] = useState('');
+ const [flowLabel, setFlowLabel] = useState(undefined);
const [values, setValues] = useState>({});
const [submitting, setSubmitting] = useState(false);
const [resumeError, setResumeError] = useState(null);
@@ -311,6 +355,7 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat
setScreen(state.screen);
setRunId(state.runId);
setFlowName(state.flowName);
+ setFlowLabel(state.flowLabel);
setValues(initialScreenValues(state.screen));
// A fresh run must not open under the previous run's refusal.
setResumeError(null);
@@ -326,7 +371,10 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat
// to keep stable (AGENTS.md §5 #10). `screen` stays the payload the run is
// driven by; `shown` differs from it in copy only, so every DISPLAY read —
// including the names in the missing-fields toast below — goes through it.
- const shown = localizeScreen(screen, flowName, activeFlowsBundle(i18n, language, flowName), language);
+ const bundle = activeFlowsBundle(i18n, language, flowName);
+ const shown = localizeScreen(screen, flowName, bundle, language);
+ // The flow's name as the user reads it (objectui#11092), from the same bundle.
+ const shownFlowLabel = displayFlowLabel(flowName, flowLabel, bundle, language);
const setVal = (name: string, v: unknown) => {
setValues((p) => ({ ...p, [name]: v }));
@@ -381,13 +429,18 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat
if (outcome.kind === 'paused') {
setScreen(outcome.screen);
setRunId(outcome.runId || runId);
+ setFlowLabel(outcome.flowLabel);
setValues(initialScreenValues(outcome.screen));
toast.success(t('flowRunner.nextStep', { defaultValue: 'Saved — next step' }));
} else {
- // Terminal success — show the flow's declared completion message.
+ // Terminal success — show the flow's declared completion message, else
+ // the catalog's sentence naming the flow by the label this answer served.
toast.success(
outcome.successMessage
- || t('flowRunner.completed', { flow: flowName, defaultValue: 'Flow "{{flow}}" completed' }),
+ || t('flowRunner.completed', {
+ flow: displayFlowLabel(flowName, outcome.flowLabel, bundle, language),
+ defaultValue: 'Flow "{{flow}}" completed',
+ }),
);
// The flow may have written ANY object — a quote created from an
// Opportunity page lands in a related list this component cannot name.
@@ -483,6 +536,9 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat