diff --git a/app/src/components/chat/ChatComposer.tsx b/app/src/components/chat/ChatComposer.tsx index d3b061125..0a62d6ee6 100644 --- a/app/src/components/chat/ChatComposer.tsx +++ b/app/src/components/chat/ChatComposer.tsx @@ -46,6 +46,18 @@ export interface ChatComposerProps { * attach button, hidden file input, and preview strip are not rendered. */ attachmentsEnabled: boolean; + /** + * Whether the voice-mode (mic) button is shown. Defaults to `true` for the + * main chat; surfaces that don't have a voice mode (e.g. the flows Workflow + * Copilot) pass `false` to hide it while still reusing this composer. + */ + micEnabled?: boolean; + /** + * Overrides the textarea placeholder. Defaults to the main chat's + * type-a-message / follow-up hint; surfaces like the Workflow Copilot pass + * their own prompt. + */ + placeholder?: string; /** * Optional nodes stacked above the input box but *outside* its focus * highlight — e.g. the queued-follow-ups strip and the thread-goal editor. @@ -82,6 +94,8 @@ export default function ChatComposer({ maxAttachments, allowedMimeTypes, attachmentsEnabled, + micEnabled = true, + placeholder, headerSlots = [], }: ChatComposerProps) { const { t } = useT(); @@ -264,7 +278,9 @@ export default function ChatComposer({ }} onKeyDown={handleInputKeyDown} onPaste={attachmentsEnabled ? handlePaste : undefined} - placeholder={allowParallelSend ? t('chat.followupHint') : t('chat.typeMessage')} + placeholder={ + placeholder ?? (allowParallelSend ? t('chat.followupHint') : t('chat.typeMessage')) + } rows={1} disabled={textareaDisabled} className="relative z-10 w-full resize-none border-0 bg-transparent py-0.5 px-0.5 text-sm leading-5 whitespace-pre-wrap break-words font-sans text-content placeholder:text-stone-400 dark:placeholder:text-neutral-500 outline-none focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0 overflow-hidden disabled:opacity-50 disabled:cursor-not-allowed" @@ -272,29 +288,31 @@ export default function ChatComposer({ {/* Voice mode */} - + {micEnabled && ( + + )} {/* Send / Stop button — while a turn is in flight and a cancel handler is wired, the Send button becomes a Stop button so generation can diff --git a/app/src/components/flows/FlowListRow.test.tsx b/app/src/components/flows/FlowListRow.test.tsx index bdc6c9dd2..18cbb609e 100644 --- a/app/src/components/flows/FlowListRow.test.tsx +++ b/app/src/components/flows/FlowListRow.test.tsx @@ -3,15 +3,16 @@ * Workflows list page. Asserts the name/status rendering, the * last-run/never-run text (including the localized relative-time strings), * that the toggle/Run/View runs controls call back with the row's `Flow`, - * and that the flow name itself is the "View" affordance that opens the - * read-only Workflow Canvas (issue B5b.1). + * that the flow name itself is the "View" affordance that opens the read-only + * Workflow Canvas (issue B5b.1), and that the overflow menu routes + * Export/Duplicate/Delete. */ import { fireEvent, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { Flow } from '../../services/api/flowsApi'; import { renderWithProviders } from '../../test/test-utils'; -import FlowListRow from './FlowListRow'; +import FlowListRow, { type FlowListRowProps } from './FlowListRow'; function makeFlow(overrides: Partial = {}): Flow { return { @@ -28,243 +29,122 @@ function makeFlow(overrides: Partial = {}): Flow { }; } +/** Render a row with no-op defaults for every callback, overridable per test. */ +function renderRow(overrides: Partial = {}) { + const props: FlowListRowProps = { + flow: makeFlow(), + onToggle: vi.fn(), + onRun: vi.fn(), + onViewRuns: vi.fn(), + onView: vi.fn(), + onExport: vi.fn(), + onDuplicate: vi.fn(), + onDelete: vi.fn(), + ...overrides, + }; + renderWithProviders(); + return props; +} + describe('FlowListRow', () => { it('renders the flow name and an Enabled badge when enabled', () => { - renderWithProviders( - - ); - + renderRow(); expect(screen.getByText('Daily digest')).toBeInTheDocument(); expect(screen.getByTestId('flow-status-flow-1')).toHaveTextContent('Enabled'); }); it('renders a Paused badge when disabled', () => { - renderWithProviders( - - ); - + renderRow({ flow: makeFlow({ enabled: false }) }); expect(screen.getByTestId('flow-status-flow-1')).toHaveTextContent('Paused'); }); it('shows "Never run" when the flow has no last_run_at', () => { - renderWithProviders( - - ); - + renderRow(); expect(screen.getByText('Never run')).toBeInTheDocument(); }); it('shows the capitalized status and "Just now" for a run seconds ago', () => { - renderWithProviders( - - ); - + renderRow({ + flow: makeFlow({ last_run_at: new Date().toISOString(), last_status: 'completed' }), + }); expect(screen.getByText('Completed · Just now')).toBeInTheDocument(); }); it('shows a minutes-ago relative time', () => { const fiveMinAgo = new Date(Date.now() - 5 * 60_000).toISOString(); - renderWithProviders( - - ); - + renderRow({ flow: makeFlow({ last_run_at: fiveMinAgo, last_status: 'completed' }) }); expect(screen.getByText('Completed · 5m ago')).toBeInTheDocument(); }); it('shows an hours-ago relative time', () => { const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60_000).toISOString(); - renderWithProviders( - - ); - + renderRow({ flow: makeFlow({ last_run_at: threeHoursAgo, last_status: 'failed' }) }); expect(screen.getByText('Failed · 3h ago')).toBeInTheDocument(); }); it('shows a days-ago relative time', () => { const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60_000).toISOString(); - renderWithProviders( - - ); - + renderRow({ flow: makeFlow({ last_run_at: twoDaysAgo, last_status: 'pending_approval' }) }); expect(screen.getByText('Pending_approval · 2d ago')).toBeInTheDocument(); }); it('calls onToggle with the flow when the switch is clicked', () => { - const onToggle = vi.fn(); - renderWithProviders( - - ); - + const { onToggle } = renderRow(); fireEvent.click(screen.getByTestId('flow-toggle-flow-1')); - expect(onToggle).toHaveBeenCalledWith(makeFlow()); }); it('calls onRun with the flow when the Run button is clicked', () => { - const onRun = vi.fn(); - renderWithProviders( - - ); - + const { onRun } = renderRow(); fireEvent.click(screen.getByTestId('flow-run-flow-1')); - expect(onRun).toHaveBeenCalledWith(makeFlow()); }); it('renders a "View runs" control and calls onViewRuns with the flow when clicked', () => { - const onViewRuns = vi.fn(); - renderWithProviders( - - ); - + const { onViewRuns } = renderRow(); const viewRunsButton = screen.getByTestId('flow-view-runs-flow-1'); expect(viewRunsButton).toHaveTextContent('View runs'); fireEvent.click(viewRunsButton); - expect(onViewRuns).toHaveBeenCalledWith(makeFlow()); }); it('renders the flow name as a "View" affordance and calls onView with the flow when clicked', () => { - const onView = vi.fn(); - renderWithProviders( - - ); - + const { onView } = renderRow(); const viewButton = screen.getByTestId('flow-view-flow-1'); expect(viewButton).toHaveTextContent('Daily digest'); fireEvent.click(viewButton); - expect(onView).toHaveBeenCalledWith(makeFlow()); }); it('shows the running label and disables Run while busy', () => { - renderWithProviders( - - ); - + renderRow({ busy: 'run' }); const runButton = screen.getByTestId('flow-run-flow-1'); expect(runButton).toHaveTextContent('Running…'); expect(runButton).toBeDisabled(); }); it('disables the toggle while busy=toggle', () => { - renderWithProviders( - - ); - + renderRow({ busy: 'toggle' }); expect(screen.getByTestId('flow-toggle-flow-1')).toBeDisabled(); }); - it('renders an Export control and calls onExport with the flow when clicked', () => { - const onExport = vi.fn(); - renderWithProviders( - - ); - - const exportButton = screen.getByTestId('flow-export-flow-1'); - expect(exportButton).toHaveTextContent('Export'); - fireEvent.click(exportButton); + it('routes Export / Duplicate / Delete through the overflow menu', () => { + const { onExport, onDuplicate, onDelete } = renderRow(); + // The secondary actions live behind the "⋯" menu, not the flat button row. + expect(screen.queryByTestId('flow-export-flow-1')).not.toBeInTheDocument(); + fireEvent.click(screen.getByTestId('flow-menu-flow-1')); + const exportItem = screen.getByTestId('flow-export-flow-1'); + expect(exportItem).toHaveTextContent('Export'); + fireEvent.click(exportItem); expect(onExport).toHaveBeenCalledWith(makeFlow()); + + fireEvent.click(screen.getByTestId('flow-menu-flow-1')); + fireEvent.click(screen.getByTestId('flow-duplicate-flow-1')); + expect(onDuplicate).toHaveBeenCalledWith(makeFlow()); + + fireEvent.click(screen.getByTestId('flow-menu-flow-1')); + fireEvent.click(screen.getByTestId('flow-delete-flow-1')); + expect(onDelete).toHaveBeenCalledWith(makeFlow()); }); }); diff --git a/app/src/components/flows/FlowListRow.tsx b/app/src/components/flows/FlowListRow.tsx index 4c0647d63..5d5b672a0 100644 --- a/app/src/components/flows/FlowListRow.tsx +++ b/app/src/components/flows/FlowListRow.tsx @@ -24,6 +24,7 @@ import { useT } from '../../lib/i18n/I18nContext'; import type { Flow } from '../../services/api/flowsApi'; import SettingsSwitch from '../settings/controls/SettingsSwitch'; import Button from '../ui/Button'; +import FlowRowMenu from './FlowRowMenu'; /** Which of this row's actions currently has a request in flight, if any. */ export type FlowListRowBusy = 'toggle' | 'run' | null; @@ -40,6 +41,10 @@ export interface FlowListRowProps { onView: (flow: Flow) => void; /** Downloads this flow's `WorkflowGraph` as a JSON file (Phase 4d export). */ onExport: (flow: Flow) => void; + /** Creates a disabled copy of this flow (`flows_duplicate`). */ + onDuplicate: (flow: Flow) => void; + /** Permanently deletes this flow after a confirm (`flows_delete`). */ + onDelete: (flow: Flow) => void; busy?: FlowListRowBusy; } @@ -75,6 +80,8 @@ const FlowListRow = ({ onViewRuns, onView, onExport, + onDuplicate, + onDelete, busy = null, }: FlowListRowProps) => { const { t } = useT(); @@ -103,26 +110,29 @@ const FlowListRow = ({
{lastRunLabel}
- - {flow.enabled ? t('flows.list.enabled') : t('flows.list.paused')} - + {/* Enable/disable control paired with its state label as a single group, + so the toggle is self-describing (the bare switch was ambiguous) and + state isn't split between a top-right badge and a bottom-left switch. */} +
+ + {flow.enabled ? t('flows.list.enabled') : t('flows.list.paused')} + + onToggle(flow)} + /> +
- onToggle(flow)} - /> - +
+ onExport(flow), + testId: `flow-export-${flow.id}`, + }, + { + key: 'duplicate', + label: t('flows.list.duplicate'), + onSelect: () => onDuplicate(flow), + testId: `flow-duplicate-${flow.id}`, + }, + { + key: 'delete', + label: t('flows.list.delete'), + onSelect: () => onDelete(flow), + danger: true, + testId: `flow-delete-${flow.id}`, + }, + ]} + /> +
); diff --git a/app/src/components/flows/FlowRowMenu.tsx b/app/src/components/flows/FlowRowMenu.tsx new file mode 100644 index 000000000..c18f84594 --- /dev/null +++ b/app/src/components/flows/FlowRowMenu.tsx @@ -0,0 +1,98 @@ +/** + * FlowRowMenu — the "⋯" overflow menu on a Workflows list row. Holds the + * secondary/rare actions (Export, Duplicate, Delete) so the row's primary + * actions (View runs, Run) stay uncluttered, and keeps the destructive Delete + * out of the flat button row. Closes on Escape, outside click, or item select. + * + * Presentational + local open state only — each item calls back up to + * `FlowListRow`, which routes to `FlowsPage`'s handlers. + */ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useEscapeKey } from '../../hooks/useEscapeKey'; +import { useT } from '../../lib/i18n/I18nContext'; + +export interface FlowRowMenuItem { + key: string; + label: string; + onSelect: () => void; + /** Renders the item in the destructive coral tone (e.g. Delete). */ + danger?: boolean; + testId?: string; +} + +export interface FlowRowMenuProps { + items: FlowRowMenuItem[]; + /** Suffixed onto test ids so multiple rows stay addressable. */ + rowId: string; +} + +function KebabIcon() { + return ( + + ); +} + +export default function FlowRowMenu({ items, rowId }: FlowRowMenuProps) { + const { t } = useT(); + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + + useEscapeKey(() => setOpen(false), open); + + // Close on any click outside the menu container. + useEffect(() => { + if (!open) return; + const onPointerDown = (event: MouseEvent) => { + if (!containerRef.current?.contains(event.target as Node | null)) setOpen(false); + }; + document.addEventListener('mousedown', onPointerDown); + return () => document.removeEventListener('mousedown', onPointerDown); + }, [open]); + + const select = useCallback((onSelect: () => void) => { + setOpen(false); + onSelect(); + }, []); + + return ( +
+ + + {open && ( +
+ {items.map(item => ( + + ))} +
+ )} +
+ ); +} diff --git a/app/src/components/flows/FlowRunInspectorDrawer.tsx b/app/src/components/flows/FlowRunInspectorDrawer.tsx index 5f646fed3..43bb06552 100644 --- a/app/src/components/flows/FlowRunInspectorDrawer.tsx +++ b/app/src/components/flows/FlowRunInspectorDrawer.tsx @@ -147,6 +147,23 @@ function StepRow({ )} + {/* Null-resolution diagnostics: each config `=`-expression that resolved + to null during this step (a wiring smell, not a hard failure). */} + {step.diagnostics && step.diagnostics.length > 0 && ( +
+
{t('flowRuns.inspector.diagnosticsTitle')}
+
    + {step.diagnostics.map((diag, diagIdx) => ( +
  • + {diag.location} ← {diag.expression}{' '} + {t('flowRuns.inspector.diagnosticResolvedNull')} +
  • + ))} +
+
+ )} {items.length > 0 && (
diff --git a/app/src/components/flows/FlowRunsSidebar.tsx b/app/src/components/flows/FlowRunsSidebar.tsx new file mode 100644 index 000000000..83d474f42 --- /dev/null +++ b/app/src/components/flows/FlowRunsSidebar.tsx @@ -0,0 +1,151 @@ +/** + * FlowRunsSidebar — the workflow's recent runs, projected into the root shell's + * dynamic left sidebar while a flow is open on the canvas (`/flows/:id`). A + * compact, scannable run history (status dot + status + relative time); clicking + * a run opens the full {@link FlowRunInspectorDrawer} (which polls its live + * status). One-shot fetch via `listFlowRuns` with a manual refresh — the engine + * emits no list-level socket events, so this mirrors `FlowRunsDrawer`'s model. + * + * Rendered by `FlowCanvasPage` inside a `SidebarContent` portal, so it only + * appears for a persisted flow (a draft has no runs yet). + */ +import createDebug from 'debug'; +import { useCallback, useEffect, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { type FlowRun, listFlowRuns } from '../../services/api/flowsApi'; +import { CenteredLoadingState, ErrorBanner } from '../ui/LoadingState'; +import { + FLOW_RUN_STATUS_ACCENT, + FLOW_RUN_STATUS_DOT, + FLOW_RUN_STATUS_KEY, + FlowRunInspectorDrawer, +} from './FlowRunInspectorDrawer'; + +/** Matches `useT()`'s `t` signature. */ +type TFn = (key: string, fallback?: string) => string; + +function relativeTime(iso: string, t: TFn): string { + const ms = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(ms / 60000); + if (mins < 1) return t('flows.list.justNow'); + if (mins < 60) return t('flows.list.minutesAgo').replace('{count}', String(mins)); + const hrs = Math.floor(mins / 60); + if (hrs < 24) return t('flows.list.hoursAgo').replace('{count}', String(hrs)); + const days = Math.floor(hrs / 24); + return t('flows.list.daysAgo').replace('{count}', String(days)); +} + +const log = createDebug('app:flows:runs-sidebar'); + +export interface FlowRunsSidebarProps { + flowId: string; +} + +export default function FlowRunsSidebar({ flowId }: FlowRunsSidebarProps) { + const { t } = useT(); + const [runs, setRuns] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedRunId, setSelectedRunId] = useState(null); + + const load = useCallback(async () => { + log('loading runs for flow=%s', flowId); + setLoading(true); + setError(null); + try { + const result = await listFlowRuns(flowId); + setRuns(result); + log('loaded %d runs', result.length); + } catch (err) { + log('load failed: %o', err); + setError(t('flows.runs.loadError')); + } finally { + setLoading(false); + } + }, [flowId, t]); + + useEffect(() => { + void load(); + }, [load]); + + return ( +
+
+ + {t('flows.runs.sidebarTitle')} + + +
+ +
+ {loading && runs.length === 0 && } + + {error && ( +
+ +
+ )} + + {!loading && !error && runs.length === 0 && ( +

+ {t('flows.runs.empty')} +

+ )} + +
    + {runs.map(run => ( +
  • + +
  • + ))} +
+
+ + setSelectedRunId(null)} /> +
+ ); +} diff --git a/app/src/components/flows/NewWorkflowModal.test.tsx b/app/src/components/flows/NewWorkflowModal.test.tsx index 512cb6a36..457267d77 100644 --- a/app/src/components/flows/NewWorkflowModal.test.tsx +++ b/app/src/components/flows/NewWorkflowModal.test.tsx @@ -6,7 +6,6 @@ * trigger node and no edges, then navigates into the new flow's canvas. * - "From a template" reveals the gallery; picking a card calls `flows_create` * with that template's exact graph and navigates into the canvas. - * - "Describe it" invokes the `onDescribe` hand-off (Chat). * - A `flows_create` rejection surfaces the localized error banner. * * `react-router-dom`'s `useNavigate` and `flowsApi.createFlow` are mocked so the @@ -29,9 +28,8 @@ vi.mock('../../services/api/flowsApi', () => ({ createFlow })); function renderModal() { const onClose = vi.fn(); - const onDescribe = vi.fn(); - render(); - return { onClose, onDescribe }; + render(); + return { onClose }; } describe('NewWorkflowModal', () => { @@ -72,13 +70,9 @@ describe('NewWorkflowModal', () => { await waitFor(() => expect(navigate).toHaveBeenCalledWith('/flows/flow-tpl')); }); - it('describe it triggers the onDescribe hand-off and does not create a flow', () => { - const { onDescribe } = renderModal(); - - fireEvent.click(screen.getByTestId('new-workflow-describe')); - - expect(onDescribe).toHaveBeenCalledTimes(1); - expect(createFlow).not.toHaveBeenCalled(); + it('does not offer a redundant "Describe it" option (the prompt bar covers it)', () => { + renderModal(); + expect(screen.queryByTestId('new-workflow-describe')).not.toBeInTheDocument(); }); it('can navigate from the gallery back to the chooser', () => { diff --git a/app/src/components/flows/NewWorkflowModal.tsx b/app/src/components/flows/NewWorkflowModal.tsx index 3e78c9ca5..231258796 100644 --- a/app/src/components/flows/NewWorkflowModal.tsx +++ b/app/src/components/flows/NewWorkflowModal.tsx @@ -6,8 +6,10 @@ * `manual` trigger, then open it in the editable canvas. * - **From a template** — switch to the {@link FlowTemplateGallery} view; * picking a card creates a flow from that template's graph and opens it. - * - **Describe it** — interim: seed the intent in Chat so the user can invoke - * `propose_workflow`. Superseded by the Phase 5 in-place prompt bar. + * + * The old "Describe it" option was dropped: the Flows page already shows the + * `WorkflowPromptBar` at all times (hero when empty, compact otherwise), so a + * modal option that only re-focused that same bar was redundant. * * Create + navigate is delegated to {@link useCreateFlow}; this component only * owns which view is showing and assembles the name/graph for each path. @@ -24,12 +26,6 @@ import { BLANK_FLOW_KEY, useCreateFlow } from './useCreateFlow'; interface NewWorkflowModalProps { onClose: () => void; - /** - * "Describe it" handler — navigates to Chat with the workflow-building intent. - * TODO(phase-5): replace this Chat hand-off with the in-place prompt bar that - * runs `propose_workflow` directly on the canvas. - */ - onDescribe: () => void; } type View = 'chooser' | 'gallery'; @@ -63,7 +59,7 @@ function ChooserOption({ const log = createDebug('app:flows:new'); -export default function NewWorkflowModal({ onClose, onDescribe }: NewWorkflowModalProps) { +export default function NewWorkflowModal({ onClose }: NewWorkflowModalProps) { const { t } = useT(); const [view, setView] = useState('chooser'); const { create, busyKey, error, clearError } = useCreateFlow(); @@ -128,13 +124,6 @@ export default function NewWorkflowModal({ onClose, onDescribe }: NewWorkflowMod disabled={busy} onClick={openGallery} /> - ) : ( <> diff --git a/app/src/components/flows/WorkflowCopilotPanel.test.tsx b/app/src/components/flows/WorkflowCopilotPanel.test.tsx index f9a07b261..b1d01608e 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.test.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.test.tsx @@ -10,6 +10,7 @@ vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) = const hookState = vi.hoisted(() => ({ sending: false, proposal: null as WorkflowProposal | null, + messages: [] as Array<{ id: string; content: string; sender: 'user' | 'agent' }>, error: null as string | null, send: vi.fn(), clearProposal: vi.fn(), @@ -38,6 +39,7 @@ describe('WorkflowCopilotPanel', () => { beforeEach(() => { hookState.sending = false; hookState.proposal = null; + hookState.messages = []; hookState.error = null; hookState.send = vi.fn().mockResolvedValue(undefined); hookState.clearProposal = vi.fn(); @@ -53,10 +55,12 @@ describe('WorkflowCopilotPanel', () => { onClose={vi.fn()} /> ); - fireEvent.change(screen.getByTestId('workflow-copilot-input'), { + // The copilot now uses the shared ChatComposer (textarea by placeholder, + // `send-message-button` for send). + fireEvent.change(screen.getByPlaceholderText('flows.copilot.placeholder'), { target: { value: 'add a Slack notification on failure' }, }); - fireEvent.click(screen.getByTestId('workflow-copilot-send')); + fireEvent.click(screen.getByTestId('send-message-button')); expect(hookState.send).toHaveBeenCalledTimes(1); const arg = hookState.send.mock.calls[0][0]; @@ -64,6 +68,28 @@ describe('WorkflowCopilotPanel', () => { expect(arg.prompt).toContain(JSON.stringify(baseGraph)); }); + it('renders the conversation transcript (user + agent turns)', () => { + hookState.messages = [ + { id: 'm1', content: 'add a Slack step', sender: 'user' }, + { id: 'm2', content: 'Done — proposed a Slack notification.', sender: 'agent' }, + ]; + render( + + ); + expect(screen.getByTestId('workflow-copilot-user')).toHaveTextContent('add a Slack step'); + expect(screen.getByTestId('workflow-copilot-agent')).toHaveTextContent( + 'Done — proposed a Slack notification.' + ); + // With a transcript present, the empty-state hint is gone. + expect(screen.queryByTestId('workflow-copilot-empty')).not.toBeInTheDocument(); + }); + it('surfaces a new proposal to the host and shows the added/removed diff', () => { const onProposal = vi.fn(); // proposed drops "b" and adds "c" vs. base [a, b]. diff --git a/app/src/components/flows/WorkflowCopilotPanel.tsx b/app/src/components/flows/WorkflowCopilotPanel.tsx index c6503f80d..1053d9003 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.tsx @@ -3,9 +3,15 @@ * `workflow_builder` specialist, docked on the editable canvas. The user asks * for changes ("add a Slack notification on failure", "make the schedule * weekdays only"); each turn injects the CURRENT draft graph as context and the - * agent returns a `revise_workflow` proposal. The panel surfaces the proposal's - * node-level diff and hands Accept/Reject up to the host, which applies it to - * the local draft overlay — a `revise_workflow` loop. + * agent returns a `revise_workflow` proposal (and can now discover + connect the + * Composio apps a step needs). The panel renders the full conversation + * transcript, surfaces each proposal's node-level diff, and hands Accept/Reject + * up to the host, which applies it to the local draft overlay. + * + * Chat UI parity: the composer is the same {@link ChatComposer} the main chat + * windows use (mic/attachments off here), and turns render as bubbles via the + * shared {@link BubbleMarkdown}, so the copilot reads like a real chat rather + * than a one-shot form. * * Invariant: the copilot only PROPOSES. Accept applies to the UNSAVED local * draft (no `flows_update`); persistence stays behind the canvas's own Save. @@ -21,12 +27,19 @@ import { type RepairPromptContext, } from '../../lib/flows/workflowBuilderPrompt'; import { useT } from '../../lib/i18n/I18nContext'; +import { BubbleMarkdown } from '../../pages/conversations/components/AgentMessageBubble'; import type { WorkflowProposal } from '../../store/chatRuntimeSlice'; +import ChatComposer from '../chat/ChatComposer'; import Button from '../ui/Button'; interface Props { /** The current draft graph, injected as context for each revise turn. */ graph: WorkflowGraph; + /** + * The saved flow's id (or `null`/absent for an unsaved draft), injected into + * revise turns so the agent can `run_workflow` it to test — with confirmation. + */ + flowId?: string | null; /** * Fires when the agent returns a fresh proposal, so the host can enter its * diff-preview overlay. The host computes/holds the preview; this panel only @@ -44,20 +57,43 @@ interface Props { * repair turn once on mount so the copilot opens already diagnosing. */ repairSeed?: RepairPromptContext | null; + /** + * The workflow's persisted copilot thread id (from the per-flow cache), so + * reopening the panel resumes the same conversation instead of starting fresh. + */ + seedThreadId?: string | null; + /** Reports the live thread id up so the host can persist it per workflow. */ + onThreadIdChange?: (threadId: string | null) => void; } export default function WorkflowCopilotPanel({ graph, + flowId = null, onProposal, onAccept, onReject, onClose, repairSeed = null, + seedThreadId = null, + onThreadIdChange, }: Props) { const { t } = useT(); - const { sending, proposal, error, send, clearProposal } = useWorkflowBuilderChat(); + const { threadId, sending, proposal, messages, error, send, clearProposal } = + useWorkflowBuilderChat(seedThreadId); const [text, setText] = useState(''); + // Report the (lazily-created) thread id up so the host persists it per flow — + // reopening the copilot then resumes this same conversation. + useEffect(() => { + onThreadIdChange?.(threadId); + }, [threadId, onThreadIdChange]); + + // ChatComposer plumbing (mic/attachments are off, so most refs are inert). + const textInputRef = useRef(null); + const fileInputRef = useRef(null); + const isComposingTextRef = useRef(false); + const scrollRef = useRef(null); + // Surface each NEW proposal to the host exactly once (enter preview overlay). const lastSurfacedRef = useRef(null); useEffect(() => { @@ -78,16 +114,25 @@ export default function WorkflowCopilotPanel({ }); }, [repairSeed, send, t]); - const submit = useCallback(async () => { - const trimmed = text.trim(); - if (!trimmed || sending) return; - await send({ displayText: trimmed, prompt: buildRevisePrompt(trimmed, graph) }); - setText(''); - }, [text, sending, send, graph]); + // Keep the transcript pinned to the newest message / thinking indicator. + // `scrollTo` is optional-chained: jsdom (tests) doesn't implement it. + useEffect(() => { + scrollRef.current?.scrollTo?.({ top: scrollRef.current.scrollHeight }); + }, [messages, sending, proposal]); - const onKeyDown = useCallback( + const submit = useCallback( + async (raw?: string) => { + const trimmed = (raw ?? text).trim(); + if (!trimmed || sending) return; + setText(''); + await send({ displayText: trimmed, prompt: buildRevisePrompt(trimmed, graph, flowId) }); + }, + [text, sending, send, graph, flowId] + ); + + const handleInputKeyDown = useCallback( (event: React.KeyboardEvent) => { - if (event.key === 'Enter' && !event.shiftKey) { + if (event.key === 'Enter' && !event.shiftKey && !isComposingTextRef.current) { event.preventDefault(); void submit(); } @@ -95,6 +140,9 @@ export default function WorkflowCopilotPanel({ [submit] ); + const noopAttach = useCallback(async () => {}, []); + const noop = useCallback(() => {}, []); + const accept = useCallback(() => { if (!proposal) return; onAccept(proposal); @@ -109,6 +157,7 @@ export default function WorkflowCopilotPanel({ }, [onReject, clearProposal]); const diff = proposal ? diffGraphs(graph, proposal.graph as WorkflowGraph) : null; + const isEmpty = messages.length === 0 && !proposal && !sending && !error; return (