mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(flows): frontend — surface flow-run approvals (run-details + chat + notifications) (#4674)
This commit is contained in:
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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': 'داكن',
|
||||
|
||||
@@ -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': 'গাঢ়',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'गहरा',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '어둡게',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Тёмная',
|
||||
|
||||
@@ -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': '深色',
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user