Skip to content
19 changes: 19 additions & 0 deletions .changeset/25916-console-leftover-strings-round-3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/i18n': patch
'@object-ui/components': patch
'@object-ui/app-shell': patch
'@object-ui/console': patch
---

fix(i18n): the last English literals on the data table, the chart view, react pages, the References sheet and Flow Runs read the user's language

Clause-②: no
Metadata-protocol: none

- **Data table.** The hover "open record" affordance and the inline-edit row's cancel and save buttons are titled from `table.openRecord`, `table.cancelChanges` and `table.saveRow`.
- **Chart view.** A chart view's loading fallback reads the existing `chart.loading` row.
- **`kind:'react'` pages.** The capability notice, the empty-source refusal, the loading line and the two error titles read `reactPage.*`. Sentences with an inline code chip are one translated string, so each language decides where the chip sits.
- **References sheet (Studio).** The table's column headers read `engine.edit.refsColFromType`, `refsColFromName` and `refsColPath` from the Studio's own table, in English and Chinese.
- **Flow Runs.** When the client lacks the method a panel calls (`meta.getItems`, `automation.execute`, `automation.listRuns`), the panel keeps the method's name and words the diagnostic at render from `console.developer.flowRuns.clientMethodUnavailable`. A message the call threw is still shown as it came.

All ten locale packs carry the new rows. With no i18n provider every surface still renders its English.
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.record.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,6 @@
"ui/packages/types"
],
"registrySources": 922,
"registrySourcesSha256": "7734fe6bc3763fe8318db624b9e0dd4c48b2dc7b15bf2a3fb1f78e46bb8d959e"
"registrySourcesSha256": "3fe916234f1b5346bff70f6f99322077630b851ebef81c9ae6ba8c4ac08e2063"
}
}
40 changes: 28 additions & 12 deletions ui/apps/console/src/pages/developer/FlowRunsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,22 @@ import {
Play, Loader2, CheckCircle2, XCircle, Clock, AlertCircle, RefreshCw, Workflow,
} from 'lucide-react';

/**
* A panel's error: the message a call threw, or the client method the panel
* needs and this client lacks. The method is kept by name and worded at render,
* so the diagnostic reads in the viewer's locale (#25916).
*/
type PanelError = { message: string } | { unavailable: string };

function panelErrorText(t: ReturnType<typeof useObjectTranslation>['t'], error: PanelError): string {
return 'unavailable' in error
? t('console.developer.flowRuns.clientMethodUnavailable', {
defaultValue: '{{method}} is not available on this client',
method: error.unavailable,
})
: error.message;
}

interface FlowVariable {
name: string;
type: string;
Expand Down Expand Up @@ -197,13 +213,13 @@ export function FlowRunsPage() {

const [flows, setFlows] = useState<FlowItem[]>([]);
const [flowsLoading, setFlowsLoading] = useState(true);
const [flowsError, setFlowsError] = useState<string | null>(null);
const [flowsError, setFlowsError] = useState<PanelError | null>(null);
const [selectedFlowName, setSelectedFlowName] = useState<string>('');
const [refreshRuns, setRefreshRuns] = useState(0);

const loadFlows = useCallback(async () => {
if (!client?.meta?.getItems) {
setFlowsError('meta.getItems is not available on this client');
setFlowsError({ unavailable: 'meta.getItems' });
setFlowsLoading(false);
return;
}
Expand Down Expand Up @@ -233,7 +249,7 @@ export function FlowRunsPage() {
setSelectedFlowName(normalized[0].name);
}
} catch (e: any) {
setFlowsError(e?.message ?? String(e));
setFlowsError({ message: e?.message ?? String(e) });
} finally {
setFlowsLoading(false);
}
Expand Down Expand Up @@ -276,7 +292,7 @@ export function FlowRunsPage() {
<CardContent>
{flowsLoading && <Skeleton className="h-9 w-72" />}
{flowsError && (
<p className="text-sm text-red-500 font-mono break-all">{flowsError}</p>
<p className="text-sm text-red-500 font-mono break-all">{panelErrorText(t, flowsError)}</p>
)}
{!flowsLoading && !flowsError && flows.length === 0 && (
<p className="text-sm text-muted-foreground">{t('console.developer.flowRuns.noFlows', { defaultValue: 'No flow definitions found.' })}</p>
Expand Down Expand Up @@ -335,7 +351,7 @@ function FlowTestRunner({
const [values, setValues] = useState<Record<string, string>>({});
const [running, setRunning] = useState(false);
const [result, setResult] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<PanelError | null>(null);
// The paused screen a test run stopped at — driven to completion by
// <FlowRunner>, exactly as a `type: 'flow'` action would.
const [screenFlow, setScreenFlow] = useState<ScreenFlowState | null>(null);
Expand All @@ -351,7 +367,7 @@ function FlowTestRunner({

const handleRun = async () => {
if (!client?.automation?.execute) {
setError('automation.execute is not available on this client');
setError({ unavailable: 'automation.execute' });
return;
}
setRunning(true);
Expand All @@ -376,7 +392,7 @@ function FlowTestRunner({
}
onExecuted?.();
} catch (e: any) {
setError(e?.message ?? String(e));
setError({ message: e?.message ?? String(e) });
} finally {
setRunning(false);
}
Expand Down Expand Up @@ -455,7 +471,7 @@ function FlowTestRunner({
</Badge>
)}
</div>
{error && <p className="text-sm text-red-500 font-mono break-all">{error}</p>}
{error && <p className="text-sm text-red-500 font-mono break-all">{panelErrorText(t, error)}</p>}
{/* A run left paused (the runner was dismissed) can be reopened —
the suspension is durable, so the screen is still waiting. */}
{result?.status === 'paused' && result?.screen && result?.runId && !screenFlow && (
Expand Down Expand Up @@ -518,7 +534,7 @@ function FlowRunsPanel({
const displayLocale = useDisplayLocale();
const [runs, setRuns] = useState<RunSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<PanelError | null>(null);

const [openRunId, setOpenRunId] = useState<string | null>(null);
const [runDetail, setRunDetail] = useState<any>(null);
Expand All @@ -527,7 +543,7 @@ function FlowRunsPanel({

const loadRuns = useCallback(async () => {
if (!client?.automation?.listRuns) {
setError('automation.listRuns is not available on this client');
setError({ unavailable: 'automation.listRuns' });
setLoading(false);
return;
}
Expand All @@ -538,7 +554,7 @@ function FlowRunsPanel({
const items: RunSummary[] = Array.isArray(res) ? res : (res?.items ?? res?.runs ?? []);
setRuns(items);
} catch (e: any) {
setError(e?.message ?? String(e));
setError({ message: e?.message ?? String(e) });
} finally {
setLoading(false);
}
Expand Down Expand Up @@ -585,7 +601,7 @@ function FlowRunsPanel({
</div>
)}
{!loading && error && (
<p className="text-sm text-red-500 font-mono break-all">{error}</p>
<p className="text-sm text-red-500 font-mono break-all">{panelErrorText(t, error)}</p>
)}
{!loading && !error && runs.length === 0 && (
<p className="text-sm text-muted-foreground py-6 text-center">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #25916, third pass — Flow Runs' client-capability diagnostics speak the
* session's language.
*
* Each of the page's three panels calls one client method (`meta.getItems`,
* `automation.execute`, `automation.listRuns`). A client without it used to
* leave an English sentence in the panel's error state. The state now keeps
* the method's NAME and the panel words it at render through
* `console.developer.flowRuns.clientMethodUnavailable`, so under zh the
* sentence is the zh row with the method name in it. A thrown message is
* still drawn verbatim. Control: with no provider the English sentence is
* unchanged.
*/

import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { I18nProvider } from '@object-ui/i18n';
import { builtInLocales } from '@object-ui/i18n/locales';

const CLIENT = vi.hoisted(() => ({ current: {} as Record<string, unknown> }));
const ADAPTER = vi.hoisted(() => ({ getClient: () => CLIENT.current }));
vi.mock('@object-ui/app-shell', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAdapter: () => ADAPTER,
useMetadata: () => ({ objects: [], getTypeStatus: () => 'loaded' }),
}));

// Imported after the mock so the page picks it up.
import { FlowRunsPage } from './FlowRunsPage';

afterEach(() => cleanup());

const { zh } = builtInLocales;
const zhFlow = zh.console.developer.flowRuns;

/** A client that serves one flow but has no automation surface at all. */
const NO_AUTOMATION = { meta: { getItems: async () => ({ type: 'flow', items: [{ name: 'welcome_flow' }] }) } };
/** A client that throws from the one method it has. */
const THROWS = { meta: { getItems: async () => { throw new Error('boom: 503'); } } };

function page(language: 'zh' | null) {
const routed = (
<MemoryRouter>
<FlowRunsPage />
</MemoryRouter>
);
return render(
language ? (
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}>
{routed}
</I18nProvider>
) : (
routed
),
);
}

const unavailable = (method: string) => zhFlow.clientMethodUnavailable.replace('{{method}}', method);

describe('Flow Runs client diagnostics under zh (#25916)', () => {
it('guard: the zh row is a real zh row', () => {
expect(zhFlow.clientMethodUnavailable).toMatch(/\p{Script=Han}/u);
expect(zhFlow.clientMethodUnavailable).toContain('{{method}}');
});

it('a client without meta.getItems: the flow panel says so in zh', async () => {
CLIENT.current = {};
page('zh');
expect(await screen.findByText(unavailable('meta.getItems'))).toBeTruthy();
});

it('a client without automation: the runs panel and a test run say so in zh', async () => {
CLIENT.current = NO_AUTOMATION;
page('zh');
expect(await screen.findByText(unavailable('automation.listRuns'))).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: zhFlow.runFlow }));
expect(await screen.findByText(unavailable('automation.execute'))).toBeTruthy();
});

it('a thrown message is still drawn verbatim', async () => {
CLIENT.current = THROWS;
page('zh');
expect(await screen.findByText('boom: 503')).toBeTruthy();
});
});

describe('control: with no provider the diagnostics read their English (#25916)', () => {
it('meta.getItems', async () => {
CLIENT.current = {};
page(null);
expect(await screen.findByText('meta.getItems is not available on this client')).toBeTruthy();
});

it('automation.listRuns and automation.execute', async () => {
CLIENT.current = NO_AUTOMATION;
page(null);
expect(await screen.findByText('automation.listRuns is not available on this client')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Run Flow' }));
expect(await screen.findByText('automation.execute is not available on this client')).toBeTruthy();
});
});
4 changes: 2 additions & 2 deletions ui/packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3176,7 +3176,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
const dims: string[] = Array.isArray(chartConfig.dimensions) ? chartConfig.dimensions : [];
const vals: string[] = Array.isArray(chartConfig.values) ? chartConfig.values : [];
return withViewTabs(
<Suspense key={identityKey} fallback={<div className="p-4 text-sm text-muted-foreground">Loading chart…</div>}>
<Suspense key={identityKey} fallback={<div className="p-4 text-sm text-muted-foreground">{t('chart.loading')}</div>}>
<ObjectChart
dataSource={ds}
schema={{
Expand Down Expand Up @@ -3208,7 +3208,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
// `chart-missing-category-axis`) instead of aggregating on a name
// nobody wrote. ⛔ Do not restore a floor here.
return withViewTabs(
<Suspense key={identityKey} fallback={<div className="p-4 text-sm text-muted-foreground">Loading chart…</div>}>
<Suspense key={identityKey} fallback={<div className="p-4 text-sm text-muted-foreground">{t('chart.loading')}</div>}>
<ObjectChart
dataSource={ds}
schema={{
Expand Down
Loading
Loading