Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 2 additions & 21 deletions app/src/components/chat/WorkflowProposalCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ function proposal(partial: Partial<WorkflowProposal> = {}): WorkflowProposal {
return {
name: 'Daily standup summary',
graph: { nodes: [], edges: [] },
requireApproval: true,
summary: {
trigger: 'schedule: 0 9 * * *',
steps: [
Expand Down Expand Up @@ -70,9 +69,7 @@ describe('WorkflowProposalCard', () => {
const p = proposal();
render(<WorkflowProposalCard threadId="t1" proposal={p} />);
fireEvent.click(screen.getByText('chat.flowProposal.save'));
await waitFor(() =>
expect(mockCreateFlow).toHaveBeenCalledWith(p.name, p.graph, p.requireApproval)
);
await waitFor(() => expect(mockCreateFlow).toHaveBeenCalledWith(p.name, p.graph));
expect(mockDispatch).toHaveBeenCalledTimes(1);
});

Expand Down Expand Up @@ -107,11 +104,7 @@ describe('WorkflowProposalCard', () => {
expect(mockNavigate).toHaveBeenCalledTimes(1);
const [route, opts] = mockNavigate.mock.calls[0];
expect(route).toBe('/flows/draft');
expect(opts.state).toEqual({
name: p.name,
graph: p.graph,
requireApproval: p.requireApproval,
});
expect(opts.state).toEqual({ name: p.name, graph: p.graph });

// The single persistence gate is untouched — no create/update, and the
// proposal is left intact in the thread (not dismissed).
Expand All @@ -136,16 +129,4 @@ describe('WorkflowProposalCard', () => {
);
expect(screen.getByText('chat.flowProposal.noSteps')).toBeInTheDocument();
});

it('shows the require-approval hint only when requireApproval is true', () => {
const { rerender } = render(
<WorkflowProposalCard threadId="t1" proposal={proposal({ requireApproval: true })} />
);
expect(screen.getByText('chat.flowProposal.requireApprovalHint')).toBeInTheDocument();

rerender(
<WorkflowProposalCard threadId="t1" proposal={proposal({ requireApproval: false })} />
);
expect(screen.queryByText('chat.flowProposal.requireApprovalHint')).not.toBeInTheDocument();
});
});
14 changes: 2 additions & 12 deletions app/src/components/chat/WorkflowProposalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,7 @@ export const WorkflowProposalCard: React.FC<Props> = ({ threadId, proposal, onSa
graph.nodes.length,
graph.edges.length
);
const draft: FlowCanvasDraftState = {
name: proposal.name,
graph,
requireApproval: proposal.requireApproval,
};
const draft: FlowCanvasDraftState = { name: proposal.name, graph };
navigate(FLOW_CANVAS_DRAFT_ROUTE, { state: draft });
};

Expand All @@ -81,7 +77,7 @@ export const WorkflowProposalCard: React.FC<Props> = ({ threadId, proposal, onSa
setSaving(true);
setErrorMsg(null);
try {
await createFlow(proposal.name, proposal.graph, proposal.requireApproval);
await createFlow(proposal.name, proposal.graph);
dispatch(clearWorkflowProposalForThread({ threadId }));
onSaved?.();
} catch (e) {
Expand Down Expand Up @@ -145,12 +141,6 @@ export const WorkflowProposalCard: React.FC<Props> = ({ threadId, proposal, onSa
)}
</div>

{proposal.requireApproval && (
<p className="mt-2 text-xs text-content-faint">
{t('chat.flowProposal.requireApprovalHint')}
</p>
)}

{errorMsg && <p className="mt-2 text-xs text-coral">⚠ {errorMsg}</p>}

<div className="mt-3 flex flex-wrap items-center gap-2">
Expand Down
1 change: 0 additions & 1 deletion app/src/components/flows/FlowListRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ function makeFlow(overrides: Partial<Flow> = {}): Flow {
updated_at: '2026-01-01T00:00:00Z',
last_run_at: null,
last_status: null,
require_approval: false,
...overrides,
};
}
Expand Down
28 changes: 9 additions & 19 deletions app/src/components/flows/FlowRunInspectorDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
* ----------------------------------
*
* Right-side drawer showing a single durable `tinyflows` run's status + step
* timeline, opened from the "View run" action on {@link FlowApprovalCard}.
* Drawer chrome mirrors `features/conversations/components/SubagentDrawer.tsx`
* (fixed overlay + backdrop-click-to-close + Escape-to-close) so it renders
* as a fixed overlay regardless of where the parent mounts it in the DOM.
* timeline, opened from the "View run" action on a flow's run history
* (`FlowRunsDrawer` / `FlowRunsSidebar`). Drawer chrome mirrors
* `features/conversations/components/SubagentDrawer.tsx` (fixed overlay +
* backdrop-click-to-close + Escape-to-close) so it renders as a fixed
* overlay regardless of where the parent mounts it in the DOM.
*
* Data comes from {@link useFlowRunPoller}, which polls
* `openhuman.flows_get_run` every 2s until the run reaches a terminal status
* (`completed`/`failed`) — `pending_approval` keeps polling since the run can
* still be resumed elsewhere.
* (`completed`/`failed`) — `pending_approval` keeps polling since a node-level
* checkpoint gate elsewhere in the graph could still resolve it. There is no
* approval UI here (the flows human-in-the-loop toggle was removed); this
* status just displays like any other non-terminal state.
*
* `FlowRunStep` is lean by design (`node_id` + `output` + optional `port`
* only — no per-step status/timing), so each step renders as a plain label
Expand Down Expand Up @@ -247,7 +250,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">
Expand Down Expand Up @@ -358,18 +360,6 @@ 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)
)}
</div>
)}

{/* Steps timeline */}
<div>
<h3 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-content-muted">
Expand Down
1 change: 0 additions & 1 deletion app/src/components/flows/SuggestedWorkflows.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,6 @@ describe('SuggestedWorkflows', () => {
hookState.proposal = {
name: 'Auto-file receipts',
graph: { nodes: [], edges: [] },
requireApproval: true,
summary: { trigger: 'app_event', steps: [] },
} as unknown as WorkflowProposal;

Expand Down
7 changes: 1 addition & 6 deletions app/src/components/flows/WorkflowCopilotPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,7 @@ function graph(ids: string[]): WorkflowGraph {
}

function proposalWith(ids: string[]): WorkflowProposal {
return {
name: 'Revised flow',
graph: graph(ids),
requireApproval: true,
summary: { trigger: 'manual', steps: [] },
};
return { name: 'Revised flow', graph: graph(ids), summary: { trigger: 'manual', steps: [] } };
}

const baseGraph = graph(['a', 'b']);
Expand Down
11 changes: 3 additions & 8 deletions app/src/components/flows/WorkflowPromptBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,30 +30,25 @@ describe('WorkflowPromptBar', () => {

await waitFor(() => expect(navigateMock).toHaveBeenCalledTimes(1));
expect(createFlowMock).toHaveBeenCalledTimes(1);
const [name, graph, requireApproval] = createFlowMock.mock.calls[0];
const [name, graph] = createFlowMock.mock.calls[0];
expect(name).toBe('digest my Slack every morning');
// The created flow is the standard blank graph (single manual trigger).
expect(graph.nodes).toHaveLength(1);
expect(graph.nodes[0].kind).toBe('trigger');
// Parity with the proposal-card path: prompt-authored flows must default
// to requiring approval, matching WorkflowProposalCard's default.
expect(requireApproval).toBe(true);
expect(navigateMock).toHaveBeenCalledWith('/flows/flow-1', {
state: { copilotBuild: { description: 'digest my Slack every morning' } },
});
});

it('defaults requireApproval to true so outbound side-effects need explicit approval', async () => {
it('calls createFlow with just name and graph (no approval-gate arg)', async () => {
render(<WorkflowPromptBar />);
fireEvent.change(screen.getByTestId('workflow-prompt-input'), {
target: { value: 'auto-reply to every gmail thread' },
});
fireEvent.click(screen.getByTestId('workflow-prompt-submit'));

await waitFor(() => expect(createFlowMock).toHaveBeenCalledTimes(1));
// Third positional arg must be `true` — flows_create's server default is
// `false`, so omitting it would silently disarm the approval gate.
expect(createFlowMock.mock.calls[0][2]).toBe(true);
expect(createFlowMock.mock.calls[0]).toHaveLength(2);
});

it('submits on Enter (Shift+Enter reserved for newlines)', async () => {
Expand Down
6 changes: 1 addition & 5 deletions app/src/components/flows/WorkflowPromptBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,9 @@ export default function WorkflowPromptBar({ variant = 'compact', autoFocus = fal
const name = deriveWorkflowName(trimmed, t('flows.page.newWorkflow'));
log('submit: creating flow name=%s', name);
try {
// Safe default: prompt-authored flows require approval so outbound
// Slack/Gmail/HTTP/code nodes cannot fire unattended. Omitting this
// arg would fall back to the server default of `false`.
const flow = await createFlow(
name,
createBlankWorkflowGraph(name, t('flows.nodeKind.trigger')),
true
createBlankWorkflowGraph(name, t('flows.nodeKind.trigger'))
);
log('submit: created id=%s — opening canvas with build seed', flow.id);
navigate(`/flows/${flow.id}`, { state: { copilotBuild: { description: trimmed } } });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
*
* 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`.
* `pending_approval` renders like any other non-terminal status (no special
* approval banner — that concept was removed); 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
Expand Down Expand Up @@ -99,19 +100,14 @@ describe('FlowRunInspectorDrawer', () => {
expect(screen.getByTestId('flow-run-inspector-error')).toHaveTextContent('network down');
});

it('shows the pending-approvals banner when status is pending_approval', () => {
it('renders a pending_approval run like any other non-terminal status, with no approval banner', () => {
useFlowRunPoller.mockReturnValue({
run: makeRun({ status: 'pending_approval', pending_approvals: ['node-a', 'node-b'] }),
loading: false,
error: null,
});
renderDrawer('thread-1', vi.fn());
expect(screen.getByTestId('flow-run-pending-approvals-banner')).toHaveTextContent('2');
});

it('does not show the pending-approvals banner for a running run', () => {
useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null });
renderDrawer('thread-1', vi.fn());
expect(screen.getByTestId('flow-run-status-pill')).toBeInTheDocument();
expect(screen.queryByTestId('flow-run-pending-approvals-banner')).not.toBeInTheDocument();
});

Expand Down
Loading
Loading