From 83d298d56adf4049629c862c3c0d086ef42a6dd1 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Sat, 4 Jul 2026 01:46:55 +0530 Subject: [PATCH] =?UTF-8?q?feat(flows):=20Workflows=20B3b=20=E2=80=94=20Ru?= =?UTF-8?q?n=20Inspector=20drawer=20(#4450)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src-tauri/src/cef_stale_reap.rs | 5 +- .../flows/FlowRunInspectorDrawer.tsx | 276 ++++++++++++++++++ .../__tests__/FlowRunInspectorDrawer.test.tsx | 143 +++++++++ .../notifications/FlowApprovalCard.test.tsx | 79 ++++- .../notifications/FlowApprovalCard.tsx | 35 ++- .../hooks/__tests__/useFlowRunPoller.test.ts | 197 +++++++++++++ app/src/hooks/useFlowRunPoller.ts | 117 ++++++++ app/src/lib/i18n/ar.ts | 18 ++ app/src/lib/i18n/bn.ts | 18 ++ app/src/lib/i18n/de.ts | 18 ++ app/src/lib/i18n/en.ts | 18 ++ app/src/lib/i18n/es.ts | 18 ++ app/src/lib/i18n/fr.ts | 18 ++ app/src/lib/i18n/hi.ts | 18 ++ app/src/lib/i18n/id.ts | 18 ++ app/src/lib/i18n/it.ts | 18 ++ app/src/lib/i18n/ko.ts | 18 ++ app/src/lib/i18n/pl.ts | 18 ++ app/src/lib/i18n/pt.ts | 18 ++ app/src/lib/i18n/ru.ts | 18 ++ app/src/lib/i18n/zh-CN.ts | 18 ++ 21 files changed, 1090 insertions(+), 14 deletions(-) create mode 100644 app/src/components/flows/FlowRunInspectorDrawer.tsx create mode 100644 app/src/components/flows/__tests__/FlowRunInspectorDrawer.test.tsx create mode 100644 app/src/hooks/__tests__/useFlowRunPoller.test.ts create mode 100644 app/src/hooks/useFlowRunPoller.ts diff --git a/app/src-tauri/src/cef_stale_reap.rs b/app/src-tauri/src/cef_stale_reap.rs index 8ba166326..2f2751ac1 100644 --- a/app/src-tauri/src/cef_stale_reap.rs +++ b/app/src-tauri/src/cef_stale_reap.rs @@ -569,7 +569,10 @@ mod tests { #[test] fn marker_is_fresh_bounds_on_age() { - assert!(marker_is_fresh(Some(Duration::from_secs(0)), MARKER_MAX_AGE)); + assert!(marker_is_fresh( + Some(Duration::from_secs(0)), + MARKER_MAX_AGE + )); assert!(marker_is_fresh(Some(MARKER_MAX_AGE), MARKER_MAX_AGE)); assert!(!marker_is_fresh( Some(MARKER_MAX_AGE + Duration::from_secs(1)), diff --git a/app/src/components/flows/FlowRunInspectorDrawer.tsx b/app/src/components/flows/FlowRunInspectorDrawer.tsx new file mode 100644 index 000000000..e7ac21c1c --- /dev/null +++ b/app/src/components/flows/FlowRunInspectorDrawer.tsx @@ -0,0 +1,276 @@ +/** + * FlowRunInspectorDrawer (issue B3b) + * ---------------------------------- + * + * Right-side drawer showing a single durable `tinyflows` run's status + step + * timeline, opened from the "View run" action on {@link FlowApprovalCard}. + * Drawer chrome mirrors `pages/conversations/components/SubagentDrawer.tsx` + * (fixed overlay + backdrop-click-to-close + Escape-to-close) so it renders + * as a fixed overlay regardless of where the parent mounts it in the DOM. + * + * Data comes from {@link useFlowRunPoller}, which polls + * `openhuman.flows_get_run` every 2s until the run reaches a terminal status + * (`completed`/`failed`) — `pending_approval` keeps polling since the run can + * still be resumed elsewhere. + * + * `FlowRunStep` is lean by design (`node_id` + `output` + optional `port` + * only — no per-step status/timing), so each step renders as a plain label + * + collapsible output, not a graduated status timeline. Status-dot/pill + * visual language borrows from `components/intelligence/WorkflowRunDetail.tsx` + * (`RUN_STATUS_ACCENT`/`PHASE_STATUS_DOT`) and + * `pages/conversations/components/ToolTimelineBlock.tsx` (`StatusTag`) — + * dots, not progress bars (project rule). + */ +import debug from 'debug'; + +import { useEscapeKey } from '../../hooks/useEscapeKey'; +import { useFlowRunPoller } from '../../hooks/useFlowRunPoller'; +import { useT } from '../../lib/i18n/I18nContext'; +import type { FlowRunStatus, FlowRunStep } from '../../services/api/flowsApi'; + +const log = debug('flows:run-inspector-drawer'); + +/** Accent classes per run status (semantic palette from tailwind.config.js). */ +const FLOW_RUN_STATUS_ACCENT: Record = { + running: + 'border-ocean-200 bg-ocean-50 text-ocean-700 dark:border-ocean-500/30 dark:bg-ocean-500/10 dark:text-ocean-300', + completed: + 'border-sage-200 bg-sage-50 text-sage-700 dark:border-sage-500/30 dark:bg-sage-500/10 dark:text-sage-300', + pending_approval: + 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-300', + failed: + 'border-coral-200 bg-coral-50 text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300', +}; + +/** Header status dot per run status — mirrors `PHASE_STATUS_DOT`. */ +const FLOW_RUN_STATUS_DOT: Record = { + running: 'bg-ocean-500 animate-pulse', + completed: 'bg-sage-500', + pending_approval: 'bg-amber-500 animate-pulse', + failed: 'bg-coral-500', +}; + +const FLOW_RUN_STATUS_KEY: Record = { + running: 'flowRuns.status.running', + completed: 'flowRuns.status.completed', + pending_approval: 'flowRuns.status.pending_approval', + failed: 'flowRuns.status.failed', +}; + +function formatTimestamp(value: string | null | undefined): string | null { + if (!value) return null; + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) return null; + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + }).format(new Date(parsed)); +} + +/** Render a step's `output` — pretty-printed JSON for objects/arrays, verbatim for strings. */ +function formatStepOutput(output: unknown): string { + if (output == null) return ''; + if (typeof output === 'string') return output; + try { + return JSON.stringify(output, null, 2); + } catch { + return String(output); + } +} + +function StepRow({ step, index }: { step: FlowRunStep; index: number }) { + const { t } = useT(); + const outputText = formatStepOutput(step.output); + + return ( +
  • +
    + + + {step.node_id} + + {step.port !== undefined && ( + + {t('flowRuns.inspector.port')}: {step.port} + + )} +
    + {outputText.length > 0 && ( +
    + + {t('flowRuns.inspector.output')} + +
    +            {outputText}
    +          
    +
    + )} +
  • + ); +} + +interface Props { + /** Run id (== thread_id) to inspect. Renders `null` (nothing) when absent. */ + runId: string | null; + onClose: () => void; +} + +/** + * Renders `null` when `runId` is `null` so the parent can mount this + * unconditionally and just flip `runId` (same convention as + * `SubagentDrawer`). + */ +export function FlowRunInspectorDrawer({ runId, onClose }: Props) { + const { t } = useT(); + const { run, loading, error } = useFlowRunPoller(runId); + + useEscapeKey(() => { + log('escape: closing runId=%s', runId); + onClose(); + }, runId !== null); + + if (!runId) return null; + + const startedAt = formatTimestamp(run?.started_at); + const finishedAt = formatTimestamp(run?.finished_at); + const pendingCount = run?.pending_approvals.length ?? 0; + + return ( +
    + {/* Backdrop */} + + + +
    + {loading && !run && ( +
    +
    + {t('flowRuns.inspector.loading')} +
    + )} + + {error && ( +
    + {t('flowRuns.inspector.loadError')}: {error} +
    + )} + + {run && ( + <> + {/* Timing */} +
    + {startedAt && ( +
    + {t('flowRuns.inspector.startedAt')}: {startedAt} +
    + )} + {finishedAt ? ( +
    + {t('flowRuns.inspector.finishedAt')}: {finishedAt} +
    + ) : run.status === 'running' || run.status === 'pending_approval' ? ( +
    {t('flowRuns.inspector.running')}
    + ) : null} +
    + + {/* Error banner */} + {run.error && ( +
    + {t('flowRuns.inspector.error')}: {run.error} +
    + )} + + {/* Pending approvals banner */} + {run.status === 'pending_approval' && pendingCount > 0 && ( +
    + {t('flowRuns.inspector.pendingApprovalsCount').replace( + '{count}', + String(pendingCount) + )} +
    + )} + + {/* Steps timeline */} +
    +

    + {t('flowRuns.inspector.steps')} +

    + {run.steps.length === 0 ? ( +

    + {t('flowRuns.inspector.noSteps')} +

    + ) : ( +
      + {run.steps.map((step, idx) => ( + + ))} +
    + )} +
    + + )} +
    + +
    + ); +} + +export default FlowRunInspectorDrawer; diff --git a/app/src/components/flows/__tests__/FlowRunInspectorDrawer.test.tsx b/app/src/components/flows/__tests__/FlowRunInspectorDrawer.test.tsx new file mode 100644 index 000000000..5994df006 --- /dev/null +++ b/app/src/components/flows/__tests__/FlowRunInspectorDrawer.test.tsx @@ -0,0 +1,143 @@ +/** + * FlowRunInspectorDrawer (issue B3b) — rendering contract. + * + * Asserts: renders null when `runId` is null; loading state; renders fetched + * run data (status pill, steps, expandable output, port pill); error state; + * pending-approvals banner when `status === 'pending_approval'`; run.error + * banner; Escape and backdrop both close; close button calls `onClose`. + * + * Mocks `useFlowRunPoller` directly rather than the underlying RPC client — + * its own poll-until-terminal contract is covered by + * `hooks/__tests__/useFlowRunPoller.test.ts`. + */ +import { fireEvent, render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { FlowRun } from '../../../services/api/flowsApi'; +import { store } from '../../../store'; +import { FlowRunInspectorDrawer } from '../FlowRunInspectorDrawer'; + +const useFlowRunPoller = vi.hoisted(() => vi.fn()); +vi.mock('../../../hooks/useFlowRunPoller', () => ({ useFlowRunPoller })); + +function makeRun(overrides: Partial = {}): FlowRun { + return { + id: 'thread-1', + flow_id: 'flow-1', + thread_id: 'thread-1', + status: 'running', + started_at: '2026-01-01T00:00:00Z', + steps: [ + { node_id: 'fetch-data', output: { rows: 3 } }, + { node_id: 'branch', output: 'ok', port: 'true' }, + ], + pending_approvals: [], + ...overrides, + }; +} + +function renderDrawer(runId: string | null, onClose: () => void) { + return render( + + + + ); +} + +describe('FlowRunInspectorDrawer', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders null when runId is null', () => { + useFlowRunPoller.mockReturnValue({ run: null, loading: false, error: null }); + const { container } = renderDrawer(null, vi.fn()); + expect(container).toBeEmptyDOMElement(); + expect(useFlowRunPoller).toHaveBeenCalledWith(null); + }); + + it('shows a loading state before data resolves', () => { + useFlowRunPoller.mockReturnValue({ run: null, loading: true, error: null }); + renderDrawer('thread-1', vi.fn()); + expect(screen.getByTestId('flow-run-inspector-loading')).toBeInTheDocument(); + }); + + it('renders the run status pill and step list once data resolves', () => { + useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null }); + renderDrawer('thread-1', vi.fn()); + + expect(screen.getByTestId('flow-run-status-pill')).toHaveTextContent('Running'); + expect(screen.getByTestId('flow-run-steps')).toBeInTheDocument(); + expect(screen.getByText('fetch-data')).toBeInTheDocument(); + expect(screen.getByText('branch')).toBeInTheDocument(); + expect(screen.getByTestId('flow-run-step-port-1')).toHaveTextContent('true'); + }); + + it('expands a step to reveal its output', () => { + useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null }); + renderDrawer('thread-1', vi.fn()); + + const step = screen.getByTestId('flow-run-step-0'); + expect(step.querySelector('pre')).not.toBeVisible(); + fireEvent.click(screen.getAllByText('Output')[0]); + expect(step.querySelector('pre')).toBeVisible(); + expect(step.querySelector('pre')?.textContent).toContain('"rows": 3'); + }); + + it('shows an error state when the poller reports an error', () => { + useFlowRunPoller.mockReturnValue({ run: null, loading: false, error: 'network down' }); + renderDrawer('thread-1', vi.fn()); + expect(screen.getByTestId('flow-run-inspector-error')).toHaveTextContent('network down'); + }); + + it('shows the pending-approvals banner when status is pending_approval', () => { + useFlowRunPoller.mockReturnValue({ + run: makeRun({ status: 'pending_approval', pending_approvals: ['node-a', 'node-b'] }), + loading: false, + error: null, + }); + renderDrawer('thread-1', vi.fn()); + expect(screen.getByTestId('flow-run-pending-approvals-banner')).toHaveTextContent('2'); + }); + + it('does not show the pending-approvals banner for a running run', () => { + useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null }); + renderDrawer('thread-1', vi.fn()); + expect(screen.queryByTestId('flow-run-pending-approvals-banner')).not.toBeInTheDocument(); + }); + + it('shows the run.error banner when present', () => { + useFlowRunPoller.mockReturnValue({ + run: makeRun({ status: 'failed', error: 'node crashed' }), + loading: false, + error: null, + }); + renderDrawer('thread-1', vi.fn()); + expect(screen.getByTestId('flow-run-error-banner')).toHaveTextContent('node crashed'); + }); + + it('calls onClose when the close button is clicked', () => { + useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null }); + const onClose = vi.fn(); + renderDrawer('thread-1', onClose); + fireEvent.click(screen.getByTestId('flow-run-inspector-close')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when the backdrop is clicked', () => { + useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null }); + const onClose = vi.fn(); + renderDrawer('thread-1', onClose); + fireEvent.click(screen.getByTestId('flow-run-inspector-backdrop')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when Escape is pressed', () => { + useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null }); + const onClose = vi.fn(); + renderDrawer('thread-1', onClose); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/components/notifications/FlowApprovalCard.test.tsx b/app/src/components/notifications/FlowApprovalCard.test.tsx index dd4d1778b..eacc8c0b0 100644 --- a/app/src/components/notifications/FlowApprovalCard.test.tsx +++ b/app/src/components/notifications/FlowApprovalCard.test.tsx @@ -1,10 +1,12 @@ /** - * Approve/Dismiss contract for the flow-pending-approval notification card - * (issue B3a). Asserts that Approve reads `{ flow_id, thread_id, node_ids }` - * from the notification's action payload, calls `flowsApi.resumeFlow` with - * those args, clears the notification on success, surfaces a localized error - * on failure, and that Dismiss clears the notification WITHOUT calling any RPC - * (there is no `flows_deny` endpoint yet). + * Approve/Dismiss/View-run contract for the flow-pending-approval + * notification card (issues B3a + B3b). Asserts that Approve reads + * `{ flow_id, thread_id, node_ids }` from the notification's action payload, + * calls `flowsApi.resumeFlow` with those args, clears the notification on + * success, surfaces a localized error on failure (including when `node_ids` + * contains non-string entries — an invalid payload), that Dismiss clears the + * notification WITHOUT calling any RPC (there is no `flows_deny` endpoint + * yet), and that "View run" opens the {@link FlowRunInspectorDrawer}. */ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { Provider } from 'react-redux'; @@ -17,6 +19,11 @@ import FlowApprovalCard from './FlowApprovalCard'; const resumeFlow = vi.hoisted(() => vi.fn()); vi.mock('../../services/api/flowsApi', () => ({ resumeFlow })); +vi.mock('../flows/FlowRunInspectorDrawer', () => ({ + FlowRunInspectorDrawer: ({ runId }: { runId: string | null; onClose: () => void }) => + runId ?
    {runId}
    : null, +})); + function makeItem(overrides: Partial = {}): NotificationItem { return { id: 'flow-pending-approval:flow-1:thread-1', @@ -92,7 +99,11 @@ describe('FlowApprovalCard', () => { it('does NOT clear the notification when the run parks again on the next gate', async () => { // Sequential gates: resume returns with pending_approvals still non-empty and // the core re-publishes the same-id prompt — the card must not wipe it. - resumeFlow.mockResolvedValue({ output: null, pending_approvals: ['node-c'], thread_id: 'thread-1' }); + resumeFlow.mockResolvedValue({ + output: null, + pending_approvals: ['node-c'], + thread_id: 'thread-1', + }); store.dispatch({ type: 'notifications/notificationReceived', payload: makeItem() }); renderCard(makeItem()); @@ -105,9 +116,7 @@ describe('FlowApprovalCard', () => { expect(item?.actions).toHaveLength(1); expect(item?.read).toBe(false); // Approve re-enabled so the user can act on the next gate. - await waitFor(() => - expect(screen.getByTestId('flow-approval-approve')).not.toBeDisabled() - ); + await waitFor(() => expect(screen.getByTestId('flow-approval-approve')).not.toBeDisabled()); }); it('shows a localized error and re-enables the buttons when resumeFlow rejects', async () => { @@ -168,4 +177,54 @@ describe('FlowApprovalCard', () => { }); expect(resumeFlow).not.toHaveBeenCalled(); }); + + it('treats non-string node_ids as an invalid payload (Approve errors, no resumeFlow call)', async () => { + renderCard( + makeItem({ + actions: [ + { + actionId: 'approve', + label: 'Review', + payload: { flow_id: 'flow-1', thread_id: 'thread-1', node_ids: [42, null] }, + }, + ], + }) + ); + + fireEvent.click(screen.getByTestId('flow-approval-approve')); + + await waitFor(() => { + expect( + screen.getByText( + (_content, element) => + element?.tagName.toLowerCase() === 'p' && + (element?.textContent ?? '').includes( + 'Could not resume the workflow. Please try again.' + ) + ) + ).toBeInTheDocument(); + }); + expect(resumeFlow).not.toHaveBeenCalled(); + }); + + it('does not render "View run" when the payload is invalid', () => { + renderCard( + makeItem({ + actions: [{ actionId: 'approve', label: 'Review', payload: { flow_id: 'flow-1' } }], + }) + ); + expect(screen.queryByTestId('flow-approval-view-run')).not.toBeInTheDocument(); + }); + + it('"View run" opens the run inspector drawer for the payload thread_id', () => { + renderCard(makeItem()); + + expect(screen.queryByTestId('flow-run-inspector-drawer-stub')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('flow-approval-view-run')); + + const drawer = screen.getByTestId('flow-run-inspector-drawer-stub'); + expect(drawer).toBeInTheDocument(); + expect(drawer).toHaveTextContent('thread-1'); + }); }); diff --git a/app/src/components/notifications/FlowApprovalCard.tsx b/app/src/components/notifications/FlowApprovalCard.tsx index f9df5f9d6..cb4fb7f84 100644 --- a/app/src/components/notifications/FlowApprovalCard.tsx +++ b/app/src/components/notifications/FlowApprovalCard.tsx @@ -15,12 +15,16 @@ * actions and marks it read. Dismiss is UI-only: there is no `flows_deny` / * cancel-run RPC yet (documented follow-up — the run stays parked * `pending_approval` server-side and can still be approved later from the run - * history once B3b's inspector ships), so Dismiss just clears the prompt from - * the Notification Center without touching the engine. + * history), so Dismiss just clears the prompt from the Notification Center + * without touching the engine. * * Styling mirrors the existing amber approval chrome * (`WorkflowRunApprovalCard`) and the `role="alertdialog"` a11y pattern * (`ApprovalRequestCard`) so this reads as the same affordance family. + * + * "View run" (B3b) opens {@link FlowRunInspectorDrawer} for the run's status + + * step timeline (run id === the payload's `thread_id`) without disturbing the + * Approve/Dismiss flow above. */ import debug from 'debug'; import { useState } from 'react'; @@ -33,6 +37,7 @@ import { markRead, type NotificationItem, } from '../../store/notificationSlice'; +import { FlowRunInspectorDrawer } from '../flows/FlowRunInspectorDrawer'; import Button from '../ui/Button'; const log = debug('notifications:flow-approval-card'); @@ -50,7 +55,8 @@ function isFlowApprovalPayload(value: unknown): value is FlowApprovalPayload { return ( typeof record.flow_id === 'string' && typeof record.thread_id === 'string' && - Array.isArray(record.node_ids) + Array.isArray(record.node_ids) && + record.node_ids.every((x: unknown) => typeof x === 'string') ); } @@ -67,6 +73,7 @@ const FlowApprovalCard = ({ notification: n }: Props) => { const dispatch = useAppDispatch(); const [pending, setPending] = useState<'approve' | null>(null); const [error, setError] = useState(null); + const [inspecting, setInspecting] = useState(false); const payload = n.actions?.[0]?.payload; const parsed = isFlowApprovalPayload(payload) ? payload : null; @@ -174,9 +181,31 @@ const FlowApprovalCard = ({ notification: n }: Props) => { onClick={handleDismiss}> {t('notifications.flow.dismiss')} + {parsed && ( + + )}
    + {parsed && ( + setInspecting(false)} + /> + )} ); }; diff --git a/app/src/hooks/__tests__/useFlowRunPoller.test.ts b/app/src/hooks/__tests__/useFlowRunPoller.test.ts new file mode 100644 index 000000000..00fb98c4c --- /dev/null +++ b/app/src/hooks/__tests__/useFlowRunPoller.test.ts @@ -0,0 +1,197 @@ +/** + * useFlowRunPoller (issue B3b) — poll-until-terminal contract. + * + * Asserts: initial loading→resolved, 2s poll cadence while `running` / + * `pending_approval`, stop on `completed`/`failed`, stop when `runId` goes + * `null`, error surfaced (and no further poll) on rejection, and effect + * cleanup on unmount. + */ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { FlowRun } from '../../services/api/flowsApi'; +import { useFlowRunPoller } from '../useFlowRunPoller'; + +const getFlowRun = vi.hoisted(() => vi.fn()); +vi.mock('../../services/api/flowsApi', () => ({ getFlowRun })); + +function makeRun(overrides: Partial = {}): FlowRun { + return { + id: 'thread-1', + flow_id: 'flow-1', + thread_id: 'thread-1', + status: 'running', + started_at: '2026-01-01T00:00:00Z', + steps: [], + pending_approvals: [], + ...overrides, + }; +} + +describe('useFlowRunPoller', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts in loading and resolves with the first fetched run', async () => { + getFlowRun.mockResolvedValue(makeRun()); + const { result } = renderHook(() => useFlowRunPoller('thread-1')); + + expect(result.current.loading).toBe(true); + expect(result.current.run).toBeNull(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.run?.status).toBe('running'); + expect(result.current.error).toBeNull(); + }); + + it('polls every 2s while the run is running', async () => { + getFlowRun.mockResolvedValue(makeRun({ status: 'running' })); + renderHook(() => useFlowRunPoller('thread-1')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(getFlowRun).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(getFlowRun).toHaveBeenCalledTimes(2); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(getFlowRun).toHaveBeenCalledTimes(3); + }); + + it('keeps polling while pending_approval (not terminal)', async () => { + getFlowRun.mockResolvedValue(makeRun({ status: 'pending_approval' })); + const { result } = renderHook(() => useFlowRunPoller('thread-1')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.run?.status).toBe('pending_approval'); + expect(getFlowRun).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(getFlowRun).toHaveBeenCalledTimes(2); + }); + + it('stops polling once the run completes', async () => { + getFlowRun.mockResolvedValue( + makeRun({ status: 'completed', finished_at: '2026-01-01T00:01:00Z' }) + ); + const { result } = renderHook(() => useFlowRunPoller('thread-1')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.run?.status).toBe('completed'); + expect(getFlowRun).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(getFlowRun).toHaveBeenCalledTimes(1); + }); + + it('stops polling once the run fails', async () => { + getFlowRun.mockResolvedValue(makeRun({ status: 'failed', error: 'boom' })); + const { result } = renderHook(() => useFlowRunPoller('thread-1')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.run?.status).toBe('failed'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(getFlowRun).toHaveBeenCalledTimes(1); + }); + + it('stops and clears state when runId becomes null', async () => { + getFlowRun.mockResolvedValue(makeRun({ status: 'running' })); + const { result, rerender } = renderHook(({ runId }) => useFlowRunPoller(runId), { + initialProps: { runId: 'thread-1' as string | null }, + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.run).not.toBeNull(); + + rerender({ runId: null }); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(result.current.run).toBeNull(); + expect(result.current.loading).toBe(false); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(getFlowRun).toHaveBeenCalledTimes(1); + }); + + it('sets error on rejection and does not schedule another poll', async () => { + getFlowRun.mockRejectedValue(new Error('network down')); + const { result } = renderHook(() => useFlowRunPoller('thread-1')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(result.current.error).toBe('network down'); + expect(result.current.loading).toBe(false); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(getFlowRun).toHaveBeenCalledTimes(1); + }); + + it('cleans up pending timers on unmount', async () => { + getFlowRun.mockResolvedValue(makeRun({ status: 'running' })); + const { unmount } = renderHook(() => useFlowRunPoller('thread-1')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(getFlowRun).toHaveBeenCalledTimes(1); + + unmount(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + // No further calls after unmount. + expect(getFlowRun).toHaveBeenCalledTimes(1); + }); + + it('does nothing when runId starts null', async () => { + const { result } = renderHook(() => useFlowRunPoller(null)); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.run).toBeNull(); + expect(getFlowRun).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/hooks/useFlowRunPoller.ts b/app/src/hooks/useFlowRunPoller.ts new file mode 100644 index 000000000..1b4f6fb8c --- /dev/null +++ b/app/src/hooks/useFlowRunPoller.ts @@ -0,0 +1,117 @@ +/** + * useFlowRunPoller (issue B3b) + * ---------------------------- + * + * Poll-until-terminal loop for a single durable `tinyflows` run, feeding the + * {@link FlowRunInspectorDrawer}. The flows engine emits no socket events for + * run progress (same situation as the `workflow_run_*` orchestration surface), + * so this mirrors the setTimeout-chained poll loop in + * `components/intelligence/IntelligenceOrchestrationTab.tsx` (~lines 112-143): + * schedule the next poll only after the current one resolves and the run is + * still non-terminal, guard against races with `cancelled`/`inFlight`, and + * never let an unmounted component call `setState`. + * + * `pending_approval` is explicitly NOT terminal — a paused run still needs + * live status so the drawer reflects an approval elsewhere resolving it. + */ +import debug from 'debug'; +import { useEffect, useRef, useState } from 'react'; + +import { type FlowRun, type FlowRunStatus, getFlowRun } from '../services/api/flowsApi'; + +const log = debug('flows:poller'); + +/** How often to poll a non-terminal run for progress. */ +const POLL_INTERVAL_MS = 2000; + +const TERMINAL = new Set(['completed', 'failed']); + +function isTerminal(run: FlowRun | null): boolean { + return run !== null && TERMINAL.has(run.status); +} + +export interface UseFlowRunPollerResult { + run: FlowRun | null; + loading: boolean; + error: string | null; +} + +/** + * Poll `openhuman.flows_get_run` for `runId` every {@link POLL_INTERVAL_MS}ms + * while the run is `running` or `pending_approval`. Stops polling once the + * run reaches a terminal status, when `runId` becomes `null`, when `runId` + * changes, or on unmount. A failed fetch surfaces `error` and does NOT + * schedule another poll — a broken endpoint shouldn't be hammered. + */ +export function useFlowRunPoller(runId: string | null): UseFlowRunPollerResult { + // Lazy initial state keyed off the `runId` this hook instance first mounts + // with, so the loading spinner is already correct on the very first paint + // without a synchronous `setState` in the effect body below. + const [run, setRun] = useState(null); + const [loading, setLoading] = useState(() => runId !== null); + const [error, setError] = useState(null); + + const mountedRef = useRef(true); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + useEffect(() => { + // Reset view state for the new target — avoids painting the previous + // runId's data/error under a different runId while the first fetch for + // it is in flight. (On the very first mount this just re-applies the + // lazy-initial values above, so it's a no-op paint-wise.) + setRun(null); + setError(null); + + if (!runId) { + setLoading(false); + return; + } + setLoading(true); + + let cancelled = false; + let inFlight = false; + let pollHandle: number | undefined; + + const tick = async () => { + if (cancelled || inFlight) return; + inFlight = true; + try { + const next = await getFlowRun(runId); + if (cancelled || !mountedRef.current) return; + setRun(next); + setLoading(false); + setError(null); + if (!isTerminal(next)) { + pollHandle = window.setTimeout(() => void tick(), POLL_INTERVAL_MS); + } else { + log('tick: runId=%s reached terminal status=%s', runId, next.status); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log('tick: error runId=%s err=%s', runId, msg); + if (cancelled || !mountedRef.current) return; + setError(msg); + setLoading(false); + // Do not schedule another poll — leave retrying to the caller (e.g. + // reopening the drawer) rather than hammering a broken endpoint. + } finally { + inFlight = false; + } + }; + + void tick(); + return () => { + cancelled = true; + if (pollHandle !== undefined) window.clearTimeout(pollHandle); + }; + }, [runId]); + + return { run, loading, error }; +} + +export default useFlowRunPoller; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 7e1041ae8..bd4b321da 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -3547,6 +3547,24 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': 'في انتظار {count} من بوابات الموافقة', 'notifications.flow.approveHint': 'استئناف سير العمل بعد نقطة التحقق هذه', 'notifications.flow.dismissHint': 'إخفاء هذا التنبيه دون استئناف سير العمل', + 'notifications.flow.viewRun': 'عرض التشغيل', + 'flowRuns.inspector.title': 'تفاصيل التشغيل', + 'flowRuns.inspector.startedAt': 'بدأ', + 'flowRuns.inspector.finishedAt': 'انتهى', + 'flowRuns.inspector.running': 'قيد التشغيل…', + 'flowRuns.inspector.error': 'خطأ', + 'flowRuns.inspector.pendingApprovals': 'الموافقات المعلقة', + 'flowRuns.inspector.pendingApprovalsCount': '{count} عقدة (عقد) في انتظار الموافقة', + 'flowRuns.inspector.steps': 'الخطوات', + 'flowRuns.inspector.noSteps': 'لم يتم تسجيل أي خطوات بعد.', + 'flowRuns.inspector.output': 'المخرجات', + 'flowRuns.inspector.port': 'المنفذ', + 'flowRuns.inspector.loading': 'جارٍ تحميل التشغيل…', + 'flowRuns.inspector.loadError': 'تعذّر تحميل هذا التشغيل', + 'flowRuns.status.running': 'قيد التشغيل', + 'flowRuns.status.completed': 'مكتمل', + 'flowRuns.status.pending_approval': 'بانتظار الموافقة', + 'flowRuns.status.failed': 'فشل', 'oauth.button.connecting': 'جارٍ الاتصال...', 'oauth.button.loopbackTimeout': 'انتهت مهلة تسجيل الدخول — لم يكتمل المتصفح إعادة توجيه OAuth. يرجى المحاولة مرة أخرى.', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 31d77ad06..900a33336 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -3628,6 +3628,24 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': '{count}টি অনুমোদন গেট মুলতুবি আছে', 'notifications.flow.approveHint': 'এই চেকপয়েন্টের পরে ওয়ার্কফ্লো আবার শুরু করুন', 'notifications.flow.dismissHint': 'ওয়ার্কফ্লো আবার শুরু না করে এই প্রম্পটটি লুকান', + 'notifications.flow.viewRun': 'রান দেখুন', + 'flowRuns.inspector.title': 'রান বিবরণ', + 'flowRuns.inspector.startedAt': 'শুরু হয়েছে', + 'flowRuns.inspector.finishedAt': 'শেষ হয়েছে', + 'flowRuns.inspector.running': 'চলছে…', + 'flowRuns.inspector.error': 'ত্রুটি', + 'flowRuns.inspector.pendingApprovals': 'মুলতুবি অনুমোদন', + 'flowRuns.inspector.pendingApprovalsCount': '{count}টি নোড অনুমোদনের অপেক্ষায়', + 'flowRuns.inspector.steps': 'ধাপ', + 'flowRuns.inspector.noSteps': 'এখনও কোনো ধাপ রেকর্ড করা হয়নি।', + 'flowRuns.inspector.output': 'আউটপুট', + 'flowRuns.inspector.port': 'পোর্ট', + 'flowRuns.inspector.loading': 'রান লোড হচ্ছে…', + 'flowRuns.inspector.loadError': 'এই রানটি লোড করা যায়নি', + 'flowRuns.status.running': 'চলছে', + 'flowRuns.status.completed': 'সম্পন্ন', + 'flowRuns.status.pending_approval': 'অনুমোদনের অপেক্ষায়', + 'flowRuns.status.failed': 'ব্যর্থ', 'oauth.button.connecting': 'সংযোগ হচ্ছে...', 'oauth.button.loopbackTimeout': 'সাইন-ইন টাইম আউট হয়েছে — ব্রাউজার OAuth পুনর্নির্দেশনা সম্পন্ন করেনি। অনুগ্রহ করে আবার চেষ্টা করুন।', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 72e1d7d61..251110491 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -3717,6 +3717,24 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': 'Wartet auf {count} Genehmigungsschritt(e)', 'notifications.flow.approveHint': 'Workflow nach diesem Kontrollpunkt fortsetzen', 'notifications.flow.dismissHint': 'Diesen Hinweis ausblenden, ohne den Workflow fortzusetzen', + 'notifications.flow.viewRun': 'Lauf anzeigen', + 'flowRuns.inspector.title': 'Laufdetails', + 'flowRuns.inspector.startedAt': 'Gestartet', + 'flowRuns.inspector.finishedAt': 'Beendet', + 'flowRuns.inspector.running': 'Läuft…', + 'flowRuns.inspector.error': 'Fehler', + 'flowRuns.inspector.pendingApprovals': 'Ausstehende Genehmigungen', + 'flowRuns.inspector.pendingApprovalsCount': '{count} Knoten warten auf Genehmigung', + 'flowRuns.inspector.steps': 'Schritte', + 'flowRuns.inspector.noSteps': 'Noch keine Schritte aufgezeichnet.', + 'flowRuns.inspector.output': 'Ausgabe', + 'flowRuns.inspector.port': 'Port', + 'flowRuns.inspector.loading': 'Lauf wird geladen…', + 'flowRuns.inspector.loadError': 'Dieser Lauf konnte nicht geladen werden', + 'flowRuns.status.running': 'Läuft', + 'flowRuns.status.completed': 'Abgeschlossen', + 'flowRuns.status.pending_approval': 'Wartet auf Genehmigung', + 'flowRuns.status.failed': 'Fehlgeschlagen', 'oauth.button.connecting': 'Verbinden...', 'oauth.button.loopbackTimeout': 'Anmeldung abgelaufen — der Browser hat die OAuth-Weiterleitung nicht abgeschlossen. Bitte versuche es erneut.', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index d280d2a51..67cc47781 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -4267,6 +4267,24 @@ const en: TranslationMap = { 'notifications.flow.gateCount': 'Waiting on {count} approval gate(s)', 'notifications.flow.approveHint': 'Resume the workflow past this checkpoint', 'notifications.flow.dismissHint': 'Hide this prompt without resuming the workflow', + 'notifications.flow.viewRun': 'View run', + 'flowRuns.inspector.title': 'Run details', + 'flowRuns.inspector.startedAt': 'Started', + 'flowRuns.inspector.finishedAt': 'Finished', + 'flowRuns.inspector.running': 'Running…', + 'flowRuns.inspector.error': 'Error', + 'flowRuns.inspector.pendingApprovals': 'Pending approvals', + 'flowRuns.inspector.pendingApprovalsCount': '{count} node(s) awaiting approval', + 'flowRuns.inspector.steps': 'Steps', + 'flowRuns.inspector.noSteps': 'No steps recorded yet.', + 'flowRuns.inspector.output': 'Output', + 'flowRuns.inspector.port': 'Port', + 'flowRuns.inspector.loading': 'Loading run…', + 'flowRuns.inspector.loadError': 'Could not load this run', + 'flowRuns.status.running': 'Running', + 'flowRuns.status.completed': 'Completed', + 'flowRuns.status.pending_approval': 'Awaiting approval', + 'flowRuns.status.failed': 'Failed', 'oauth.button.connecting': 'Connecting...', 'oauth.button.loopbackTimeout': 'Sign-in timed out — the browser did not complete the OAuth redirect. Please try again.', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index f5902382a..9932d82e7 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -3691,6 +3691,24 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': 'Esperando {count} puerta(s) de aprobación', 'notifications.flow.approveHint': 'Reanudar el flujo de trabajo después de este punto de control', 'notifications.flow.dismissHint': 'Ocultar este aviso sin reanudar el flujo de trabajo', + 'notifications.flow.viewRun': 'Ver ejecución', + 'flowRuns.inspector.title': 'Detalles de la ejecución', + 'flowRuns.inspector.startedAt': 'Iniciado', + 'flowRuns.inspector.finishedAt': 'Finalizado', + 'flowRuns.inspector.running': 'En ejecución…', + 'flowRuns.inspector.error': 'Error', + 'flowRuns.inspector.pendingApprovals': 'Aprobaciones pendientes', + 'flowRuns.inspector.pendingApprovalsCount': '{count} nodo(s) esperando aprobación', + 'flowRuns.inspector.steps': 'Pasos', + 'flowRuns.inspector.noSteps': 'Aún no se han registrado pasos.', + 'flowRuns.inspector.output': 'Salida', + 'flowRuns.inspector.port': 'Puerto', + 'flowRuns.inspector.loading': 'Cargando ejecución…', + 'flowRuns.inspector.loadError': 'No se pudo cargar esta ejecución', + 'flowRuns.status.running': 'En ejecución', + 'flowRuns.status.completed': 'Completado', + 'flowRuns.status.pending_approval': 'Esperando aprobación', + 'flowRuns.status.failed': 'Fallido', 'oauth.button.connecting': 'Conectando...', 'oauth.button.loopbackTimeout': 'El inicio de sesión expiró — el navegador no completó la redirección OAuth. Por favor, inténtalo de nuevo.', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 7589d55af..0cf916c15 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -3706,6 +3706,24 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': 'En attente de {count} validation(s)', 'notifications.flow.approveHint': 'Reprendre le workflow après ce point de contrôle', 'notifications.flow.dismissHint': 'Masquer cette invite sans reprendre le workflow', + 'notifications.flow.viewRun': "Voir l'exécution", + 'flowRuns.inspector.title': "Détails de l'exécution", + 'flowRuns.inspector.startedAt': 'Démarré', + 'flowRuns.inspector.finishedAt': 'Terminé', + 'flowRuns.inspector.running': 'En cours…', + 'flowRuns.inspector.error': 'Erreur', + 'flowRuns.inspector.pendingApprovals': 'Approbations en attente', + 'flowRuns.inspector.pendingApprovalsCount': "{count} nœud(s) en attente d'approbation", + 'flowRuns.inspector.steps': 'Étapes', + 'flowRuns.inspector.noSteps': 'Aucune étape enregistrée pour le moment.', + 'flowRuns.inspector.output': 'Sortie', + 'flowRuns.inspector.port': 'Port', + 'flowRuns.inspector.loading': "Chargement de l'exécution…", + 'flowRuns.inspector.loadError': 'Impossible de charger cette exécution', + 'flowRuns.status.running': 'En cours', + 'flowRuns.status.completed': 'Terminé', + 'flowRuns.status.pending_approval': "En attente d'approbation", + 'flowRuns.status.failed': 'Échoué', 'oauth.button.connecting': 'Connexion en cours…', 'oauth.button.loopbackTimeout': "La connexion a expiré — le navigateur n'a pas complété la redirection OAuth. Veuillez réessayer.", diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 0b430e1d7..2e1eea58a 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -3629,6 +3629,24 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': '{count} अनुमोदन गेट लंबित हैं', 'notifications.flow.approveHint': 'इस चेकपॉइंट के बाद वर्कफ़्लो फिर से शुरू करें', 'notifications.flow.dismissHint': 'वर्कफ़्लो को फिर से शुरू किए बिना यह संकेत छिपाएं', + 'notifications.flow.viewRun': 'रन देखें', + 'flowRuns.inspector.title': 'रन विवरण', + 'flowRuns.inspector.startedAt': 'शुरू हुआ', + 'flowRuns.inspector.finishedAt': 'समाप्त हुआ', + 'flowRuns.inspector.running': 'चल रहा है…', + 'flowRuns.inspector.error': 'त्रुटि', + 'flowRuns.inspector.pendingApprovals': 'लंबित अनुमोदन', + 'flowRuns.inspector.pendingApprovalsCount': '{count} नोड अनुमोदन की प्रतीक्षा में', + 'flowRuns.inspector.steps': 'चरण', + 'flowRuns.inspector.noSteps': 'अभी तक कोई चरण दर्ज नहीं किया गया है।', + 'flowRuns.inspector.output': 'आउटपुट', + 'flowRuns.inspector.port': 'पोर्ट', + 'flowRuns.inspector.loading': 'रन लोड हो रहा है…', + 'flowRuns.inspector.loadError': 'यह रन लोड नहीं हो सका', + 'flowRuns.status.running': 'चल रहा है', + 'flowRuns.status.completed': 'पूर्ण', + 'flowRuns.status.pending_approval': 'अनुमोदन की प्रतीक्षा में', + 'flowRuns.status.failed': 'विफल', 'oauth.button.connecting': 'कनेक्ट हो रहा है...', 'oauth.button.loopbackTimeout': 'साइन-इन का समय समाप्त हो गया — ब्राउज़र ने OAuth पुनर्निर्देशन पूरा नहीं किया। कृपया पुनः प्रयास करें।', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index cd16746be..52d3648c5 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -3636,6 +3636,24 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': 'Menunggu {count} gerbang persetujuan', 'notifications.flow.approveHint': 'Lanjutkan alur kerja setelah titik pemeriksaan ini', 'notifications.flow.dismissHint': 'Sembunyikan prompt ini tanpa melanjutkan alur kerja', + 'notifications.flow.viewRun': 'Lihat proses', + 'flowRuns.inspector.title': 'Detail proses', + 'flowRuns.inspector.startedAt': 'Dimulai', + 'flowRuns.inspector.finishedAt': 'Selesai', + 'flowRuns.inspector.running': 'Berjalan…', + 'flowRuns.inspector.error': 'Kesalahan', + 'flowRuns.inspector.pendingApprovals': 'Persetujuan tertunda', + 'flowRuns.inspector.pendingApprovalsCount': '{count} node menunggu persetujuan', + 'flowRuns.inspector.steps': 'Langkah', + 'flowRuns.inspector.noSteps': 'Belum ada langkah yang tercatat.', + 'flowRuns.inspector.output': 'Keluaran', + 'flowRuns.inspector.port': 'Port', + 'flowRuns.inspector.loading': 'Memuat proses…', + 'flowRuns.inspector.loadError': 'Tidak dapat memuat proses ini', + 'flowRuns.status.running': 'Berjalan', + 'flowRuns.status.completed': 'Selesai', + 'flowRuns.status.pending_approval': 'Menunggu persetujuan', + 'flowRuns.status.failed': 'Gagal', 'oauth.button.connecting': 'Menghubungkan...', 'oauth.button.loopbackTimeout': 'Masuk habis waktu — browser tidak menyelesaikan pengalihan OAuth. Silakan coba lagi.', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 4fc856d0a..cc2db3fd7 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -3686,6 +3686,24 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': 'In attesa di {count} punto/i di approvazione', 'notifications.flow.approveHint': 'Riprendi il workflow dopo questo checkpoint', 'notifications.flow.dismissHint': 'Nascondi questo avviso senza riprendere il workflow', + 'notifications.flow.viewRun': 'Visualizza esecuzione', + 'flowRuns.inspector.title': 'Dettagli esecuzione', + 'flowRuns.inspector.startedAt': 'Avviato', + 'flowRuns.inspector.finishedAt': 'Terminato', + 'flowRuns.inspector.running': 'In esecuzione…', + 'flowRuns.inspector.error': 'Errore', + 'flowRuns.inspector.pendingApprovals': 'Approvazioni in sospeso', + 'flowRuns.inspector.pendingApprovalsCount': '{count} nodo/i in attesa di approvazione', + 'flowRuns.inspector.steps': 'Passaggi', + 'flowRuns.inspector.noSteps': 'Nessun passaggio registrato finora.', + 'flowRuns.inspector.output': 'Output', + 'flowRuns.inspector.port': 'Porta', + 'flowRuns.inspector.loading': 'Caricamento esecuzione…', + 'flowRuns.inspector.loadError': 'Impossibile caricare questa esecuzione', + 'flowRuns.status.running': 'In esecuzione', + 'flowRuns.status.completed': 'Completato', + 'flowRuns.status.pending_approval': 'In attesa di approvazione', + 'flowRuns.status.failed': 'Non riuscito', 'oauth.button.connecting': 'Connessione...', 'oauth.button.loopbackTimeout': 'Accesso scaduto — il browser non ha completato il reindirizzamento OAuth. Riprova.', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 60e43df20..bac9448dc 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -3592,6 +3592,24 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': '승인 게이트 {count}개 대기 중', 'notifications.flow.approveHint': '이 체크포인트 이후 워크플로 재개', 'notifications.flow.dismissHint': '워크플로를 재개하지 않고 이 알림 숨기기', + 'notifications.flow.viewRun': '실행 보기', + 'flowRuns.inspector.title': '실행 세부정보', + 'flowRuns.inspector.startedAt': '시작됨', + 'flowRuns.inspector.finishedAt': '종료됨', + 'flowRuns.inspector.running': '실행 중…', + 'flowRuns.inspector.error': '오류', + 'flowRuns.inspector.pendingApprovals': '대기 중인 승인', + 'flowRuns.inspector.pendingApprovalsCount': '노드 {count}개가 승인 대기 중', + 'flowRuns.inspector.steps': '단계', + 'flowRuns.inspector.noSteps': '아직 기록된 단계가 없습니다.', + 'flowRuns.inspector.output': '출력', + 'flowRuns.inspector.port': '포트', + 'flowRuns.inspector.loading': '실행 로드 중…', + 'flowRuns.inspector.loadError': '이 실행을 로드할 수 없습니다', + 'flowRuns.status.running': '실행 중', + 'flowRuns.status.completed': '완료됨', + 'flowRuns.status.pending_approval': '승인 대기 중', + 'flowRuns.status.failed': '실패', 'oauth.button.connecting': '연결 중...', 'oauth.button.loopbackTimeout': '로그인 시간 초과 — 브라우저가 OAuth 리디렉션을 완료하지 못했습니다. 다시 시도해 주세요.', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 7219c7434..7dec11c44 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -3672,6 +3672,24 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': 'Oczekiwanie na {count} bramkę/bramki zatwierdzenia', 'notifications.flow.approveHint': 'Wznów przepływ pracy po tym punkcie kontrolnym', 'notifications.flow.dismissHint': 'Ukryj ten monit bez wznawiania przepływu pracy', + 'notifications.flow.viewRun': 'Zobacz przebieg', + 'flowRuns.inspector.title': 'Szczegóły przebiegu', + 'flowRuns.inspector.startedAt': 'Rozpoczęto', + 'flowRuns.inspector.finishedAt': 'Zakończono', + 'flowRuns.inspector.running': 'W trakcie…', + 'flowRuns.inspector.error': 'Błąd', + 'flowRuns.inspector.pendingApprovals': 'Oczekujące zatwierdzenia', + 'flowRuns.inspector.pendingApprovalsCount': '{count} węzeł(y) oczekuje(ą) na zatwierdzenie', + 'flowRuns.inspector.steps': 'Kroki', + 'flowRuns.inspector.noSteps': 'Nie zarejestrowano jeszcze żadnych kroków.', + 'flowRuns.inspector.output': 'Dane wyjściowe', + 'flowRuns.inspector.port': 'Port', + 'flowRuns.inspector.loading': 'Ładowanie przebiegu…', + 'flowRuns.inspector.loadError': 'Nie można załadować tego przebiegu', + 'flowRuns.status.running': 'W trakcie', + 'flowRuns.status.completed': 'Zakończono', + 'flowRuns.status.pending_approval': 'Oczekuje na zatwierdzenie', + 'flowRuns.status.failed': 'Niepowodzenie', 'oauth.button.connecting': 'Łączenie...', 'oauth.button.loopbackTimeout': 'Logowanie przekroczyło limit czasu — przeglądarka nie ukończyła przekierowania OAuth. Spróbuj ponownie.', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 8f3c24af0..5851ee219 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -3687,6 +3687,24 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': 'Aguardando {count} porta(s) de aprovação', 'notifications.flow.approveHint': 'Retomar o fluxo de trabalho após este ponto de verificação', 'notifications.flow.dismissHint': 'Ocultar este aviso sem retomar o fluxo de trabalho', + 'notifications.flow.viewRun': 'Ver execução', + 'flowRuns.inspector.title': 'Detalhes da execução', + 'flowRuns.inspector.startedAt': 'Iniciado', + 'flowRuns.inspector.finishedAt': 'Concluído', + 'flowRuns.inspector.running': 'Em execução…', + 'flowRuns.inspector.error': 'Erro', + 'flowRuns.inspector.pendingApprovals': 'Aprovações pendentes', + 'flowRuns.inspector.pendingApprovalsCount': '{count} nó(s) aguardando aprovação', + 'flowRuns.inspector.steps': 'Etapas', + 'flowRuns.inspector.noSteps': 'Nenhuma etapa registrada ainda.', + 'flowRuns.inspector.output': 'Saída', + 'flowRuns.inspector.port': 'Porta', + 'flowRuns.inspector.loading': 'Carregando execução…', + 'flowRuns.inspector.loadError': 'Não foi possível carregar esta execução', + 'flowRuns.status.running': 'Em execução', + 'flowRuns.status.completed': 'Concluído', + 'flowRuns.status.pending_approval': 'Aguardando aprovação', + 'flowRuns.status.failed': 'Falhou', 'oauth.button.connecting': 'Conectando...', 'oauth.button.loopbackTimeout': 'Login expirou — o navegador não concluiu o redirecionamento OAuth. Por favor, tente novamente.', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 8cd8c7196..e122c6b4d 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -3661,6 +3661,24 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': 'Ожидание {count} шлюз(ов) одобрения', 'notifications.flow.approveHint': 'Возобновить рабочий процесс после этой контрольной точки', 'notifications.flow.dismissHint': 'Скрыть это уведомление без возобновления рабочего процесса', + 'notifications.flow.viewRun': 'Просмотреть запуск', + 'flowRuns.inspector.title': 'Детали запуска', + 'flowRuns.inspector.startedAt': 'Начато', + 'flowRuns.inspector.finishedAt': 'Завершено', + 'flowRuns.inspector.running': 'Выполняется…', + 'flowRuns.inspector.error': 'Ошибка', + 'flowRuns.inspector.pendingApprovals': 'Ожидающие подтверждения', + 'flowRuns.inspector.pendingApprovalsCount': '{count} узел(-ов) ожидает подтверждения', + 'flowRuns.inspector.steps': 'Шаги', + 'flowRuns.inspector.noSteps': 'Пока не зафиксировано ни одного шага.', + 'flowRuns.inspector.output': 'Вывод', + 'flowRuns.inspector.port': 'Порт', + 'flowRuns.inspector.loading': 'Загрузка запуска…', + 'flowRuns.inspector.loadError': 'Не удалось загрузить этот запуск', + 'flowRuns.status.running': 'Выполняется', + 'flowRuns.status.completed': 'Завершено', + 'flowRuns.status.pending_approval': 'Ожидает подтверждения', + 'flowRuns.status.failed': 'Не удалось', 'oauth.button.connecting': 'Подключение...', 'oauth.button.loopbackTimeout': 'Время входа истекло — браузер не завершил перенаправление OAuth. Пожалуйста, попробуйте снова.', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 4edbf7f58..2048e579e 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -3438,6 +3438,24 @@ const messages: TranslationMap = { 'notifications.flow.gateCount': '正在等待 {count} 个批准节点', 'notifications.flow.approveHint': '在此检查点之后恢复工作流', 'notifications.flow.dismissHint': '隐藏此提示但不恢复工作流', + 'notifications.flow.viewRun': '查看运行', + 'flowRuns.inspector.title': '运行详情', + 'flowRuns.inspector.startedAt': '开始时间', + 'flowRuns.inspector.finishedAt': '结束时间', + 'flowRuns.inspector.running': '运行中…', + 'flowRuns.inspector.error': '错误', + 'flowRuns.inspector.pendingApprovals': '待批准', + 'flowRuns.inspector.pendingApprovalsCount': '{count} 个节点等待批准', + 'flowRuns.inspector.steps': '步骤', + 'flowRuns.inspector.noSteps': '尚未记录任何步骤。', + 'flowRuns.inspector.output': '输出', + 'flowRuns.inspector.port': '端口', + 'flowRuns.inspector.loading': '正在加载运行…', + 'flowRuns.inspector.loadError': '无法加载此运行', + 'flowRuns.status.running': '运行中', + 'flowRuns.status.completed': '已完成', + 'flowRuns.status.pending_approval': '等待批准', + 'flowRuns.status.failed': '失败', 'oauth.button.connecting': '连接中...', 'oauth.button.loopbackTimeout': '登录超时 — 浏览器未完成 OAuth 跳转。请重试。', 'oauth.login.continueWith': '继续使用',