feat(flows): frontend — surface flow-run approvals (run-details + chat + notifications) (#4674)

This commit is contained in:
Cyrus Gray
2026-07-08 02:51:28 +05:30
committed by GitHub
parent 771eeff5e4
commit dfcf71a7fa
31 changed files with 1907 additions and 29 deletions
@@ -0,0 +1,119 @@
/**
* FlowApprovalRequestCard (flow-approval surface — chat)
* ---------------------------------------------------------
*
* Chat-surfaced banner for a single `flow_approval_request` socket event
* (see {@link useFlowApprovalRequests}) — a paused `tinyflows` run's gate,
* shown to the user while they're chatting rather than inspecting the run
* directly. Styling mirrors `ApprovalRequestCard` (the thread-scoped chat
* tool-approval card) so both read as the same affordance family; unlike
* that card, this one isn't keyed to the active thread — the payload has no
* `thread_id` — so it renders independent of which thread is selected.
*
* All three decisions route through the shared `openhuman.approval_decide`
* RPC via {@link decideApproval}. On success (or once the request no longer
* needs surfacing) the parent removes it from its list via `onResolved`.
*/
import debug from 'debug';
import React, { useState } from 'react';
import type { FlowApprovalRequest } from '../../hooks/useFlowApprovalRequests';
import { useT } from '../../lib/i18n/I18nContext';
import { type ApprovalDecision, decideApproval } from '../../services/api/approvalApi';
import Button from '../ui/Button';
const log = debug('openhuman:chat:flow-approval-card');
interface Props {
request: FlowApprovalRequest;
onResolved: (requestId: string) => void;
}
export const FlowApprovalRequestCard: React.FC<Props> = ({ request, onResolved }) => {
const { t } = useT();
const [deciding, setDeciding] = useState<ApprovalDecision | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const decide = async (decision: ApprovalDecision) => {
if (deciding) return;
setDeciding(decision);
setErrorMsg(null);
try {
await decideApproval(request.request_id, decision);
log('decide: ok request=%s decision=%s', request.request_id, decision);
onResolved(request.request_id);
} catch (err) {
log('decide: failed request=%s err=%o', request.request_id, err);
setErrorMsg(t('chat.flowApproval.error'));
setDeciding(null);
}
};
return (
<div
role="alertdialog"
aria-label={t('chat.flowApproval.title')}
data-testid="flow-approval-request-card"
className="rounded-xl border border-amber-300 bg-amber-50 p-3 text-sm shadow-sm dark:border-amber-700 dark:bg-amber-950">
<div className="flex items-start gap-2">
<span aria-hidden className="text-base leading-none text-amber-700 dark:text-amber-200">
🔒
</span>
<div className="min-w-0 flex-1">
<p className="font-semibold text-amber-900 dark:text-amber-100">
{t('chat.flowApproval.title')}
</p>
<p className="mt-1 break-words text-amber-800/90 dark:text-amber-200/90">
{request.summary || t('chat.flowApproval.fallback')}
</p>
<p className="mt-1 text-xs text-amber-800/80 dark:text-amber-200/80">
{t('chat.flowApproval.tool')}{' '}
<span className="font-mono text-amber-950 dark:text-amber-100">
{request.tool_name}
</span>
</p>
<p className="mt-0.5 text-xs text-amber-800/80 dark:text-amber-200/80">
{t('chat.flowApproval.flow')}{' '}
<span className="font-mono text-amber-950 dark:text-amber-100">{request.flow_id}</span>
</p>
{errorMsg && <p className="mt-2 text-xs text-coral"> {errorMsg}</p>}
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
variant="primary"
size="sm"
data-testid="flow-approval-request-approve"
onClick={() => void decide('approve_once')}
disabled={deciding !== null}>
{deciding === 'approve_once'
? t('chat.flowApproval.deciding')
: t('chat.flowApproval.approve')}
</Button>
<Button
variant="secondary"
size="sm"
data-testid="flow-approval-request-always"
onClick={() => void decide('approve_always_for_flow')}
disabled={deciding !== null}
title={t('chat.flowApproval.approveAlwaysHint')}>
{deciding === 'approve_always_for_flow'
? t('chat.flowApproval.deciding')
: t('chat.flowApproval.approveAlways')}
</Button>
<Button
variant="secondary"
size="sm"
data-testid="flow-approval-request-deny"
onClick={() => void decide('deny')}
disabled={deciding !== null}>
{deciding === 'deny' ? t('chat.flowApproval.deciding') : t('chat.flowApproval.deny')}
</Button>
</div>
</div>
</div>
</div>
);
};
export default FlowApprovalRequestCard;
@@ -0,0 +1,89 @@
/**
* FlowApprovalRequestCard (flow-approval surface — chat) — rendering +
* decision contract. Mirrors `ApprovalRequestCard.test.tsx`'s approach: mocks
* `decideApproval` directly rather than the underlying RPC client.
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { FlowApprovalRequest } from '../../../hooks/useFlowApprovalRequests';
import { decideApproval } from '../../../services/api/approvalApi';
import FlowApprovalRequestCard from '../FlowApprovalRequestCard';
vi.mock('../../../services/api/approvalApi', () => ({ decideApproval: vi.fn() }));
const REQUEST: FlowApprovalRequest = {
request_id: 'req-1',
flow_id: 'flow-1',
run_id: 'run-1',
tool_name: 'shell',
summary: 'Run `shell` — rm -rf /tmp/scratch',
};
describe('FlowApprovalRequestCard', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the summary, tool name, and flow id', () => {
render(<FlowApprovalRequestCard request={REQUEST} onResolved={vi.fn()} />);
expect(screen.getByText('Workflow needs approval')).toBeInTheDocument();
expect(screen.getByText('Run `shell` — rm -rf /tmp/scratch')).toBeInTheDocument();
expect(screen.getByText('shell')).toBeInTheDocument();
expect(screen.getByText('flow-1')).toBeInTheDocument();
});
it('falls back to the generic prompt when summary is empty', () => {
render(<FlowApprovalRequestCard request={{ ...REQUEST, summary: '' }} onResolved={vi.fn()} />);
expect(
screen.getByText('A workflow run wants to perform an action that needs your approval.')
).toBeInTheDocument();
});
it('Approve once routes approve_once to approval_decide and resolves', async () => {
vi.mocked(decideApproval).mockResolvedValueOnce(undefined);
const onResolved = vi.fn();
render(<FlowApprovalRequestCard request={REQUEST} onResolved={onResolved} />);
fireEvent.click(screen.getByText('Approve once'));
expect(decideApproval).toHaveBeenCalledWith('req-1', 'approve_once');
await waitFor(() => expect(onResolved).toHaveBeenCalledWith('req-1'));
});
it('Approve always routes approve_always_for_flow to approval_decide', async () => {
vi.mocked(decideApproval).mockResolvedValueOnce(undefined);
const onResolved = vi.fn();
render(<FlowApprovalRequestCard request={REQUEST} onResolved={onResolved} />);
fireEvent.click(screen.getByText('Approve always'));
expect(decideApproval).toHaveBeenCalledWith('req-1', 'approve_always_for_flow');
await waitFor(() => expect(onResolved).toHaveBeenCalledWith('req-1'));
});
it('Deny routes deny to approval_decide', async () => {
vi.mocked(decideApproval).mockResolvedValueOnce(undefined);
const onResolved = vi.fn();
render(<FlowApprovalRequestCard request={REQUEST} onResolved={onResolved} />);
fireEvent.click(screen.getByText('Deny'));
expect(decideApproval).toHaveBeenCalledWith('req-1', 'deny');
await waitFor(() => expect(onResolved).toHaveBeenCalledWith('req-1'));
});
it('keeps the prompt and shows an error when the decide RPC fails', async () => {
vi.mocked(decideApproval).mockRejectedValueOnce(new Error('gate not installed'));
const onResolved = vi.fn();
render(<FlowApprovalRequestCard request={REQUEST} onResolved={onResolved} />);
fireEvent.click(screen.getByText('Approve once'));
await waitFor(() => {
expect(screen.getByText(/Could not record your decision/)).toBeInTheDocument();
});
expect(onResolved).not.toHaveBeenCalled();
expect(screen.getByText('Approve once')).toBeInTheDocument();
});
});
@@ -24,12 +24,14 @@
import debug from 'debug';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { useFlowPendingApprovals } from '../../hooks/useFlowPendingApprovals';
import { useFlowRunPoller } from '../../hooks/useFlowRunPoller';
import { type FlowNodeRunStatus, useFlowRunProgress } from '../../hooks/useFlowRunProgress';
import { type FlowRunItem, normalizeItems } from '../../lib/flows/runItems';
import { useT } from '../../lib/i18n/I18nContext';
import type { FlowRunStatus, FlowRunStep } from '../../services/api/flowsApi';
import Button from '../ui/Button';
import { FlowRunPendingApprovalCard } from './FlowRunPendingApprovalCard';
import { RunItemDataBrowser } from './RunItemDataBrowser';
/**
@@ -217,6 +219,19 @@ export function FlowRunInspectorDrawer({ runId, onClose, onFixWithAgent }: Props
// Live per-node status overlay (Phase 3e): the socket feed makes the poller's
// durable step list feel live without replacing it as the source of truth.
const liveStatuses = useFlowRunProgress(runId);
// Actionable approval gates for this run (flow-approval surface — run
// details). Only polls while the run is in an active state; `null`/`null`
// stops the underlying poll loop.
const isActiveRun = !!run && (run.status === 'running' || run.status === 'pending_approval');
const {
approvals: pendingApprovals,
decidingId: decidingApprovalId,
error: pendingApprovalsError,
decide: decideApproval,
} = useFlowPendingApprovals(
isActiveRun && run ? run.flow_id : null,
isActiveRun && run ? run.thread_id : null
);
const handleFixWithAgent = () => {
if (!run || !onFixWithAgent) return;
@@ -247,7 +262,6 @@ export function FlowRunInspectorDrawer({ runId, onClose, onFixWithAgent }: Props
const startedAt = formatTimestamp(run?.started_at);
const finishedAt = formatTimestamp(run?.finished_at);
const pendingCount = run?.pending_approvals.length ?? 0;
return (
<div className="fixed inset-0 z-50 flex justify-end" data-testid="flow-run-inspector-drawer">
@@ -358,14 +372,32 @@ export function FlowRunInspectorDrawer({ runId, onClose, onFixWithAgent }: Props
</div>
)}
{/* Pending approvals banner */}
{run.status === 'pending_approval' && pendingCount > 0 && (
<div
data-testid="flow-run-pending-approvals-banner"
className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-300">
{t('flowRuns.inspector.pendingApprovalsCount').replace(
'{count}',
String(pendingCount)
{/* Actionable pending-approval gates for this run (flow-approval
surface). Replaces the old read-only "N node(s) awaiting
approval" banner — Approve once / Approve always / Deny
resolve the gate in place via `openhuman.approval_decide`;
the run poller above picks up the resulting steps on its
own 2s cadence once the gate clears. */}
{isActiveRun && pendingApprovals.length > 0 && (
<div className="space-y-2" data-testid="flow-run-pending-approvals">
<h3 className="text-xs font-semibold uppercase tracking-wide text-content-muted">
{t('flowRuns.inspector.pendingApprovals')}
</h3>
{pendingApprovals.map(approval => (
<FlowRunPendingApprovalCard
key={approval.request_id}
approval={approval}
deciding={decidingApprovalId === approval.request_id}
onDecide={decision => decideApproval(approval.request_id, decision)}
/>
))}
{pendingApprovalsError && (
<p
role="alert"
data-testid="flow-run-pending-approvals-error"
className="text-xs text-coral-600 dark:text-coral-400">
{t('flowRuns.inspector.approval.loadError')}
</p>
)}
</div>
)}
@@ -0,0 +1,100 @@
/**
* FlowRunPendingApprovalCard (flow-approval surface — run details)
* ------------------------------------------------------------------
*
* Actionable replacement for the old read-only "N node(s) awaiting approval"
* banner in `FlowRunInspectorDrawer`. Renders one gate from
* `useFlowPendingApprovals` with Approve once / Approve always / Deny,
* routing every decision through `openhuman.approval_decide` (same RPC and
* decision vocabulary as the chat `ApprovalRequestCard`). Styling mirrors
* that card's amber warning chrome, scaled down for the drawer's narrower
* column.
*/
import { useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { type ApprovalDecision, type PendingApproval } from '../../services/api/approvalApi';
import Button from '../ui/Button';
interface Props {
approval: PendingApproval;
/** Whether THIS approval's decision RPC is currently in flight. */
deciding: boolean;
onDecide: (decision: ApprovalDecision) => Promise<void>;
}
export function FlowRunPendingApprovalCard({ approval, deciding, onDecide }: Props) {
const { t } = useT();
const [localDecision, setLocalDecision] = useState<ApprovalDecision | null>(null);
const handleDecide = (decision: ApprovalDecision) => {
if (deciding) return;
setLocalDecision(decision);
void onDecide(decision).catch(() => {
// Error surfaces via the hook's shared `error` field; nothing extra to
// do here besides letting the buttons re-enable (`deciding` flips back
// to false on the parent's next render).
});
};
return (
<div
role="alertdialog"
aria-label={t('flowRuns.inspector.pendingApprovals')}
data-testid={`flow-run-pending-approval-${approval.request_id}`}
className="rounded-xl border border-amber-300 bg-amber-50 p-3 text-xs shadow-sm dark:border-amber-700 dark:bg-amber-950">
<div className="flex items-start gap-2">
<span aria-hidden className="text-sm leading-none">
🔒
</span>
<div className="min-w-0 flex-1">
<p className="break-words text-amber-800/90 dark:text-amber-200/90">
{approval.action_summary}
</p>
<p className="mt-1 text-[11px] text-amber-800/80 dark:text-amber-200/80">
{t('flowRuns.inspector.approval.tool')}{' '}
<span className="font-mono text-amber-950 dark:text-amber-100">
{approval.tool_name}
</span>
</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<Button
variant="primary"
size="xs"
data-testid={`flow-run-pending-approval-approve-${approval.request_id}`}
disabled={deciding}
onClick={() => handleDecide('approve_once')}>
{deciding && localDecision === 'approve_once'
? t('flowRuns.inspector.approval.deciding')
: t('flowRuns.inspector.approval.approve')}
</Button>
<Button
variant="secondary"
size="xs"
data-testid={`flow-run-pending-approval-always-${approval.request_id}`}
disabled={deciding}
title={t('flowRuns.inspector.approval.approveAlwaysHint')}
onClick={() => handleDecide('approve_always_for_flow')}>
{deciding && localDecision === 'approve_always_for_flow'
? t('flowRuns.inspector.approval.deciding')
: t('flowRuns.inspector.approval.approveAlways')}
</Button>
<Button
variant="secondary"
size="xs"
data-testid={`flow-run-pending-approval-deny-${approval.request_id}`}
disabled={deciding}
onClick={() => handleDecide('deny')}>
{deciding && localDecision === 'deny'
? t('flowRuns.inspector.approval.deciding')
: t('flowRuns.inspector.approval.deny')}
</Button>
</div>
</div>
</div>
</div>
);
}
export default FlowRunPendingApprovalCard;
@@ -3,17 +3,20 @@
*
* 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`.
* actionable pending-approval cards when `status === 'pending_approval'`
* (flow-approval surface — run details); 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`.
* Mocks `useFlowRunPoller` and `useFlowPendingApprovals` directly rather than
* the underlying RPC client — their own poll contracts are covered by
* `hooks/__tests__/useFlowRunPoller.test.ts` and
* `hooks/__tests__/useFlowPendingApprovals.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 { PendingApproval } from '../../../services/api/approvalApi';
import type { FlowRun } from '../../../services/api/flowsApi';
import { store } from '../../../store';
import { FlowRunInspectorDrawer } from '../FlowRunInspectorDrawer';
@@ -21,6 +24,23 @@ import { FlowRunInspectorDrawer } from '../FlowRunInspectorDrawer';
const useFlowRunPoller = vi.hoisted(() => vi.fn());
vi.mock('../../../hooks/useFlowRunPoller', () => ({ useFlowRunPoller }));
const useFlowPendingApprovals = vi.hoisted(() => vi.fn());
vi.mock('../../../hooks/useFlowPendingApprovals', () => ({ useFlowPendingApprovals }));
function makeApproval(overrides: Partial<PendingApproval> = {}): PendingApproval {
return {
request_id: 'req-1',
tool_name: 'shell',
action_summary: 'Run `shell` — rm -rf /tmp/scratch',
args_redacted: {},
session_id: 'session-1',
created_at: '2026-01-01T00:00:00Z',
expires_at: null,
source_context: { kind: 'flow', flow_id: 'flow-1', run_id: 'thread-1' },
...overrides,
};
}
function makeRun(overrides: Partial<FlowRun> = {}): FlowRun {
return {
id: 'thread-1',
@@ -48,6 +68,14 @@ function renderDrawer(runId: string | null, onClose: () => void) {
describe('FlowRunInspectorDrawer', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: no pending approvals, no decide-in-flight. Tests that exercise
// the actionable approval cards override this explicitly.
useFlowPendingApprovals.mockReturnValue({
approvals: [],
decidingId: null,
error: null,
decide: vi.fn(),
});
});
it('renders null when runId is null', () => {
@@ -99,20 +127,92 @@ describe('FlowRunInspectorDrawer', () => {
expect(screen.getByTestId('flow-run-inspector-error')).toHaveTextContent('network down');
});
it('shows the pending-approvals banner when status is pending_approval', () => {
// ── Actionable pending-approval gates (flow-approval surface — run
// details). Replaces the old read-only "N node(s) awaiting approval"
// banner with Approve once / Approve always / Deny cards wired to
// `openhuman.approval_decide` via `useFlowPendingApprovals`.
it('polls scoped to this run only while pending_approval/running, passing null otherwise', () => {
useFlowRunPoller.mockReturnValue({
run: makeRun({ status: 'pending_approval', pending_approvals: ['node-a', 'node-b'] }),
run: makeRun({ status: 'pending_approval', flow_id: 'flow-9', thread_id: 'thread-9' }),
loading: false,
error: null,
});
renderDrawer('thread-9', vi.fn());
expect(useFlowPendingApprovals).toHaveBeenCalledWith('flow-9', 'thread-9');
});
it('passes null/null to stop polling once the run is terminal', () => {
useFlowRunPoller.mockReturnValue({
run: makeRun({ status: 'completed' }),
loading: false,
error: null,
});
renderDrawer('thread-1', vi.fn());
expect(screen.getByTestId('flow-run-pending-approvals-banner')).toHaveTextContent('2');
expect(useFlowPendingApprovals).toHaveBeenCalledWith(null, null);
});
it('does not show the pending-approvals banner for a running run', () => {
it('renders an actionable card per pending approval scoped to this run', () => {
useFlowRunPoller.mockReturnValue({
run: makeRun({ status: 'pending_approval' }),
loading: false,
error: null,
});
useFlowPendingApprovals.mockReturnValue({
approvals: [makeApproval({ request_id: 'req-a' }), makeApproval({ request_id: 'req-b' })],
decidingId: null,
error: null,
decide: vi.fn(),
});
renderDrawer('thread-1', vi.fn());
expect(screen.getByTestId('flow-run-pending-approval-req-a')).toBeInTheDocument();
expect(screen.getByTestId('flow-run-pending-approval-req-b')).toBeInTheDocument();
});
it('does not show any approval card for a running run with no pending approvals', () => {
useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null });
renderDrawer('thread-1', vi.fn());
expect(screen.queryByTestId('flow-run-pending-approvals-banner')).not.toBeInTheDocument();
expect(screen.queryByTestId('flow-run-pending-approvals')).not.toBeInTheDocument();
});
it("routes Approve once / Approve always / Deny to the hook's decide() with the request id", () => {
const decide = vi.fn().mockResolvedValue(undefined);
useFlowRunPoller.mockReturnValue({
run: makeRun({ status: 'pending_approval' }),
loading: false,
error: null,
});
useFlowPendingApprovals.mockReturnValue({
approvals: [makeApproval({ request_id: 'req-a' })],
decidingId: null,
error: null,
decide,
});
renderDrawer('thread-1', vi.fn());
fireEvent.click(screen.getByTestId('flow-run-pending-approval-approve-req-a'));
expect(decide).toHaveBeenCalledWith('req-a', 'approve_once');
fireEvent.click(screen.getByTestId('flow-run-pending-approval-always-req-a'));
expect(decide).toHaveBeenCalledWith('req-a', 'approve_always_for_flow');
fireEvent.click(screen.getByTestId('flow-run-pending-approval-deny-req-a'));
expect(decide).toHaveBeenCalledWith('req-a', 'deny');
});
it('shows the polling error message when useFlowPendingApprovals reports one', () => {
useFlowRunPoller.mockReturnValue({
run: makeRun({ status: 'pending_approval' }),
loading: false,
error: null,
});
useFlowPendingApprovals.mockReturnValue({
approvals: [makeApproval()],
decidingId: null,
error: 'network down',
decide: vi.fn(),
});
renderDrawer('thread-1', vi.fn());
expect(screen.getByTestId('flow-run-pending-approvals-error')).toBeInTheDocument();
});
it('shows the run.error banner when present', () => {
@@ -0,0 +1,195 @@
/**
* GateApprovalCard (flow-approval surface — notifications) — rendering +
* decision contract for the `flow-gate-approval` CoreNotification kind.
* Asserts: renders as an alertdialog with tool + summary; Approve once /
* Approve always / Deny each route the matching decision to
* `openhuman.approval_decide` reading `{ request_id }` from the action
* payload; a successful decision marks the notification read and clears its
* actions; a failed decision surfaces a localized error and re-enables the
* buttons without touching the notification; and an invalid payload is
* handled defensively (error shown, no RPC call). Mirrors
* `FlowApprovalCard.test.tsx`'s approach (real Redux `store`, dispatch
* `notificationReceived` directly).
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { store } from '../../store';
import { type NotificationItem } from '../../store/notificationSlice';
import GateApprovalCard, { isGateApprovalNotification } from './GateApprovalCard';
const decideApproval = vi.hoisted(() => vi.fn());
vi.mock('../../services/api/approvalApi', () => ({ decideApproval }));
function makeItem(overrides: Partial<NotificationItem> = {}): NotificationItem {
return {
id: 'flow-gate-approval:req-1',
kind: 'flow-gate-approval',
category: 'agents',
title: 'Workflow needs approval',
body: '',
timestamp: Date.now(),
read: false,
actions: [
{
actionId: 'decide',
label: 'Review',
payload: {
request_id: 'req-1',
flow_id: 'flow-1',
tool_name: 'shell',
summary: 'Run `shell` — rm -rf /tmp/scratch',
},
},
],
...overrides,
};
}
function renderCard(item: NotificationItem) {
return render(
<Provider store={store}>
<GateApprovalCard notification={item} />
</Provider>
);
}
describe('isGateApprovalNotification', () => {
it('matches on the kind field', () => {
expect(isGateApprovalNotification({ id: 'anything', kind: 'flow-gate-approval' })).toBe(true);
});
it('matches on the legacy id-prefix fallback', () => {
expect(isGateApprovalNotification({ id: 'flow-gate-approval:req-1', kind: undefined })).toBe(
true
);
});
it('does not match unrelated notifications', () => {
expect(
isGateApprovalNotification({ id: 'flow-pending-approval:flow-1:t1', kind: undefined })
).toBe(false);
});
});
describe('GateApprovalCard', () => {
beforeEach(() => {
vi.clearAllMocks();
store.dispatch({ type: 'notifications/clearAll' });
});
it('renders as an alertdialog with the summary and tool name', () => {
renderCard(makeItem());
const card = screen.getByTestId('gate-approval-card');
expect(card).toHaveAttribute('role', 'alertdialog');
expect(screen.getByText('Run `shell` — rm -rf /tmp/scratch')).toBeInTheDocument();
expect(screen.getByText('shell')).toBeInTheDocument();
});
it('renders all three decision buttons', () => {
renderCard(makeItem());
expect(screen.getByTestId('gate-approval-approve')).toBeInTheDocument();
expect(screen.getByTestId('gate-approval-always')).toBeInTheDocument();
expect(screen.getByTestId('gate-approval-deny')).toBeInTheDocument();
});
it('Approve once calls approval_decide with the request id and marks the notification read', async () => {
decideApproval.mockResolvedValue(undefined);
store.dispatch({ type: 'notifications/notificationReceived', payload: makeItem() });
renderCard(makeItem());
fireEvent.click(screen.getByTestId('gate-approval-approve'));
await waitFor(() => expect(decideApproval).toHaveBeenCalledWith('req-1', 'approve_once'));
await waitFor(() => {
const item = store
.getState()
.notifications.items.find(i => i.id === 'flow-gate-approval:req-1');
expect(item?.read).toBe(true);
expect(item?.actions ?? []).toHaveLength(0);
});
});
it('Approve always calls approval_decide with approve_always_for_flow', async () => {
decideApproval.mockResolvedValue(undefined);
renderCard(makeItem());
fireEvent.click(screen.getByTestId('gate-approval-always'));
await waitFor(() =>
expect(decideApproval).toHaveBeenCalledWith('req-1', 'approve_always_for_flow')
);
});
it('Deny calls approval_decide with deny', async () => {
decideApproval.mockResolvedValue(undefined);
renderCard(makeItem());
fireEvent.click(screen.getByTestId('gate-approval-deny'));
await waitFor(() => expect(decideApproval).toHaveBeenCalledWith('req-1', 'deny'));
});
it('shows a localized error and re-enables the buttons when the decide RPC fails', async () => {
decideApproval.mockRejectedValue(new Error('gate not installed'));
store.dispatch({ type: 'notifications/notificationReceived', payload: makeItem() });
renderCard(makeItem());
fireEvent.click(screen.getByTestId('gate-approval-approve'));
await waitFor(() => {
expect(screen.getByTestId('gate-approval-approve')).not.toBeDisabled();
});
expect(
screen.getByText(
(_content, element) =>
element?.tagName.toLowerCase() === 'p' &&
(element?.textContent ?? '').includes('Could not record your decision')
)
).toBeInTheDocument();
const item = store
.getState()
.notifications.items.find(i => i.id === 'flow-gate-approval:req-1');
expect(item?.actions).toHaveLength(1);
});
it('treats a missing payload as invalid (shows error, no RPC call)', async () => {
renderCard(
makeItem({ actions: [{ actionId: 'decide', label: 'Review', payload: undefined }] })
);
fireEvent.click(screen.getByTestId('gate-approval-approve'));
await waitFor(() => {
expect(
screen.getByText(
(_content, element) =>
element?.tagName.toLowerCase() === 'p' &&
(element?.textContent ?? '').includes('Could not record your decision')
)
).toBeInTheDocument();
});
expect(decideApproval).not.toHaveBeenCalled();
});
it('disables all buttons while a decision is in flight', async () => {
let resolve!: (v: unknown) => void;
decideApproval.mockImplementation(
() =>
new Promise(r => {
resolve = r;
})
);
renderCard(makeItem());
fireEvent.click(screen.getByTestId('gate-approval-approve'));
expect(screen.getByTestId('gate-approval-approve')).toBeDisabled();
expect(screen.getByTestId('gate-approval-always')).toBeDisabled();
expect(screen.getByTestId('gate-approval-deny')).toBeDisabled();
resolve(undefined);
await waitFor(() => expect(screen.getByTestId('gate-approval-approve')).not.toBeDisabled());
});
});
@@ -0,0 +1,178 @@
/**
* GateApprovalCard (flow-approval surface — notifications)
* ------------------------------------------------------------
*
* Approval surface for the `flow-gate-approval` CoreNotification kind — a
* paused `tinyflows` run's approval gate, surfaced in the Notification
* Center. `NotificationCenter` routes any notification matching
* {@link isGateApprovalNotification} here instead of the generic
* `CoreNotificationCard`.
*
* Distinct from the older `FlowApprovalCard` (`flow-pending-approval:`-id
* notifications, issue B3a): that card resumes the run via
* `openhuman.flows_resume` naming specific node ids. This one decides a
* single gate via the shared `openhuman.approval_decide` RPC — the same
* decision vocabulary as the chat `ApprovalRequestCard` and the flow-run
* inspector's actionable cards (Approve once / Approve always / Deny).
*
* Payload shape (`notification.actions[0].payload`):
* `{ request_id, flow_id, tool_name, summary }`.
*/
import debug from 'debug';
import { useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { type ApprovalDecision, decideApproval } from '../../services/api/approvalApi';
import { useAppDispatch } from '../../store/hooks';
import {
clearNotificationActions,
markRead,
type NotificationItem,
} from '../../store/notificationSlice';
import Button from '../ui/Button';
const log = debug('notifications:gate-approval-card');
/** Shape of `notification.actions[0].payload` for the `flow-gate-approval` kind. */
interface GateApprovalPayload {
request_id: string;
flow_id: string;
tool_name: string;
summary: string;
}
function isGateApprovalPayload(value: unknown): value is GateApprovalPayload {
if (!value || typeof value !== 'object') return false;
const record = value as Record<string, unknown>;
return (
typeof record.request_id === 'string' &&
typeof record.flow_id === 'string' &&
typeof record.tool_name === 'string' &&
typeof record.summary === 'string'
);
}
/**
* Matches a `NotificationItem` that should render as {@link GateApprovalCard}
* — either the explicit `kind` field or (as a defensive fallback, mirroring
* the older `flow-pending-approval:` id-prefix convention) an id starting
* with `flow-gate-approval:`.
*/
export function isGateApprovalNotification(item: Pick<NotificationItem, 'id' | 'kind'>): boolean {
return item.kind === 'flow-gate-approval' || item.id.startsWith('flow-gate-approval:');
}
interface Props {
notification: NotificationItem;
}
const GateApprovalCard = ({ notification: n }: Props) => {
const { t } = useT();
const dispatch = useAppDispatch();
const [deciding, setDeciding] = useState<ApprovalDecision | null>(null);
const [error, setError] = useState<string | null>(null);
const payload = n.actions?.[0]?.payload;
const parsed = isGateApprovalPayload(payload) ? payload : null;
const handleDecide = async (decision: ApprovalDecision) => {
if (deciding) return;
if (!parsed) {
// Defensive — the core always stamps this shape, but never crash the
// notification center on an unexpected payload.
log('decide: missing/invalid payload notification=%s', n.id);
setError(t('notifications.flowGate.error'));
return;
}
setDeciding(decision);
setError(null);
log('decide: request=%s decision=%s', parsed.request_id, decision);
try {
await decideApproval(parsed.request_id, decision);
log('decide: ok request=%s', parsed.request_id);
dispatch(markRead({ id: n.id }));
dispatch(clearNotificationActions({ id: n.id }));
} catch (err) {
log('decide: failed request=%s err=%o', parsed.request_id, err);
setError(t('notifications.flowGate.error'));
} finally {
setDeciding(null);
}
};
return (
<div
role="alertdialog"
aria-label={t('notifications.flowGate.title')}
data-testid="gate-approval-card"
className="rounded-xl border border-amber-300 bg-amber-50 p-3 text-sm shadow-sm dark:border-amber-700 dark:bg-amber-950">
<div className="flex items-start gap-2">
<span aria-hidden className="text-base leading-none">
🔒
</span>
<div className="min-w-0 flex-1">
<p className="font-semibold text-amber-900 dark:text-amber-100">
{t('notifications.flowGate.title')}
</p>
{(parsed?.summary || n.body) && (
<p className="mt-1 break-words text-amber-800/90 dark:text-amber-200/90">
{parsed?.summary || n.body}
</p>
)}
{parsed && (
<p className="mt-1 text-xs text-amber-800/80 dark:text-amber-200/80">
{t('notifications.flowGate.tool')}{' '}
<span className="font-mono text-amber-950 dark:text-amber-100">
{parsed.tool_name}
</span>
</p>
)}
{error && <p className="mt-2 text-xs text-coral"> {error}</p>}
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
variant="primary"
size="sm"
data-testid="gate-approval-approve"
disabled={deciding !== null}
onClick={() => {
void handleDecide('approve_once');
}}>
{deciding === 'approve_once'
? t('notifications.flowGate.deciding')
: t('notifications.flowGate.approve')}
</Button>
<Button
variant="secondary"
size="sm"
data-testid="gate-approval-always"
disabled={deciding !== null}
title={t('notifications.flowGate.approveAlwaysHint')}
onClick={() => {
void handleDecide('approve_always_for_flow');
}}>
{deciding === 'approve_always_for_flow'
? t('notifications.flowGate.deciding')
: t('notifications.flowGate.approveAlways')}
</Button>
<Button
variant="secondary"
size="sm"
data-testid="gate-approval-deny"
disabled={deciding !== null}
onClick={() => {
void handleDecide('deny');
}}>
{deciding === 'deny'
? t('notifications.flowGate.deciding')
: t('notifications.flowGate.deny')}
</Button>
</div>
</div>
</div>
</div>
);
};
export default GateApprovalCard;
@@ -21,6 +21,7 @@ import {
import Button from '../ui/Button';
import CoreNotificationCard from './CoreNotificationCard';
import FlowApprovalCard from './FlowApprovalCard';
import GateApprovalCard, { isGateApprovalNotification } from './GateApprovalCard';
import NotificationCard from './NotificationCard';
// ─────────────────────────────────────────────────────────────────────────────
@@ -196,13 +197,15 @@ const NotificationCenter = () => {
always shown first, independent of integration load state. */}
{coreActionItems.length > 0 && (
<div className="divide-y-0">
{coreActionItems.map(item =>
isFlowApproval(item) ? (
<FlowApprovalCard key={item.id} notification={item} />
) : (
<CoreNotificationCard key={item.id} notification={item} />
)
)}
{coreActionItems.map(item => {
if (isFlowApproval(item)) {
return <FlowApprovalCard key={item.id} notification={item} />;
}
if (isGateApprovalNotification(item)) {
return <GateApprovalCard key={item.id} notification={item} />;
}
return <CoreNotificationCard key={item.id} notification={item} />;
})}
</div>
)}
@@ -28,11 +28,17 @@ const formatDateTime = (value: string): string => {
const DECISION_BADGE_VARIANT: Record<
ApprovalDecision,
'success' | 'danger' | 'warning' | 'neutral' | 'primary'
> = { approve_once: 'success', approve_always_for_tool: 'success', deny: 'danger' };
> = {
approve_once: 'success',
approve_always_for_tool: 'success',
approve_always_for_flow: 'success',
deny: 'danger',
};
const DECISION_LABEL_KEY: Record<ApprovalDecision, string> = {
approve_once: 'settings.approvalHistory.decision.approveOnce',
approve_always_for_tool: 'settings.approvalHistory.decision.approveAlways',
approve_always_for_flow: 'settings.approvalHistory.decision.approveAlwaysFlow',
deny: 'settings.approvalHistory.decision.deny',
};
@@ -11,6 +11,7 @@ import ChatComposer from '../../components/chat/ChatComposer';
import ChatFilesChip from '../../components/chat/ChatFilesChip';
import ChatNewWindowHero from '../../components/chat/ChatNewWindowHero';
import ComposerTokenStats from '../../components/chat/ComposerTokenStats';
import { FlowApprovalRequestCard } from '../../components/chat/FlowApprovalRequestCard';
import IntegrationConnectCard from '../../components/chat/IntegrationConnectCard';
import QueuedFollowups from '../../components/chat/QueuedFollowups';
import SuperContextToggle from '../../components/chat/SuperContextToggle';
@@ -62,6 +63,7 @@ import {
isThreadVisibleInTab,
} from '../../features/conversations/utils/threadFilter';
import MicComposer from '../../features/human/MicComposer';
import { useFlowApprovalRequests } from '../../hooks/useFlowApprovalRequests';
import { useStickToBottom } from '../../hooks/useStickToBottom';
import { useUsageState } from '../../hooks/useUsageState';
import {
@@ -403,6 +405,12 @@ const Conversations = ({
const pendingApprovalByThread = useAppSelector(
state => state.chatRuntime.pendingApprovalByThread
);
// Flow-approval surface (chat): a paused tinyflows run's gate, pushed via
// the `flow_approval_request` socket event. Not thread-scoped — the
// payload carries no `thread_id` — so it's tracked independently of the
// selected thread and surfaced regardless of which one is open.
const { requests: flowApprovalRequests, dismiss: dismissFlowApprovalRequest } =
useFlowApprovalRequests();
const pendingPlanReviewByThread = useAppSelector(
state => state.chatRuntime.pendingPlanReviewByThread
);
@@ -2789,6 +2797,23 @@ const Conversations = ({
);
})()}
{/* Flow-approval surface (chat): actionable banner(s) for paused
tinyflows runs, pushed via the `flow_approval_request` socket
event (issue: flow-approval surfacing). Not gated on the selected
thread — see the hook call above for why — so every pending
request renders regardless of which thread is open. */}
{flowApprovalRequests.length > 0 && (
<div className="mb-2 flex flex-col gap-2">
{flowApprovalRequests.map(request => (
<FlowApprovalRequestCard
key={request.request_id}
request={request}
onResolved={dismissFlowApprovalRequest}
/>
))}
</div>
)}
{(() => {
// Surface in-flight + failed artifact cards above the composer
// (#2779). Mirrors the approval-card placement so the user sees
@@ -0,0 +1,173 @@
/**
* useFlowPendingApprovals (flow-approval surface — run details) — poll +
* decide contract.
*
* Asserts: no-op while `flowId`/`runId` is null; polls
* `approval_list_pending` every 2s while both are set; filters to gates
* matching `source_context.{kind:"flow",flow_id,run_id}`; stops polling on a
* fetch error (surfacing it) without hammering the endpoint; resets state
* when `flowId`/`runId` change; `decide()` calls `decideApproval` and
* optimistically drops the request on success, surfaces `error` and keeps it
* in the list on failure; cleans up timers on unmount.
*/
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PendingApproval } from '../../services/api/approvalApi';
import { useFlowPendingApprovals } from '../useFlowPendingApprovals';
const fetchPendingApprovals = vi.hoisted(() => vi.fn());
const decideApproval = vi.hoisted(() => vi.fn());
vi.mock('../../services/api/approvalApi', () => ({ fetchPendingApprovals, decideApproval }));
function makeApproval(overrides: Partial<PendingApproval> = {}): PendingApproval {
return {
request_id: 'req-1',
tool_name: 'shell',
action_summary: 'Run `shell`',
args_redacted: {},
session_id: 'session-1',
created_at: '2026-01-01T00:00:00Z',
expires_at: null,
source_context: { kind: 'flow', flow_id: 'flow-1', run_id: 'run-1' },
...overrides,
};
}
describe('useFlowPendingApprovals', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('does nothing when flowId or runId is null', async () => {
const { result, rerender } = renderHook(
({ flowId, runId }: { flowId: string | null; runId: string | null }) =>
useFlowPendingApprovals(flowId, runId),
{ initialProps: { flowId: null as string | null, runId: null as string | null } }
);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.approvals).toEqual([]);
expect(fetchPendingApprovals).not.toHaveBeenCalled();
rerender({ flowId: 'flow-1', runId: null });
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(fetchPendingApprovals).not.toHaveBeenCalled();
});
it('polls every 2s and filters to this flow/run via source_context', async () => {
fetchPendingApprovals.mockResolvedValue([
makeApproval({ request_id: 'req-a' }),
makeApproval({
request_id: 'req-b',
source_context: { kind: 'flow', flow_id: 'flow-2', run_id: 'run-1' },
}),
makeApproval({ request_id: 'req-c', source_context: undefined }),
]);
const { result } = renderHook(() => useFlowPendingApprovals('flow-1', 'run-1'));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.approvals.map(a => a.request_id)).toEqual(['req-a']);
expect(fetchPendingApprovals).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(fetchPendingApprovals).toHaveBeenCalledTimes(2);
});
it('surfaces an error and stops polling on a failed fetch', async () => {
fetchPendingApprovals.mockRejectedValue(new Error('network down'));
const { result } = renderHook(() => useFlowPendingApprovals('flow-1', 'run-1'));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.error).toBe('network down');
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000);
});
expect(fetchPendingApprovals).toHaveBeenCalledTimes(1);
});
it('resets approvals when flowId/runId change', async () => {
fetchPendingApprovals.mockResolvedValue([makeApproval({ request_id: 'req-a' })]);
const { result, rerender } = renderHook(
({ flowId, runId }: { flowId: string | null; runId: string | null }) =>
useFlowPendingApprovals(flowId, runId),
{ initialProps: { flowId: 'flow-1' as string | null, runId: 'run-1' as string | null } }
);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.approvals).toHaveLength(1);
rerender({ flowId: null, runId: null });
expect(result.current.approvals).toEqual([]);
});
it('decide() calls decideApproval and optimistically drops the request on success', async () => {
fetchPendingApprovals.mockResolvedValue([makeApproval({ request_id: 'req-a' })]);
decideApproval.mockResolvedValue(undefined);
const { result } = renderHook(() => useFlowPendingApprovals('flow-1', 'run-1'));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.approvals).toHaveLength(1);
await act(async () => {
await result.current.decide('req-a', 'approve_once');
});
expect(decideApproval).toHaveBeenCalledWith('req-a', 'approve_once');
expect(result.current.approvals).toEqual([]);
expect(result.current.decidingId).toBeNull();
});
it('decide() surfaces an error and keeps the request when the RPC fails', async () => {
fetchPendingApprovals.mockResolvedValue([makeApproval({ request_id: 'req-a' })]);
decideApproval.mockRejectedValue(new Error('gate not installed'));
const { result } = renderHook(() => useFlowPendingApprovals('flow-1', 'run-1'));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
await expect(result.current.decide('req-a', 'deny')).rejects.toThrow('gate not installed');
});
expect(result.current.error).toBe('gate not installed');
expect(result.current.approvals).toHaveLength(1);
expect(result.current.decidingId).toBeNull();
});
it('cleans up pending timers on unmount', async () => {
fetchPendingApprovals.mockResolvedValue([]);
const { unmount } = renderHook(() => useFlowPendingApprovals('flow-1', 'run-1'));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(fetchPendingApprovals).toHaveBeenCalledTimes(1);
unmount();
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000);
});
expect(fetchPendingApprovals).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,106 @@
/**
* useFlowApprovalRequests (flow-approval surface — chat) — unit tests.
*
* Verifies the hook builds a de-duplicated list from the socket
* `flow_approval_request` feed (both colon and underscore aliases), drops
* malformed payloads, `dismiss()` removes a request by id, and it
* unsubscribes on unmount. The socket is a tiny in-memory emitter so events
* can be simulated deterministically — same harness as
* `useFlowRunProgress.test.ts`.
*/
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useFlowApprovalRequests } from './useFlowApprovalRequests';
const handlers = vi.hoisted(() => new Map<string, Set<(data: unknown) => void>>());
const on = vi.hoisted(() =>
vi.fn((event: string, cb: (data: unknown) => void) => {
const set = handlers.get(event) ?? new Set();
set.add(cb);
handlers.set(event, set);
})
);
const off = vi.hoisted(() =>
vi.fn((event: string, cb: (data: unknown) => void) => {
handlers.get(event)?.delete(cb);
})
);
vi.mock('../services/socketService', () => ({ socketService: { on, off } }));
function emit(payload: unknown) {
act(() => {
for (const event of ['flow:approval_request', 'flow_approval_request']) {
for (const cb of handlers.get(event) ?? []) cb(payload);
}
});
}
const REQUEST = {
request_id: 'req-1',
flow_id: 'flow-1',
run_id: 'run-1',
tool_name: 'shell',
summary: 'Run `shell` — rm -rf /tmp/scratch',
};
describe('useFlowApprovalRequests', () => {
beforeEach(() => {
handlers.clear();
on.mockClear();
off.mockClear();
});
it('subscribes to both event aliases on mount', () => {
renderHook(() => useFlowApprovalRequests());
expect(on).toHaveBeenCalledWith('flow:approval_request', expect.any(Function));
expect(on).toHaveBeenCalledWith('flow_approval_request', expect.any(Function));
});
it('adds a request from a valid payload', () => {
const { result } = renderHook(() => useFlowApprovalRequests());
emit(REQUEST);
expect(result.current.requests).toEqual([REQUEST]);
});
it('de-duplicates by request_id', () => {
const { result } = renderHook(() => useFlowApprovalRequests());
emit(REQUEST);
emit(REQUEST);
expect(result.current.requests).toHaveLength(1);
});
it('ignores malformed payloads', () => {
const { result } = renderHook(() => useFlowApprovalRequests());
emit(null);
emit({ ...REQUEST, request_id: undefined });
emit({ ...REQUEST, flow_id: 42 });
expect(result.current.requests).toEqual([]);
});
it('accumulates distinct requests', () => {
const { result } = renderHook(() => useFlowApprovalRequests());
emit(REQUEST);
emit({ ...REQUEST, request_id: 'req-2' });
expect(result.current.requests.map(r => r.request_id)).toEqual(['req-1', 'req-2']);
});
it('dismiss() removes a request by id', () => {
const { result } = renderHook(() => useFlowApprovalRequests());
emit(REQUEST);
emit({ ...REQUEST, request_id: 'req-2' });
expect(result.current.requests).toHaveLength(2);
act(() => {
result.current.dismiss('req-1');
});
expect(result.current.requests.map(r => r.request_id)).toEqual(['req-2']);
});
it('unsubscribes both event aliases on unmount', () => {
const { unmount } = renderHook(() => useFlowApprovalRequests());
unmount();
expect(off).toHaveBeenCalledWith('flow:approval_request', expect.any(Function));
expect(off).toHaveBeenCalledWith('flow_approval_request', expect.any(Function));
});
});
+110
View File
@@ -0,0 +1,110 @@
/**
* useFlowApprovalRequests (flow-approval surface — chat)
* --------------------------------------------------------
*
* Subscribes to the core's `flow_approval_request` socket event — raised
* when a `tinyflows` run pauses on an approval-gated tool call — and
* surfaces it as a live list so the chat surface can show an actionable
* banner without the user having to open the run inspector. Mirrors
* `useFlowRunProgress`'s subscription shape (`socketService.on`/`off` with
* cleanup on unmount) and listens for both the colon and underscore aliases
* the core socket bridge emits for every bridged event.
*
* Payload: `{ request_id, flow_id, run_id, tool_name, summary }`. Unlike the
* thread-scoped `approval_request` chat event (`ApprovalRequestCard`), this
* event carries no `thread_id` — a flow run isn't tied to the chat thread
* that's open when the gate fires — so requests are tracked independently
* of the active thread and surfaced regardless of which thread is selected.
*
* Decisions are NOT made here — `decide`/`dismiss` just drop the request off
* the local list once `FlowApprovalRequestCard` has resolved it via
* `approvalApi.decideApproval`, so a slow-to-arrive duplicate broadcast (or
* the same request already decided from another surface, e.g. the run
* inspector) doesn't linger.
*/
import debug from 'debug';
import { useCallback, useEffect, useState } from 'react';
import { socketService } from '../services/socketService';
const log = debug('flows:approval-requests');
/** Socket event aliases the core bridge emits (colon + underscore forms). */
const EVENT_COLON = 'flow:approval_request';
const EVENT_UNDERSCORE = 'flow_approval_request';
export interface FlowApprovalRequest {
request_id: string;
flow_id: string;
run_id: string;
tool_name: string;
summary: string;
}
function parsePayload(data: unknown): FlowApprovalRequest | null {
if (!data || typeof data !== 'object') return null;
const obj = data as Record<string, unknown>;
if (typeof obj.request_id !== 'string') return null;
if (typeof obj.flow_id !== 'string') return null;
if (typeof obj.run_id !== 'string') return null;
if (typeof obj.tool_name !== 'string') return null;
if (typeof obj.summary !== 'string') return null;
return {
request_id: obj.request_id,
flow_id: obj.flow_id,
run_id: obj.run_id,
tool_name: obj.tool_name,
summary: obj.summary,
};
}
export interface UseFlowApprovalRequestsResult {
/** Live flow-approval requests awaiting a decision, oldest first. */
requests: FlowApprovalRequest[];
/** Drop a request off the list once it's been decided (or otherwise resolved). */
dismiss: (requestId: string) => void;
}
/**
* Watches for `flow_approval_request` broadcasts and keeps a de-duplicated
* (by `request_id`) list of pending ones for the caller to render.
*/
export function useFlowApprovalRequests(): UseFlowApprovalRequestsResult {
const [requests, setRequests] = useState<FlowApprovalRequest[]>([]);
const handleRequest = useCallback((data: unknown) => {
const parsed = parsePayload(data);
if (!parsed) {
log('dropped — invalid payload %o', data);
return;
}
log(
'request: id=%s flow=%s run=%s tool=%s',
parsed.request_id,
parsed.flow_id,
parsed.run_id,
parsed.tool_name
);
setRequests(prev =>
prev.some(r => r.request_id === parsed.request_id) ? prev : [...prev, parsed]
);
}, []);
useEffect(() => {
socketService.on(EVENT_COLON, handleRequest);
socketService.on(EVENT_UNDERSCORE, handleRequest);
return () => {
socketService.off(EVENT_COLON, handleRequest);
socketService.off(EVENT_UNDERSCORE, handleRequest);
};
}, [handleRequest]);
const dismiss = useCallback((requestId: string) => {
log('dismiss: id=%s', requestId);
setRequests(prev => prev.filter(r => r.request_id !== requestId));
}, []);
return { requests, dismiss };
}
export default useFlowApprovalRequests;
+137
View File
@@ -0,0 +1,137 @@
/**
* useFlowPendingApprovals (flow-approval surface — run details)
* ---------------------------------------------------------------
*
* Feeds the actionable approval cards in `FlowRunInspectorDrawer`. The core
* has no dedicated "pending approvals for this run" endpoint — approvals are
* a single shared queue (`openhuman.approval_list_pending`) covering chat,
* flow, and any future origin — so this hook polls that shared queue every
* ~2s and filters client-side to the gates raised for one specific flow run
* via `PendingApproval.source_context` (`{ kind: "flow", flow_id, run_id }`).
*
* Mirrors the poll-until-told-to-stop shape of `useFlowRunPoller`: the caller
* controls the polling window by passing `null` for either `flowId` or
* `runId` once the run leaves an active state (`running` /
* `pending_approval`) — this hook does not know about run status itself.
*
* `decide()` wraps `approvalApi.decideApproval` and optimistically drops the
* request from the local list on success; the next poll tick reconciles
* against the server (e.g. a sequential-gate flow re-parking on a fresh
* request id). On success the run itself proceeds server-side —
* `useFlowRunPoller`'s independent 2s loop is what picks up the new steps.
*/
import debug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
type ApprovalDecision,
decideApproval,
fetchPendingApprovals,
type PendingApproval,
} from '../services/api/approvalApi';
const log = debug('flows:pending-approvals');
/** How often to re-poll the shared approval queue while a run is active. */
const POLL_INTERVAL_MS = 2000;
function matchesRun(approval: PendingApproval, flowId: string, runId: string): boolean {
const ctx = approval.source_context;
return !!ctx && ctx.kind === 'flow' && ctx.flow_id === flowId && ctx.run_id === runId;
}
export interface UseFlowPendingApprovalsResult {
/** Pending approvals scoped to this flow/run, oldest first (server order). */
approvals: PendingApproval[];
/** `request_id` of the approval currently being decided, or `null`. */
decidingId: string | null;
/** Set when the last poll or decide call failed; cleared on the next success. */
error: string | null;
/** Record a decision for one of `approvals`. Throws on failure (caller may ignore). */
decide: (requestId: string, decision: ApprovalDecision) => Promise<void>;
}
/**
* Poll `openhuman.approval_list_pending` every {@link POLL_INTERVAL_MS}ms
* while both `flowId` and `runId` are non-null, filtered to that run via
* `source_context`. Stops polling (and clears state) when either argument
* becomes `null` or changes. A failed poll surfaces `error` and does not
* reschedule — same "don't hammer a broken endpoint" contract as
* `useFlowRunPoller`; the caller re-enabling polling (e.g. the drawer
* reopening) is what retries.
*/
export function useFlowPendingApprovals(
flowId: string | null,
runId: string | null
): UseFlowPendingApprovalsResult {
const [approvals, setApprovals] = useState<PendingApproval[]>([]);
const [decidingId, setDecidingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
setApprovals([]);
setError(null);
if (!flowId || !runId) return;
let cancelled = false;
let pollHandle: number | undefined;
const tick = async () => {
if (cancelled) return;
try {
const all = await fetchPendingApprovals();
if (cancelled || !mountedRef.current) return;
const scoped = all.filter(a => matchesRun(a, flowId, runId));
log('tick: flow=%s run=%s total=%d scoped=%d', flowId, runId, all.length, scoped.length);
setApprovals(scoped);
setError(null);
pollHandle = window.setTimeout(() => void tick(), POLL_INTERVAL_MS);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log('tick: error flow=%s run=%s err=%s', flowId, runId, msg);
if (cancelled || !mountedRef.current) return;
setError(msg);
// Do not schedule another poll — leave retrying to the caller.
}
};
void tick();
return () => {
cancelled = true;
if (pollHandle !== undefined) window.clearTimeout(pollHandle);
};
}, [flowId, runId]);
const decide = useCallback(async (requestId: string, decision: ApprovalDecision) => {
setDecidingId(requestId);
setError(null);
try {
await decideApproval(requestId, decision);
log('decide: ok request=%s decision=%s', requestId, decision);
if (mountedRef.current) {
setApprovals(prev => prev.filter(a => a.request_id !== requestId));
}
} catch (err) {
log('decide: failed request=%s err=%o', requestId, err);
if (mountedRef.current) {
setError(err instanceof Error ? err.message : String(err));
}
throw err;
} finally {
if (mountedRef.current) setDecidingId(null);
}
}, []);
return { approvals, decidingId, error, decide };
}
export default useFlowPendingApprovals;
+28
View File
@@ -3077,6 +3077,16 @@ const messages: TranslationMap = {
'chat.approval.fallback': 'العميل يريد القيام بعمل يحتاج إلى موافقتك',
'chat.approval.title': 'الموافقة المطلوبة',
'chat.approval.tool': 'Tool:',
'chat.flowApproval.title': 'يحتاج سير العمل إلى موافقة',
'chat.flowApproval.fallback': 'يريد تشغيل سير العمل تنفيذ إجراء يحتاج إلى موافقتك.',
'chat.flowApproval.tool': 'الأداة:',
'chat.flowApproval.flow': 'التدفق:',
'chat.flowApproval.approve': 'الموافقة مرة واحدة',
'chat.flowApproval.approveAlways': 'الموافقة دائماً',
'chat.flowApproval.approveAlwaysHint': 'تخطي نقطة التحقق هذه في التشغيلات المستقبلية لهذا التدفق',
'chat.flowApproval.deny': 'رفض',
'chat.flowApproval.deciding': 'جارٍ العمل…',
'chat.flowApproval.error': 'تعذّر تسجيل قرارك. حاول مرة أخرى.',
'chat.flowProposal.title': 'اقتراح مسار العمل',
'chat.flowProposal.subtitle': 'راجع هذه الأتمتة قبل حفظها.',
'chat.flowProposal.triggerLabel': 'المُشغّل',
@@ -3618,6 +3628,15 @@ const messages: TranslationMap = {
'notifications.flow.approveHint': 'استئناف سير العمل بعد نقطة التحقق هذه',
'notifications.flow.dismissHint': 'إخفاء هذا التنبيه دون استئناف سير العمل',
'notifications.flow.viewRun': 'عرض التشغيل',
'notifications.flowGate.title': 'يحتاج سير العمل إلى موافقة',
'notifications.flowGate.tool': 'الأداة:',
'notifications.flowGate.approve': 'الموافقة مرة واحدة',
'notifications.flowGate.approveAlways': 'الموافقة دائماً',
'notifications.flowGate.approveAlwaysHint':
'تخطي نقطة التحقق هذه في التشغيلات المستقبلية لهذا التدفق',
'notifications.flowGate.deny': 'رفض',
'notifications.flowGate.deciding': 'جارٍ العمل…',
'notifications.flowGate.error': 'تعذّر تسجيل قرارك. حاول مرة أخرى.',
'flowRuns.inspector.title': 'تفاصيل التشغيل',
'flowRuns.inspector.startedAt': 'بدأ',
'flowRuns.inspector.finishedAt': 'انتهى',
@@ -3625,6 +3644,14 @@ const messages: TranslationMap = {
'flowRuns.inspector.error': 'خطأ',
'flowRuns.inspector.pendingApprovals': 'الموافقات المعلقة',
'flowRuns.inspector.pendingApprovalsCount': '{count} عقدة (عقد) في انتظار الموافقة',
'flowRuns.inspector.approval.tool': 'الأداة:',
'flowRuns.inspector.approval.approve': 'الموافقة مرة واحدة',
'flowRuns.inspector.approval.approveAlways': 'الموافقة دائماً',
'flowRuns.inspector.approval.approveAlwaysHint':
'تخطي نقطة التحقق هذه في التشغيلات المستقبلية لهذا التدفق',
'flowRuns.inspector.approval.deny': 'رفض',
'flowRuns.inspector.approval.deciding': 'جارٍ العمل…',
'flowRuns.inspector.approval.loadError': 'تعذّر تحميل الموافقات المعلقة لهذا التشغيل.',
'flowRuns.inspector.steps': 'الخطوات',
'flowRuns.inspector.noSteps': 'لم يتم تسجيل أي خطوات بعد.',
'flowRuns.inspector.output': 'المخرجات',
@@ -5154,6 +5181,7 @@ const messages: TranslationMap = {
'settings.approvalHistory.decidedAt': 'تم القرار في {date}',
'settings.approvalHistory.decision.approveOnce': 'تمت الموافقة مرة واحدة',
'settings.approvalHistory.decision.approveAlways': 'مسموح دائمًا',
'settings.approvalHistory.decision.approveAlwaysFlow': 'مسموح دائمًا (التدفق)',
'settings.approvalHistory.decision.deny': 'مرفوض',
'settings.theme.variantLight': 'فاتح',
'settings.theme.variantDark': 'داكن',
+29
View File
@@ -3143,6 +3143,17 @@ const messages: TranslationMap = {
'chat.approval.fallback': 'এজেন্ট এমন কাজ করতে চায় যা আপনার অনুমোদন প্রয়োজন.',
'chat.approval.title': 'অনুমোদন প্রয়োজন',
'chat.approval.tool': 'টুল:',
'chat.flowApproval.title': 'ওয়ার্কফ্লোর জন্য অনুমোদন প্রয়োজন',
'chat.flowApproval.fallback':
'একটি ওয়ার্কফ্লো রান আপনার অনুমোদনের প্রয়োজন এমন একটি কাজ সম্পাদন করতে চায়।',
'chat.flowApproval.tool': 'টুল:',
'chat.flowApproval.flow': 'ফ্লো:',
'chat.flowApproval.approve': 'একবার অনুমোদন করুন',
'chat.flowApproval.approveAlways': 'সর্বদা অনুমোদন করুন',
'chat.flowApproval.approveAlwaysHint': 'এই ফ্লো-এর ভবিষ্যত রানগুলিতে এই চেকপয়েন্ট এড়িয়ে যান',
'chat.flowApproval.deny': 'প্রত্যাখ্যান করুন',
'chat.flowApproval.deciding': 'চলমান...',
'chat.flowApproval.error': 'আপনার সিদ্ধান্তের কথা রেকর্ড করা যায় নি — আবার চেষ্টা করুন ।',
'chat.flowProposal.title': 'ওয়ার্কফ্লো প্রস্তাব',
'chat.flowProposal.subtitle': 'সংরক্ষণ করার আগে এই অটোমেশনটি পর্যালোচনা করুন।',
'chat.flowProposal.triggerLabel': 'ট্রিগার',
@@ -3701,6 +3712,15 @@ const messages: TranslationMap = {
'notifications.flow.approveHint': 'এই চেকপয়েন্টের পরে ওয়ার্কফ্লো আবার শুরু করুন',
'notifications.flow.dismissHint': 'ওয়ার্কফ্লো আবার শুরু না করে এই প্রম্পটটি লুকান',
'notifications.flow.viewRun': 'রান দেখুন',
'notifications.flowGate.title': 'ওয়ার্কফ্লোর জন্য অনুমোদন প্রয়োজন',
'notifications.flowGate.tool': 'টুল:',
'notifications.flowGate.approve': 'একবার অনুমোদন করুন',
'notifications.flowGate.approveAlways': 'সর্বদা অনুমোদন করুন',
'notifications.flowGate.approveAlwaysHint':
'এই ফ্লো-এর ভবিষ্যত রানগুলিতে এই চেকপয়েন্ট এড়িয়ে যান',
'notifications.flowGate.deny': 'প্রত্যাখ্যান করুন',
'notifications.flowGate.deciding': 'চলমান...',
'notifications.flowGate.error': 'আপনার সিদ্ধান্তের কথা রেকর্ড করা যায় নি — আবার চেষ্টা করুন ।',
'flowRuns.inspector.title': 'রান বিবরণ',
'flowRuns.inspector.startedAt': 'শুরু হয়েছে',
'flowRuns.inspector.finishedAt': 'শেষ হয়েছে',
@@ -3708,6 +3728,14 @@ const messages: TranslationMap = {
'flowRuns.inspector.error': 'ত্রুটি',
'flowRuns.inspector.pendingApprovals': 'মুলতুবি অনুমোদন',
'flowRuns.inspector.pendingApprovalsCount': '{count}টি নোড অনুমোদনের অপেক্ষায়',
'flowRuns.inspector.approval.tool': 'টুল:',
'flowRuns.inspector.approval.approve': 'একবার অনুমোদন করুন',
'flowRuns.inspector.approval.approveAlways': 'সর্বদা অনুমোদন করুন',
'flowRuns.inspector.approval.approveAlwaysHint':
'এই ফ্লো-এর ভবিষ্যত রানগুলিতে এই চেকপয়েন্ট এড়িয়ে যান',
'flowRuns.inspector.approval.deny': 'প্রত্যাখ্যান করুন',
'flowRuns.inspector.approval.deciding': 'চলমান...',
'flowRuns.inspector.approval.loadError': 'এই রানের জন্য মুলতুবি অনুমোদনগুলি লোড করা যায়নি।',
'flowRuns.inspector.steps': 'ধাপ',
'flowRuns.inspector.noSteps': 'এখনও কোনো ধাপ রেকর্ড করা হয়নি।',
'flowRuns.inspector.output': 'আউটপুট',
@@ -5270,6 +5298,7 @@ const messages: TranslationMap = {
'settings.approvalHistory.decidedAt': '{date} তারিখে সিদ্ধান্ত নেওয়া হয়েছে',
'settings.approvalHistory.decision.approveOnce': 'একবার অনুমোদিত',
'settings.approvalHistory.decision.approveAlways': 'সর্বদা অনুমোদিত',
'settings.approvalHistory.decision.approveAlwaysFlow': 'সর্বদা অনুমোদিত (ফ্লো)',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.theme.variantLight': 'হালকা',
'settings.theme.variantDark': 'গাঢ়',
+33
View File
@@ -3221,6 +3221,19 @@ const messages: TranslationMap = {
'Der Agent möchte eine Aktion ausführen, die Ihre Zustimmung erfordert.',
'chat.approval.title': 'Genehmigung erforderlich',
'chat.approval.tool': 'Werkzeug:',
'chat.flowApproval.title': 'Workflow benötigt Genehmigung',
'chat.flowApproval.fallback':
'Ein Workflow-Lauf möchte eine Aktion ausführen, die deine Genehmigung erfordert.',
'chat.flowApproval.tool': 'Werkzeug:',
'chat.flowApproval.flow': 'Ablauf:',
'chat.flowApproval.approve': 'Einmal genehmigen',
'chat.flowApproval.approveAlways': 'Immer genehmigen',
'chat.flowApproval.approveAlwaysHint':
'Diesen Prüfpunkt für zukünftige Läufe dieses Flows überspringen',
'chat.flowApproval.deny': 'Ablehnen',
'chat.flowApproval.deciding': 'Arbeiten…',
'chat.flowApproval.error':
'Deine Entscheidung konnte nicht gespeichert werden — bitte versuche es erneut.',
'chat.flowProposal.title': 'Workflow-Vorschlag',
'chat.flowProposal.subtitle': 'Prüfen Sie diese Automatisierung, bevor Sie sie speichern.',
'chat.flowProposal.triggerLabel': 'Auslöser',
@@ -3791,6 +3804,16 @@ const messages: TranslationMap = {
'notifications.flow.approveHint': 'Workflow nach diesem Kontrollpunkt fortsetzen',
'notifications.flow.dismissHint': 'Diesen Hinweis ausblenden, ohne den Workflow fortzusetzen',
'notifications.flow.viewRun': 'Lauf anzeigen',
'notifications.flowGate.title': 'Workflow benötigt Genehmigung',
'notifications.flowGate.tool': 'Werkzeug:',
'notifications.flowGate.approve': 'Einmal genehmigen',
'notifications.flowGate.approveAlways': 'Immer genehmigen',
'notifications.flowGate.approveAlwaysHint':
'Diesen Prüfpunkt für zukünftige Läufe dieses Flows überspringen',
'notifications.flowGate.deny': 'Ablehnen',
'notifications.flowGate.deciding': 'Arbeiten…',
'notifications.flowGate.error':
'Deine Entscheidung konnte nicht gespeichert werden — bitte versuche es erneut.',
'flowRuns.inspector.title': 'Laufdetails',
'flowRuns.inspector.startedAt': 'Gestartet',
'flowRuns.inspector.finishedAt': 'Beendet',
@@ -3798,6 +3821,15 @@ const messages: TranslationMap = {
'flowRuns.inspector.error': 'Fehler',
'flowRuns.inspector.pendingApprovals': 'Ausstehende Genehmigungen',
'flowRuns.inspector.pendingApprovalsCount': '{count} Knoten warten auf Genehmigung',
'flowRuns.inspector.approval.tool': 'Werkzeug:',
'flowRuns.inspector.approval.approve': 'Einmal genehmigen',
'flowRuns.inspector.approval.approveAlways': 'Immer genehmigen',
'flowRuns.inspector.approval.approveAlwaysHint':
'Diesen Prüfpunkt für zukünftige Läufe dieses Flows überspringen',
'flowRuns.inspector.approval.deny': 'Ablehnen',
'flowRuns.inspector.approval.deciding': 'Arbeiten…',
'flowRuns.inspector.approval.loadError':
'Ausstehende Genehmigungen für diesen Lauf konnten nicht geladen werden.',
'flowRuns.inspector.steps': 'Schritte',
'flowRuns.inspector.noSteps': 'Noch keine Schritte aufgezeichnet.',
'flowRuns.inspector.output': 'Ausgabe',
@@ -5408,6 +5440,7 @@ const messages: TranslationMap = {
'settings.approvalHistory.decidedAt': 'Entschieden am {date}',
'settings.approvalHistory.decision.approveOnce': 'Einmal genehmigt',
'settings.approvalHistory.decision.approveAlways': 'Immer erlaubt',
'settings.approvalHistory.decision.approveAlwaysFlow': 'Immer erlaubt (Flow)',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.theme.variantLight': 'Hell',
'settings.theme.variantDark': 'Dunkel',
+36
View File
@@ -3560,6 +3560,20 @@ const en: TranslationMap = {
'chat.approval.fallback': 'The agent wants to run an action that needs your approval.',
'chat.approval.title': 'Approval needed',
'chat.approval.tool': 'Tool:',
// Flow-approval surface — chat banner for a `flow_approval_request` socket
// event (a paused tinyflows run's gate, surfaced while the user is
// chatting rather than inspecting the run directly).
'chat.flowApproval.title': 'Workflow needs approval',
'chat.flowApproval.fallback':
'A workflow run wants to perform an action that needs your approval.',
'chat.flowApproval.tool': 'Tool:',
'chat.flowApproval.flow': 'Flow:',
'chat.flowApproval.approve': 'Approve once',
'chat.flowApproval.approveAlways': 'Approve always',
'chat.flowApproval.approveAlwaysHint': 'Skip this checkpoint for future runs of this flow',
'chat.flowApproval.deny': 'Deny',
'chat.flowApproval.deciding': 'Working…',
'chat.flowApproval.error': 'Could not record your decision — try again.',
'chat.flowProposal.title': 'Workflow proposal',
'chat.flowProposal.subtitle': 'Review this automation before saving it.',
'chat.flowProposal.triggerLabel': 'Trigger',
@@ -4339,6 +4353,18 @@ const en: TranslationMap = {
'notifications.flow.approveHint': 'Resume the workflow past this checkpoint',
'notifications.flow.dismissHint': 'Hide this prompt without resuming the workflow',
'notifications.flow.viewRun': 'View run',
// Flow-approval surface — notification-center gate card for the
// `flow-gate-approval` CoreNotification kind. Distinct from
// `notifications.flow.*` above (that's the older `flows_resume`-based
// pending-approval prompt); this one decides via `approval_decide`.
'notifications.flowGate.title': 'Workflow needs approval',
'notifications.flowGate.tool': 'Tool:',
'notifications.flowGate.approve': 'Approve once',
'notifications.flowGate.approveAlways': 'Approve always',
'notifications.flowGate.approveAlwaysHint': 'Skip this checkpoint for future runs of this flow',
'notifications.flowGate.deny': 'Deny',
'notifications.flowGate.deciding': 'Working…',
'notifications.flowGate.error': 'Could not record your decision. Please try again.',
'flowRuns.inspector.title': 'Run details',
'flowRuns.inspector.startedAt': 'Started',
'flowRuns.inspector.finishedAt': 'Finished',
@@ -4346,6 +4372,15 @@ const en: TranslationMap = {
'flowRuns.inspector.error': 'Error',
'flowRuns.inspector.pendingApprovals': 'Pending approvals',
'flowRuns.inspector.pendingApprovalsCount': '{count} node(s) awaiting approval',
// Actionable approval gate cards (flow-approval surface — run details).
'flowRuns.inspector.approval.tool': 'Tool:',
'flowRuns.inspector.approval.approve': 'Approve once',
'flowRuns.inspector.approval.approveAlways': 'Approve always',
'flowRuns.inspector.approval.approveAlwaysHint':
'Skip this checkpoint for future runs of this flow',
'flowRuns.inspector.approval.deny': 'Deny',
'flowRuns.inspector.approval.deciding': 'Working…',
'flowRuns.inspector.approval.loadError': 'Could not load pending approvals for this run.',
'flowRuns.inspector.steps': 'Steps',
'flowRuns.inspector.noSteps': 'No steps recorded yet.',
'flowRuns.inspector.output': 'Output',
@@ -5954,6 +5989,7 @@ const en: TranslationMap = {
'settings.approvalHistory.decidedAt': 'Decided {date}',
'settings.approvalHistory.decision.approveOnce': 'Approved once',
'settings.approvalHistory.decision.approveAlways': 'Always allowed',
'settings.approvalHistory.decision.approveAlwaysFlow': 'Always allowed (flow)',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.theme.title': 'Theme Studio',
'settings.theme.menuDesc': 'Fully customize colours and fonts, or pick a preset theme.',
+31
View File
@@ -3199,6 +3199,18 @@ const messages: TranslationMap = {
'chat.approval.fallback': 'El agente quiere ejecutar una acción que necesita su aprobación.',
'chat.approval.title': 'Aprobación necesaria',
'chat.approval.tool': 'Herramienta:',
'chat.flowApproval.title': 'El flujo de trabajo necesita aprobación',
'chat.flowApproval.fallback':
'Una ejecución de flujo de trabajo quiere realizar una acción que necesita tu aprobación.',
'chat.flowApproval.tool': 'Herramienta:',
'chat.flowApproval.flow': 'Flujo:',
'chat.flowApproval.approve': 'Aprobar una vez',
'chat.flowApproval.approveAlways': 'Aprobar siempre',
'chat.flowApproval.approveAlwaysHint':
'Omitir esta verificación en futuras ejecuciones de este flujo',
'chat.flowApproval.deny': 'Denegar',
'chat.flowApproval.deciding': 'Laboral…',
'chat.flowApproval.error': 'No se pudo registrar su decisión; inténtelo de nuevo.',
'chat.flowProposal.title': 'Propuesta de flujo de trabajo',
'chat.flowProposal.subtitle': 'Revisa esta automatización antes de guardarla.',
'chat.flowProposal.triggerLabel': 'Disparador',
@@ -3764,6 +3776,15 @@ const messages: TranslationMap = {
'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',
'notifications.flowGate.title': 'El flujo de trabajo necesita aprobación',
'notifications.flowGate.tool': 'Herramienta:',
'notifications.flowGate.approve': 'Aprobar una vez',
'notifications.flowGate.approveAlways': 'Aprobar siempre',
'notifications.flowGate.approveAlwaysHint':
'Omitir esta verificación en futuras ejecuciones de este flujo',
'notifications.flowGate.deny': 'Denegar',
'notifications.flowGate.deciding': 'Laboral…',
'notifications.flowGate.error': 'No se pudo registrar su decisión; inténtelo de nuevo.',
'flowRuns.inspector.title': 'Detalles de la ejecución',
'flowRuns.inspector.startedAt': 'Iniciado',
'flowRuns.inspector.finishedAt': 'Finalizado',
@@ -3771,6 +3792,15 @@ const messages: TranslationMap = {
'flowRuns.inspector.error': 'Error',
'flowRuns.inspector.pendingApprovals': 'Aprobaciones pendientes',
'flowRuns.inspector.pendingApprovalsCount': '{count} nodo(s) esperando aprobación',
'flowRuns.inspector.approval.tool': 'Herramienta:',
'flowRuns.inspector.approval.approve': 'Aprobar una vez',
'flowRuns.inspector.approval.approveAlways': 'Aprobar siempre',
'flowRuns.inspector.approval.approveAlwaysHint':
'Omitir esta verificación en futuras ejecuciones de este flujo',
'flowRuns.inspector.approval.deny': 'Denegar',
'flowRuns.inspector.approval.deciding': 'Laboral…',
'flowRuns.inspector.approval.loadError':
'No se pudieron cargar las aprobaciones pendientes de esta ejecución.',
'flowRuns.inspector.steps': 'Pasos',
'flowRuns.inspector.noSteps': 'Aún no se han registrado pasos.',
'flowRuns.inspector.output': 'Salida',
@@ -5365,6 +5395,7 @@ const messages: TranslationMap = {
'settings.approvalHistory.decidedAt': 'Decidido el {date}',
'settings.approvalHistory.decision.approveOnce': 'Aprobado una vez',
'settings.approvalHistory.decision.approveAlways': 'Siempre permitido',
'settings.approvalHistory.decision.approveAlwaysFlow': 'Siempre permitido (flujo)',
'settings.approvalHistory.decision.deny': 'Denegado',
'settings.theme.variantLight': 'Claro',
'settings.theme.variantDark': 'Oscuro',
+31
View File
@@ -3213,6 +3213,18 @@ const messages: TranslationMap = {
'chat.approval.fallback': "L'agent veut exécuter une action qui nécessite votre approbation.",
'chat.approval.title': 'Approbation requise',
'chat.approval.tool': 'Outil:',
'chat.flowApproval.title': 'Le workflow nécessite une approbation',
'chat.flowApproval.fallback':
'Une exécution de workflow souhaite effectuer une action qui nécessite votre approbation.',
'chat.flowApproval.tool': 'Outil:',
'chat.flowApproval.flow': 'Flux :',
'chat.flowApproval.approve': 'Approuver une fois',
'chat.flowApproval.approveAlways': 'Toujours approuver',
'chat.flowApproval.approveAlwaysHint':
'Ignorer ce point de contrôle pour les futures exécutions de ce flux',
'chat.flowApproval.deny': 'Refuser',
'chat.flowApproval.deciding': 'Travail en cours…',
'chat.flowApproval.error': "Impossible d'enregistrer votre décision — veuillez réessayer.",
'chat.flowProposal.title': 'Proposition de workflow',
'chat.flowProposal.subtitle': "Vérifiez cette automatisation avant de l'enregistrer.",
'chat.flowProposal.triggerLabel': 'Déclencheur',
@@ -3779,6 +3791,15 @@ const messages: TranslationMap = {
'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",
'notifications.flowGate.title': 'Le workflow nécessite une approbation',
'notifications.flowGate.tool': 'Outil:',
'notifications.flowGate.approve': 'Approuver une fois',
'notifications.flowGate.approveAlways': 'Toujours approuver',
'notifications.flowGate.approveAlwaysHint':
'Ignorer ce point de contrôle pour les futures exécutions de ce flux',
'notifications.flowGate.deny': 'Refuser',
'notifications.flowGate.deciding': 'Travail en cours…',
'notifications.flowGate.error': "Impossible d'enregistrer votre décision — veuillez réessayer.",
'flowRuns.inspector.title': "Détails de l'exécution",
'flowRuns.inspector.startedAt': 'Démarré',
'flowRuns.inspector.finishedAt': 'Terminé',
@@ -3786,6 +3807,15 @@ const messages: TranslationMap = {
'flowRuns.inspector.error': 'Erreur',
'flowRuns.inspector.pendingApprovals': 'Approbations en attente',
'flowRuns.inspector.pendingApprovalsCount': "{count} nœud(s) en attente d'approbation",
'flowRuns.inspector.approval.tool': 'Outil:',
'flowRuns.inspector.approval.approve': 'Approuver une fois',
'flowRuns.inspector.approval.approveAlways': 'Toujours approuver',
'flowRuns.inspector.approval.approveAlwaysHint':
'Ignorer ce point de contrôle pour les futures exécutions de ce flux',
'flowRuns.inspector.approval.deny': 'Refuser',
'flowRuns.inspector.approval.deciding': 'Travail en cours…',
'flowRuns.inspector.approval.loadError':
'Impossible de charger les approbations en attente pour cette exécution.',
'flowRuns.inspector.steps': 'Étapes',
'flowRuns.inspector.noSteps': 'Aucune étape enregistrée pour le moment.',
'flowRuns.inspector.output': 'Sortie',
@@ -5385,6 +5415,7 @@ const messages: TranslationMap = {
'settings.approvalHistory.decidedAt': 'Décidé le {date}',
'settings.approvalHistory.decision.approveOnce': 'Approuvé une fois',
'settings.approvalHistory.decision.approveAlways': 'Toujours autorisé',
'settings.approvalHistory.decision.approveAlwaysFlow': 'Toujours autorisé (flux)',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.theme.variantLight': 'Clair',
'settings.theme.variantDark': 'Sombre',
+28
View File
@@ -3144,6 +3144,16 @@ const messages: TranslationMap = {
'chat.approval.fallback': 'एजेंट अपने अनुमोदन की जरूरत है कि एक कार्रवाई चलाने के लिए चाहता है।',
'chat.approval.title': 'आवश्यक अनुमोदन',
'chat.approval.tool': 'उपकरण:',
'chat.flowApproval.title': 'वर्कफ़्लो को अनुमोदन की आवश्यकता है',
'chat.flowApproval.fallback': 'एक वर्कफ़्लो रन आपकी अनुमति चाहने वाली एक क्रिया करना चाहता है।',
'chat.flowApproval.tool': 'उपकरण:',
'chat.flowApproval.flow': 'फ़्लो:',
'chat.flowApproval.approve': 'एक बार स्वीकृति दें',
'chat.flowApproval.approveAlways': 'हमेशा स्वीकृति दें',
'chat.flowApproval.approveAlwaysHint': 'इस फ़्लो के भविष्य के रन के लिए इस चेकपॉइंट को छोड़ें',
'chat.flowApproval.deny': 'अस्वीकार करें',
'chat.flowApproval.deciding': 'कार्य प्रगति पर…',
'chat.flowApproval.error': 'अपने निर्णय को रिकॉर्ड नहीं कर सकता - फिर से प्रयास करें।',
'chat.flowProposal.title': 'वर्कफ़्लो प्रस्ताव',
'chat.flowProposal.subtitle': 'सहेजने से पहले इस स्वचालन की समीक्षा करें।',
'chat.flowProposal.triggerLabel': 'ट्रिगर',
@@ -3702,6 +3712,15 @@ const messages: TranslationMap = {
'notifications.flow.approveHint': 'इस चेकपॉइंट के बाद वर्कफ़्लो फिर से शुरू करें',
'notifications.flow.dismissHint': 'वर्कफ़्लो को फिर से शुरू किए बिना यह संकेत छिपाएं',
'notifications.flow.viewRun': 'रन देखें',
'notifications.flowGate.title': 'वर्कफ़्लो को अनुमोदन की आवश्यकता है',
'notifications.flowGate.tool': 'उपकरण:',
'notifications.flowGate.approve': 'एक बार स्वीकृति दें',
'notifications.flowGate.approveAlways': 'हमेशा स्वीकृति दें',
'notifications.flowGate.approveAlwaysHint':
'इस फ़्लो के भविष्य के रन के लिए इस चेकपॉइंट को छोड़ें',
'notifications.flowGate.deny': 'अस्वीकार करें',
'notifications.flowGate.deciding': 'कार्य प्रगति पर…',
'notifications.flowGate.error': 'अपने निर्णय को रिकॉर्ड नहीं कर सकता - फिर से प्रयास करें।',
'flowRuns.inspector.title': 'रन विवरण',
'flowRuns.inspector.startedAt': 'शुरू हुआ',
'flowRuns.inspector.finishedAt': 'समाप्त हुआ',
@@ -3709,6 +3728,14 @@ const messages: TranslationMap = {
'flowRuns.inspector.error': 'त्रुटि',
'flowRuns.inspector.pendingApprovals': 'लंबित अनुमोदन',
'flowRuns.inspector.pendingApprovalsCount': '{count} नोड अनुमोदन की प्रतीक्षा में',
'flowRuns.inspector.approval.tool': 'उपकरण:',
'flowRuns.inspector.approval.approve': 'एक बार स्वीकृति दें',
'flowRuns.inspector.approval.approveAlways': 'हमेशा स्वीकृति दें',
'flowRuns.inspector.approval.approveAlwaysHint':
'इस फ़्लो के भविष्य के रन के लिए इस चेकपॉइंट को छोड़ें',
'flowRuns.inspector.approval.deny': 'अस्वीकार करें',
'flowRuns.inspector.approval.deciding': 'कार्य प्रगति पर…',
'flowRuns.inspector.approval.loadError': 'इस रन के लिए लंबित अनुमोदन लोड नहीं किए जा सके।',
'flowRuns.inspector.steps': 'चरण',
'flowRuns.inspector.noSteps': 'अभी तक कोई चरण दर्ज नहीं किया गया है।',
'flowRuns.inspector.output': 'आउटपुट',
@@ -5270,6 +5297,7 @@ const messages: TranslationMap = {
'settings.approvalHistory.decidedAt': '{date} को निर्णय लिया गया',
'settings.approvalHistory.decision.approveOnce': 'एक बार स्वीकृत',
'settings.approvalHistory.decision.approveAlways': 'हमेशा अनुमति',
'settings.approvalHistory.decision.approveAlwaysFlow': 'हमेशा अनुमति (फ़्लो)',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.theme.variantLight': 'हल्का',
'settings.theme.variantDark': 'गहरा',
+31
View File
@@ -3154,6 +3154,18 @@ const messages: TranslationMap = {
'chat.approval.fallback': 'Agen ingin melakukan tindakan yang membutuhkan persetujuanmu.',
'chat.approval.title': 'Perlu persetujuan',
'chat.approval.tool': 'Alat:',
'chat.flowApproval.title': 'Alur kerja memerlukan persetujuan',
'chat.flowApproval.fallback':
'Proses alur kerja ingin melakukan tindakan yang memerlukan persetujuan Anda.',
'chat.flowApproval.tool': 'Alat:',
'chat.flowApproval.flow': 'Alur:',
'chat.flowApproval.approve': 'Setujui sekali',
'chat.flowApproval.approveAlways': 'Selalu setujui',
'chat.flowApproval.approveAlwaysHint':
'Lewati pemeriksaan ini untuk proses mendatang pada alur ini',
'chat.flowApproval.deny': 'Tolak',
'chat.flowApproval.deciding': 'Bekerja…',
'chat.flowApproval.error': 'Tidak bisa merekam keputusan Anda - coba lagi.',
'chat.flowProposal.title': 'Proposal alur kerja',
'chat.flowProposal.subtitle': 'Tinjau otomatisasi ini sebelum menyimpannya.',
'chat.flowProposal.triggerLabel': 'Pemicu',
@@ -3710,6 +3722,15 @@ const messages: TranslationMap = {
'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',
'notifications.flowGate.title': 'Alur kerja memerlukan persetujuan',
'notifications.flowGate.tool': 'Alat:',
'notifications.flowGate.approve': 'Setujui sekali',
'notifications.flowGate.approveAlways': 'Selalu setujui',
'notifications.flowGate.approveAlwaysHint':
'Lewati pemeriksaan ini untuk proses mendatang pada alur ini',
'notifications.flowGate.deny': 'Tolak',
'notifications.flowGate.deciding': 'Bekerja…',
'notifications.flowGate.error': 'Tidak bisa merekam keputusan Anda - coba lagi.',
'flowRuns.inspector.title': 'Detail proses',
'flowRuns.inspector.startedAt': 'Dimulai',
'flowRuns.inspector.finishedAt': 'Selesai',
@@ -3717,6 +3738,15 @@ const messages: TranslationMap = {
'flowRuns.inspector.error': 'Kesalahan',
'flowRuns.inspector.pendingApprovals': 'Persetujuan tertunda',
'flowRuns.inspector.pendingApprovalsCount': '{count} node menunggu persetujuan',
'flowRuns.inspector.approval.tool': 'Alat:',
'flowRuns.inspector.approval.approve': 'Setujui sekali',
'flowRuns.inspector.approval.approveAlways': 'Selalu setujui',
'flowRuns.inspector.approval.approveAlwaysHint':
'Lewati pemeriksaan ini untuk proses mendatang pada alur ini',
'flowRuns.inspector.approval.deny': 'Tolak',
'flowRuns.inspector.approval.deciding': 'Bekerja…',
'flowRuns.inspector.approval.loadError':
'Tidak dapat memuat persetujuan yang tertunda untuk proses ini.',
'flowRuns.inspector.steps': 'Langkah',
'flowRuns.inspector.noSteps': 'Belum ada langkah yang tercatat.',
'flowRuns.inspector.output': 'Keluaran',
@@ -5286,6 +5316,7 @@ const messages: TranslationMap = {
'settings.approvalHistory.decidedAt': 'Diputuskan {date}',
'settings.approvalHistory.decision.approveOnce': 'Disetujui sekali',
'settings.approvalHistory.decision.approveAlways': 'Selalu diizinkan',
'settings.approvalHistory.decision.approveAlwaysFlow': 'Selalu diizinkan (alur)',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.theme.variantLight': 'Terang',
'settings.theme.variantDark': 'Gelap',
+31
View File
@@ -3195,6 +3195,18 @@ const messages: TranslationMap = {
"L'agente vuole eseguire un'azione che necessita della tua approvazione.",
'chat.approval.title': 'Approvazione necessaria',
'chat.approval.tool': 'Strumento:',
'chat.flowApproval.title': "Il workflow richiede l'approvazione",
'chat.flowApproval.fallback':
"Un'esecuzione del workflow vuole eseguire un'azione che richiede la tua approvazione.",
'chat.flowApproval.tool': 'Strumento:',
'chat.flowApproval.flow': 'Flusso:',
'chat.flowApproval.approve': 'Approva una volta',
'chat.flowApproval.approveAlways': 'Approva sempre',
'chat.flowApproval.approveAlwaysHint':
'Salta questo checkpoint per le future esecuzioni di questo flusso',
'chat.flowApproval.deny': 'Rifiuta',
'chat.flowApproval.deciding': 'Lavorando…',
'chat.flowApproval.error': 'Impossibile registrare la tua decisione — riprova.',
'chat.flowProposal.title': 'Proposta di workflow',
'chat.flowProposal.subtitle': 'Rivedi questa automazione prima di salvarla.',
'chat.flowProposal.triggerLabel': 'Trigger',
@@ -3759,6 +3771,15 @@ const messages: TranslationMap = {
'notifications.flow.approveHint': 'Riprendi il workflow dopo questo checkpoint',
'notifications.flow.dismissHint': 'Nascondi questo avviso senza riprendere il workflow',
'notifications.flow.viewRun': 'Visualizza esecuzione',
'notifications.flowGate.title': "Il workflow richiede l'approvazione",
'notifications.flowGate.tool': 'Strumento:',
'notifications.flowGate.approve': 'Approva una volta',
'notifications.flowGate.approveAlways': 'Approva sempre',
'notifications.flowGate.approveAlwaysHint':
'Salta questo checkpoint per le future esecuzioni di questo flusso',
'notifications.flowGate.deny': 'Rifiuta',
'notifications.flowGate.deciding': 'Lavorando…',
'notifications.flowGate.error': 'Impossibile registrare la tua decisione — riprova.',
'flowRuns.inspector.title': 'Dettagli esecuzione',
'flowRuns.inspector.startedAt': 'Avviato',
'flowRuns.inspector.finishedAt': 'Terminato',
@@ -3766,6 +3787,15 @@ const messages: TranslationMap = {
'flowRuns.inspector.error': 'Errore',
'flowRuns.inspector.pendingApprovals': 'Approvazioni in sospeso',
'flowRuns.inspector.pendingApprovalsCount': '{count} nodo/i in attesa di approvazione',
'flowRuns.inspector.approval.tool': 'Strumento:',
'flowRuns.inspector.approval.approve': 'Approva una volta',
'flowRuns.inspector.approval.approveAlways': 'Approva sempre',
'flowRuns.inspector.approval.approveAlwaysHint':
'Salta questo checkpoint per le future esecuzioni di questo flusso',
'flowRuns.inspector.approval.deny': 'Rifiuta',
'flowRuns.inspector.approval.deciding': 'Lavorando…',
'flowRuns.inspector.approval.loadError':
'Impossibile caricare le approvazioni in sospeso per questa esecuzione.',
'flowRuns.inspector.steps': 'Passaggi',
'flowRuns.inspector.noSteps': 'Nessun passaggio registrato finora.',
'flowRuns.inspector.output': 'Output',
@@ -5355,6 +5385,7 @@ const messages: TranslationMap = {
'settings.approvalHistory.decidedAt': 'Deciso il {date}',
'settings.approvalHistory.decision.approveOnce': 'Approvato una volta',
'settings.approvalHistory.decision.approveAlways': 'Sempre consentito',
'settings.approvalHistory.decision.approveAlwaysFlow': 'Sempre consentito (flusso)',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.theme.variantLight': 'Chiaro',
'settings.theme.variantDark': 'Scuro',
+27
View File
@@ -3113,6 +3113,16 @@ const messages: TranslationMap = {
'chat.approval.fallback': '에이전트가 승인이 필요한 작업을 실행하려고 합니다.',
'chat.approval.title': '승인 필요',
'chat.approval.tool': '도구:',
'chat.flowApproval.title': '워크플로에 승인이 필요합니다',
'chat.flowApproval.fallback': '워크플로 실행이 승인이 필요한 작업을 수행하려고 합니다.',
'chat.flowApproval.tool': '도구:',
'chat.flowApproval.flow': '플로우:',
'chat.flowApproval.approve': '한 번 승인',
'chat.flowApproval.approveAlways': '항상 승인',
'chat.flowApproval.approveAlwaysHint': '이 플로우의 향후 실행에서 이 검사 지점을 건너뜁니다',
'chat.flowApproval.deny': '거부',
'chat.flowApproval.deciding': '작업 중…',
'chat.flowApproval.error': '결정을 기록할 수 없습니다. 다시 시도하세요.',
'chat.flowProposal.title': '워크플로 제안',
'chat.flowProposal.subtitle': '저장하기 전에 이 자동화를 검토하세요.',
'chat.flowProposal.triggerLabel': '트리거',
@@ -3665,6 +3675,14 @@ const messages: TranslationMap = {
'notifications.flow.approveHint': '이 체크포인트 이후 워크플로 재개',
'notifications.flow.dismissHint': '워크플로를 재개하지 않고 이 알림 숨기기',
'notifications.flow.viewRun': '실행 보기',
'notifications.flowGate.title': '워크플로에 승인이 필요합니다',
'notifications.flowGate.tool': '도구:',
'notifications.flowGate.approve': '한 번 승인',
'notifications.flowGate.approveAlways': '항상 승인',
'notifications.flowGate.approveAlwaysHint': '이 플로우의 향후 실행에서 이 검사 지점을 건너뜁니다',
'notifications.flowGate.deny': '거부',
'notifications.flowGate.deciding': '작업 중…',
'notifications.flowGate.error': '결정을 기록할 수 없습니다. 다시 시도하세요.',
'flowRuns.inspector.title': '실행 세부정보',
'flowRuns.inspector.startedAt': '시작됨',
'flowRuns.inspector.finishedAt': '종료됨',
@@ -3672,6 +3690,14 @@ const messages: TranslationMap = {
'flowRuns.inspector.error': '오류',
'flowRuns.inspector.pendingApprovals': '대기 중인 승인',
'flowRuns.inspector.pendingApprovalsCount': '노드 {count}개가 승인 대기 중',
'flowRuns.inspector.approval.tool': '도구:',
'flowRuns.inspector.approval.approve': '한 번 승인',
'flowRuns.inspector.approval.approveAlways': '항상 승인',
'flowRuns.inspector.approval.approveAlwaysHint':
'이 플로우의 향후 실행에서 이 검사 지점을 건너뜁니다',
'flowRuns.inspector.approval.deny': '거부',
'flowRuns.inspector.approval.deciding': '작업 중…',
'flowRuns.inspector.approval.loadError': '이 실행에 대한 대기 중인 승인을 불러올 수 없습니다.',
'flowRuns.inspector.steps': '단계',
'flowRuns.inspector.noSteps': '아직 기록된 단계가 없습니다.',
'flowRuns.inspector.output': '출력',
@@ -5209,6 +5235,7 @@ const messages: TranslationMap = {
'settings.approvalHistory.decidedAt': '{date}에 결정됨',
'settings.approvalHistory.decision.approveOnce': '1회 승인',
'settings.approvalHistory.decision.approveAlways': '항상 허용',
'settings.approvalHistory.decision.approveAlwaysFlow': '항상 허용(플로우)',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.theme.variantLight': '밝게',
'settings.theme.variantDark': '어둡게',
+31
View File
@@ -3179,6 +3179,18 @@ const messages: TranslationMap = {
'chat.approval.fallback': 'Agent chce wykonać akcję wymagającą Twojej zgody.',
'chat.approval.title': 'Wymagana zgoda',
'chat.approval.tool': 'Narzędzie:',
'chat.flowApproval.title': 'Przepływ pracy wymaga zatwierdzenia',
'chat.flowApproval.fallback':
'Uruchomienie przepływu pracy chce wykonać czynność, która wymaga twojego zatwierdzenia.',
'chat.flowApproval.tool': 'Narzędzie:',
'chat.flowApproval.flow': 'Przepływ:',
'chat.flowApproval.approve': 'Zatwierdź raz',
'chat.flowApproval.approveAlways': 'Zawsze zatwierdzaj',
'chat.flowApproval.approveAlwaysHint':
'Pomijaj ten punkt kontrolny w przyszłych uruchomieniach tego przepływu',
'chat.flowApproval.deny': 'Odrzuć',
'chat.flowApproval.deciding': 'Przetwarzanie…',
'chat.flowApproval.error': 'Nie udało się zapisać decyzji — spróbuj ponownie.',
'chat.flowProposal.title': 'Propozycja przepływu pracy',
'chat.flowProposal.subtitle': 'Sprawdź tę automatyzację przed zapisaniem.',
'chat.flowProposal.triggerLabel': 'Wyzwalacz',
@@ -3745,6 +3757,15 @@ const messages: TranslationMap = {
'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',
'notifications.flowGate.title': 'Przepływ pracy wymaga zatwierdzenia',
'notifications.flowGate.tool': 'Narzędzie:',
'notifications.flowGate.approve': 'Zatwierdź raz',
'notifications.flowGate.approveAlways': 'Zawsze zatwierdzaj',
'notifications.flowGate.approveAlwaysHint':
'Pomijaj ten punkt kontrolny w przyszłych uruchomieniach tego przepływu',
'notifications.flowGate.deny': 'Odrzuć',
'notifications.flowGate.deciding': 'Przetwarzanie…',
'notifications.flowGate.error': 'Nie udało się zapisać decyzji — spróbuj ponownie.',
'flowRuns.inspector.title': 'Szczegóły przebiegu',
'flowRuns.inspector.startedAt': 'Rozpoczęto',
'flowRuns.inspector.finishedAt': 'Zakończono',
@@ -3752,6 +3773,15 @@ const messages: TranslationMap = {
'flowRuns.inspector.error': 'Błąd',
'flowRuns.inspector.pendingApprovals': 'Oczekujące zatwierdzenia',
'flowRuns.inspector.pendingApprovalsCount': '{count} węzeł(y) oczekuje(ą) na zatwierdzenie',
'flowRuns.inspector.approval.tool': 'Narzędzie:',
'flowRuns.inspector.approval.approve': 'Zatwierdź raz',
'flowRuns.inspector.approval.approveAlways': 'Zawsze zatwierdzaj',
'flowRuns.inspector.approval.approveAlwaysHint':
'Pomijaj ten punkt kontrolny w przyszłych uruchomieniach tego przepływu',
'flowRuns.inspector.approval.deny': 'Odrzuć',
'flowRuns.inspector.approval.deciding': 'Przetwarzanie…',
'flowRuns.inspector.approval.loadError':
'Nie udało się załadować oczekujących zatwierdzeń dla tego przebiegu.',
'flowRuns.inspector.steps': 'Kroki',
'flowRuns.inspector.noSteps': 'Nie zarejestrowano jeszcze żadnych kroków.',
'flowRuns.inspector.output': 'Dane wyjściowe',
@@ -5348,6 +5378,7 @@ const messages: TranslationMap = {
'settings.approvalHistory.decidedAt': 'Zdecydowano {date}',
'settings.approvalHistory.decision.approveOnce': 'Zatwierdzone raz',
'settings.approvalHistory.decision.approveAlways': 'Zawsze dozwolone',
'settings.approvalHistory.decision.approveAlwaysFlow': 'Zawsze dozwolone (przepływ)',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.theme.variantLight': 'Jasny',
'settings.theme.variantDark': 'Ciemny',
+31
View File
@@ -3197,6 +3197,18 @@ const messages: TranslationMap = {
'chat.approval.fallback': 'O agente quer executar uma ação que precisa da sua aprovação.',
'chat.approval.title': 'Aprovação necessária',
'chat.approval.tool': 'Ferramenta:',
'chat.flowApproval.title': 'O fluxo de trabalho precisa de aprovação',
'chat.flowApproval.fallback':
'Uma execução de fluxo de trabalho deseja realizar uma ação que precisa da sua aprovação.',
'chat.flowApproval.tool': 'Ferramenta:',
'chat.flowApproval.flow': 'Fluxo:',
'chat.flowApproval.approve': 'Aprovar uma vez',
'chat.flowApproval.approveAlways': 'Aprovar sempre',
'chat.flowApproval.approveAlwaysHint':
'Ignorar esta verificação em futuras execuções deste fluxo',
'chat.flowApproval.deny': 'Negar',
'chat.flowApproval.deciding': 'Trabalhando…',
'chat.flowApproval.error': 'Não foi possível registrar sua decisão — tente novamente.',
'chat.flowProposal.title': 'Proposta de fluxo de trabalho',
'chat.flowProposal.subtitle': 'Revise esta automação antes de salvá-la.',
'chat.flowProposal.triggerLabel': 'Gatilho',
@@ -3760,6 +3772,15 @@ const messages: TranslationMap = {
'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',
'notifications.flowGate.title': 'O fluxo de trabalho precisa de aprovação',
'notifications.flowGate.tool': 'Ferramenta:',
'notifications.flowGate.approve': 'Aprovar uma vez',
'notifications.flowGate.approveAlways': 'Aprovar sempre',
'notifications.flowGate.approveAlwaysHint':
'Ignorar esta verificação em futuras execuções deste fluxo',
'notifications.flowGate.deny': 'Negar',
'notifications.flowGate.deciding': 'Trabalhando…',
'notifications.flowGate.error': 'Não foi possível registrar sua decisão — tente novamente.',
'flowRuns.inspector.title': 'Detalhes da execução',
'flowRuns.inspector.startedAt': 'Iniciado',
'flowRuns.inspector.finishedAt': 'Concluído',
@@ -3767,6 +3788,15 @@ const messages: TranslationMap = {
'flowRuns.inspector.error': 'Erro',
'flowRuns.inspector.pendingApprovals': 'Aprovações pendentes',
'flowRuns.inspector.pendingApprovalsCount': '{count} nó(s) aguardando aprovação',
'flowRuns.inspector.approval.tool': 'Ferramenta:',
'flowRuns.inspector.approval.approve': 'Aprovar uma vez',
'flowRuns.inspector.approval.approveAlways': 'Aprovar sempre',
'flowRuns.inspector.approval.approveAlwaysHint':
'Ignorar esta verificação em futuras execuções deste fluxo',
'flowRuns.inspector.approval.deny': 'Negar',
'flowRuns.inspector.approval.deciding': 'Trabalhando…',
'flowRuns.inspector.approval.loadError':
'Não foi possível carregar as aprovações pendentes desta execução.',
'flowRuns.inspector.steps': 'Etapas',
'flowRuns.inspector.noSteps': 'Nenhuma etapa registrada ainda.',
'flowRuns.inspector.output': 'Saída',
@@ -5354,6 +5384,7 @@ const messages: TranslationMap = {
'settings.approvalHistory.decidedAt': 'Decidido em {date}',
'settings.approvalHistory.decision.approveOnce': 'Aprovado uma vez',
'settings.approvalHistory.decision.approveAlways': 'Sempre permitido',
'settings.approvalHistory.decision.approveAlwaysFlow': 'Sempre permitido (fluxo)',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.theme.variantLight': 'Claro',
'settings.theme.variantDark': 'Escuro',
+31
View File
@@ -3170,6 +3170,18 @@ const messages: TranslationMap = {
'chat.approval.fallback': 'Агент хочет выполнить действие, требующее вашего одобрения.',
'chat.approval.title': 'Требуется одобрение',
'chat.approval.tool': 'Инструмент:',
'chat.flowApproval.title': 'Рабочий процесс требует одобрения',
'chat.flowApproval.fallback':
'Запуск рабочего процесса хочет выполнить действие, которое требует вашего одобрения.',
'chat.flowApproval.tool': 'Инструмент:',
'chat.flowApproval.flow': 'Поток:',
'chat.flowApproval.approve': 'Утвердить один раз',
'chat.flowApproval.approveAlways': 'Утверждать всегда',
'chat.flowApproval.approveAlwaysHint':
'Пропускать эту проверку при будущих запусках этого потока',
'chat.flowApproval.deny': 'Отклонить',
'chat.flowApproval.deciding': 'Выполняется…',
'chat.flowApproval.error': 'Не удалось записать свое решение — попробуйте еще раз.',
'chat.flowProposal.title': 'Предложение рабочего процесса',
'chat.flowProposal.subtitle': 'Проверьте эту автоматизацию перед сохранением.',
'chat.flowProposal.triggerLabel': 'Триггер',
@@ -3734,6 +3746,15 @@ const messages: TranslationMap = {
'notifications.flow.approveHint': 'Возобновить рабочий процесс после этой контрольной точки',
'notifications.flow.dismissHint': 'Скрыть это уведомление без возобновления рабочего процесса',
'notifications.flow.viewRun': 'Просмотреть запуск',
'notifications.flowGate.title': 'Рабочий процесс требует одобрения',
'notifications.flowGate.tool': 'Инструмент:',
'notifications.flowGate.approve': 'Утвердить один раз',
'notifications.flowGate.approveAlways': 'Утверждать всегда',
'notifications.flowGate.approveAlwaysHint':
'Пропускать эту проверку при будущих запусках этого потока',
'notifications.flowGate.deny': 'Отклонить',
'notifications.flowGate.deciding': 'Выполняется…',
'notifications.flowGate.error': 'Не удалось записать свое решение — попробуйте еще раз.',
'flowRuns.inspector.title': 'Детали запуска',
'flowRuns.inspector.startedAt': 'Начато',
'flowRuns.inspector.finishedAt': 'Завершено',
@@ -3741,6 +3762,15 @@ const messages: TranslationMap = {
'flowRuns.inspector.error': 'Ошибка',
'flowRuns.inspector.pendingApprovals': 'Ожидающие подтверждения',
'flowRuns.inspector.pendingApprovalsCount': '{count} узел(-ов) ожидает подтверждения',
'flowRuns.inspector.approval.tool': 'Инструмент:',
'flowRuns.inspector.approval.approve': 'Утвердить один раз',
'flowRuns.inspector.approval.approveAlways': 'Утверждать всегда',
'flowRuns.inspector.approval.approveAlwaysHint':
'Пропускать эту проверку при будущих запусках этого потока',
'flowRuns.inspector.approval.deny': 'Отклонить',
'flowRuns.inspector.approval.deciding': 'Выполняется…',
'flowRuns.inspector.approval.loadError':
'Не удалось загрузить ожидающие подтверждения для этого запуска.',
'flowRuns.inspector.steps': 'Шаги',
'flowRuns.inspector.noSteps': 'Пока не зафиксировано ни одного шага.',
'flowRuns.inspector.output': 'Вывод',
@@ -5322,6 +5352,7 @@ const messages: TranslationMap = {
'settings.approvalHistory.decidedAt': 'Решено {date}',
'settings.approvalHistory.decision.approveOnce': 'Одобрено однократно',
'settings.approvalHistory.decision.approveAlways': 'Всегда разрешено',
'settings.approvalHistory.decision.approveAlwaysFlow': 'Всегда разрешено (поток)',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.theme.variantLight': 'Светлая',
'settings.theme.variantDark': 'Тёмная',
+26
View File
@@ -2981,6 +2981,16 @@ const messages: TranslationMap = {
'chat.approval.fallback': '智能体想要运行一项需要你批准的操作。',
'chat.approval.title': '需要批准',
'chat.approval.tool': '工具:',
'chat.flowApproval.title': '工作流需要批准',
'chat.flowApproval.fallback': '工作流运行想要执行一个需要你批准的操作。',
'chat.flowApproval.tool': '工具:',
'chat.flowApproval.flow': '流程:',
'chat.flowApproval.approve': '批准一次',
'chat.flowApproval.approveAlways': '始终批准',
'chat.flowApproval.approveAlwaysHint': '跳过此流程未来运行中的此检查点',
'chat.flowApproval.deny': '拒绝',
'chat.flowApproval.deciding': '处理中…',
'chat.flowApproval.error': '无法记录你的决定 — 请重试。',
'chat.flowProposal.title': '工作流建议',
'chat.flowProposal.subtitle': '保存前请先查看此自动化流程。',
'chat.flowProposal.triggerLabel': '触发器',
@@ -3508,6 +3518,14 @@ const messages: TranslationMap = {
'notifications.flow.approveHint': '在此检查点之后恢复工作流',
'notifications.flow.dismissHint': '隐藏此提示但不恢复工作流',
'notifications.flow.viewRun': '查看运行',
'notifications.flowGate.title': '工作流需要批准',
'notifications.flowGate.tool': '工具:',
'notifications.flowGate.approve': '批准一次',
'notifications.flowGate.approveAlways': '始终批准',
'notifications.flowGate.approveAlwaysHint': '跳过此流程未来运行中的此检查点',
'notifications.flowGate.deny': '拒绝',
'notifications.flowGate.deciding': '处理中…',
'notifications.flowGate.error': '无法记录你的决定 — 请重试。',
'flowRuns.inspector.title': '运行详情',
'flowRuns.inspector.startedAt': '开始时间',
'flowRuns.inspector.finishedAt': '结束时间',
@@ -3515,6 +3533,13 @@ const messages: TranslationMap = {
'flowRuns.inspector.error': '错误',
'flowRuns.inspector.pendingApprovals': '待批准',
'flowRuns.inspector.pendingApprovalsCount': '{count} 个节点等待批准',
'flowRuns.inspector.approval.tool': '工具:',
'flowRuns.inspector.approval.approve': '批准一次',
'flowRuns.inspector.approval.approveAlways': '始终批准',
'flowRuns.inspector.approval.approveAlwaysHint': '跳过此流程未来运行中的此检查点',
'flowRuns.inspector.approval.deny': '拒绝',
'flowRuns.inspector.approval.deciding': '处理中…',
'flowRuns.inspector.approval.loadError': '无法加载此运行的待批准项。',
'flowRuns.inspector.steps': '步骤',
'flowRuns.inspector.noSteps': '尚未记录任何步骤。',
'flowRuns.inspector.output': '输出',
@@ -4994,6 +5019,7 @@ const messages: TranslationMap = {
'settings.approvalHistory.decidedAt': '决定于 {date}',
'settings.approvalHistory.decision.approveOnce': '批准一次',
'settings.approvalHistory.decision.approveAlways': '始终允许',
'settings.approvalHistory.decision.approveAlwaysFlow': '始终允许(流程)',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.theme.variantLight': '浅色',
'settings.theme.variantDark': '深色',
+30
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
type ApprovalAuditEntry,
decideApproval,
fetchPendingApprovals,
fetchRecentApprovalDecisions,
unwrapRows,
@@ -90,4 +91,33 @@ describe('fetchPendingApprovals', () => {
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.approval_list_pending' });
expect(rows[0].request_id).toBe('p-1');
});
it('preserves a flow-origin source_context when present', async () => {
mockCallCoreRpc.mockResolvedValueOnce([
{
request_id: 'p-2',
tool_name: 'shell',
source_context: { kind: 'flow', flow_id: 'flow-1', run_id: 'run-1' },
},
]);
const rows = await fetchPendingApprovals();
expect(rows[0].source_context).toEqual({ kind: 'flow', flow_id: 'flow-1', run_id: 'run-1' });
});
});
describe('decideApproval', () => {
beforeEach(() => mockCallCoreRpc.mockReset());
it('calls openhuman.approval_decide with the request id and decision', async () => {
mockCallCoreRpc.mockResolvedValueOnce({});
await decideApproval('req-1', 'approve_always_for_flow');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.approval_decide',
params: { request_id: 'req-1', decision: 'approve_always_for_flow' },
});
});
});
+43 -2
View File
@@ -14,8 +14,31 @@ import { callCoreRpc } from '../coreRpcClient';
// is NOT installed the core returns a bare `[]`. `unwrapRows` normalizes both.
// ---------------------------------------------------------------------------
/** User's decision on a pending approval (mirrors Rust `ApprovalDecision`). */
export type ApprovalDecision = 'approve_once' | 'approve_always_for_tool' | 'deny';
/**
* User's decision on a pending approval (mirrors Rust `ApprovalDecision`).
* `approve_always_for_flow` (flow-approval surface, see {@link ApprovalSourceContext})
* additionally allowlists the gate for every future run of the same flow, the
* flow-scoped analogue of `approve_always_for_tool`'s session-scoped allowlist.
*/
export type ApprovalDecision =
| 'approve_once'
| 'approve_always_for_tool'
| 'approve_always_for_flow'
| 'deny';
/**
* Origin hint attached to a pending approval that was raised from inside a
* `tinyflows` run rather than an interactive chat turn. Lets the flow-run
* inspector (and any other flow-aware surface) filter the shared
* `approval_list_pending` queue down to just the gates for one run without a
* dedicated endpoint. Absent for chat-originated approvals.
*/
export interface ApprovalSourceContext {
kind: 'flow';
flow_id: string;
run_id: string;
node_id?: string;
}
/** A pending approval awaiting a decision (mirrors Rust `PendingApproval`). */
export interface PendingApproval {
@@ -30,6 +53,8 @@ export interface PendingApproval {
created_at: string;
/** RFC3339 timestamp, or null when the request does not expire. */
expires_at: string | null;
/** Present when this gate was raised from a flow run — see {@link ApprovalSourceContext}. */
source_context?: ApprovalSourceContext;
}
/** A decided approval audit row (mirrors Rust `ApprovalAuditEntry`). */
@@ -84,6 +109,22 @@ export const fetchPendingApprovals = async (): Promise<PendingApproval[]> => {
return unwrapRows<PendingApproval>(raw);
};
/**
* Record a decision on a pending approval. Shared by every approval surface
* (chat `ApprovalRequestCard`, the flow-run inspector, the flow chat banner,
* and the notification-center gate card) — they all park on the same
* `ApprovalGate` and resolve through this one RPC.
*/
export const decideApproval = async (
requestId: string,
decision: ApprovalDecision
): Promise<void> => {
await callCoreRpc({
method: 'openhuman.approval_decide',
params: { request_id: requestId, decision },
});
};
/**
* Snapshot of the host-aware approval-gate boot decision. Mirrors the Rust
* `ApprovalGateBootState` struct in `src/openhuman/approval/gate.rs`.
+10
View File
@@ -30,6 +30,16 @@ export interface NotificationItem {
provider?: string;
deepLink?: string;
actions?: NotificationAction[];
/**
* Discriminator for a core-originated notification that needs a dedicated
* rendering (routed in `NotificationCenter`) instead of the generic
* `CoreNotificationCard`. Currently only `'flow-gate-approval'` — a paused
* `tinyflows` run's approval gate, resolved via `GateApprovalCard` and the
* `openhuman.approval_decide` RPC. Absent for plain action notifications
* (e.g. the meeting auto-join prompt) and for the older
* `flow-pending-approval:`-prefixed id convention (`FlowApprovalCard`).
*/
kind?: string;
}
export interface NotificationPreferences {