mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
571 lines
26 KiB
TypeScript
571 lines
26 KiB
TypeScript
/**
|
|
* WorkflowCopilotPanel (Phase 5c) — a side-panel chat bound to the
|
|
* `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 (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 copilot renders its transcript through the SAME
|
|
* {@link ChatThreadView} the home composer chat uses — message bubbles,
|
|
* past-turn insights, the shared tool timeline + sub-agent drawer, and the
|
|
* streaming / interrupted / parallel previews — driven by this copilot's
|
|
* DEDICATED thread. `flows_build` streams the `workflow_builder` turn onto
|
|
* that thread via the global `ChatRuntimeProvider` (Phase B), exactly as a
|
|
* normal chat turn streams, so the copilot reads like the real chat rather
|
|
* than a bespoke transcript. This panel keeps only the authoring concerns:
|
|
* the {@link ChatComposer} footer (mic/attachments off), the seed auto-sends,
|
|
* and the proposal-preview + capped cards pinned above the composer.
|
|
*
|
|
* Invariant: the copilot only PROPOSES — the agent turn itself never
|
|
* persists. Accept applies the proposal to the local draft AND immediately
|
|
* saves it (review + save in one click) via the host's `onAccept`, which
|
|
* awaits the host's own persistence call; the panel shows a saving state
|
|
* meanwhile and, if the save fails, leaves the proposal visible for retry
|
|
* rather than silently discarding it. Reject remains local-only (revert the
|
|
* overlay, no persistence call).
|
|
*/
|
|
import createDebug from 'debug';
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
import { ChatThreadView } from '../../features/conversations/components/ChatThreadView';
|
|
import { useWorkflowBuilderChat } from '../../hooks/useWorkflowBuilderChat';
|
|
import { diffGraphs } from '../../lib/flows/graphDiff';
|
|
import type { WorkflowGraph } from '../../lib/flows/types';
|
|
import { useT } from '../../lib/i18n/I18nContext';
|
|
import type { WorkflowProposal } from '../../store/chatRuntimeSlice';
|
|
import ChatComposer from '../chat/ChatComposer';
|
|
import Button from '../ui/Button';
|
|
|
|
const log = createDebug('app:flows:copilot-panel');
|
|
|
|
/**
|
|
* Context for a repair turn opened from a failed run's inspector ("Fix with
|
|
* agent"). Maps directly onto a `repair`-mode builder request.
|
|
*/
|
|
export interface RepairPromptContext {
|
|
/** The failed run id (== thread_id) so the agent can `get_flow_run` it. */
|
|
runId: string;
|
|
/** The run-level error message, if any. */
|
|
error?: string | null;
|
|
/** Node ids that failed / are implicated, if known. */
|
|
failingNodeIds?: string[];
|
|
/** The flow's current graph, injected so the fix builds on the real draft. */
|
|
graph: WorkflowGraph;
|
|
}
|
|
|
|
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
|
|
* reflects it.
|
|
*/
|
|
onProposal: (proposal: WorkflowProposal) => void;
|
|
/**
|
|
* Accept the pending proposal (host applies it to the local draft AND
|
|
* persists it — "accept" is now review + save in one step). May return a
|
|
* promise the panel awaits to show a saving state; a rejected promise
|
|
* leaves the proposal visible so the user can retry.
|
|
*/
|
|
onAccept: (proposal: WorkflowProposal) => void | Promise<void>;
|
|
/** Reject the pending proposal (host reverts the overlay). */
|
|
onReject: () => void;
|
|
/** Close the panel. */
|
|
onClose: () => void;
|
|
/**
|
|
* Optional repair seed (from a failed run's "Fix with agent") — auto-sends a
|
|
* repair turn once on mount so the copilot opens already diagnosing.
|
|
*/
|
|
repairSeed?: RepairPromptContext | null;
|
|
/**
|
|
* Optional build seed (from the Flows prompt bar's instant-create path) —
|
|
* auto-sends the user's workflow description once on mount so the copilot
|
|
* opens already building it against the just-created blank flow.
|
|
*/
|
|
buildSeed?: { description: string } | null;
|
|
/**
|
|
* Fires once the build seed has been dispatched, so the host can clear the
|
|
* ephemeral route seed (`location.state.copilotBuild`). The in-mount
|
|
* `buildSentRef` guard only protects the current mount; closing and reopening
|
|
* the panel remounts it and resets that ref, so without clearing the route
|
|
* seed a remount would re-fire the same `build` turn (issue #4597).
|
|
*/
|
|
onBuildSeedConsumed?: () => void;
|
|
/**
|
|
* Optional prefill seed (from the Suggested Workflows "Build this" action)
|
|
* — populates the composer's input with the suggestion's `build_prompt`
|
|
* once on mount WITHOUT sending it; the user reviews/edits the text and
|
|
* presses Send themselves. Distinct from `buildSeed`, which auto-sends.
|
|
*
|
|
* `mode` carries the builder mode the FIRST Send after this prefill must
|
|
* use — `'build'` for a Suggested Workflows seed, matching the
|
|
* already-created blank flow's `BuildMode::Build` contract — instead of
|
|
* `submit`'s normal `'revise'` turn. Consumed (and reset to `'revise'`) as
|
|
* soon as that first Send fires; later Sends on the same mount are plain
|
|
* revise turns.
|
|
*/
|
|
prefillSeed?: { text: string; mode?: 'build' | 'create' } | null;
|
|
/**
|
|
* Fires once the prefill seed has populated the input, so the host can
|
|
* clear the ephemeral route seed (`location.state.copilotPrefill`) — same
|
|
* rationale as `onBuildSeedConsumed`: a remount (close/reopen) must not
|
|
* re-populate the input a second time against a still-present route seed.
|
|
*/
|
|
onPrefillSeedConsumed?: () => void;
|
|
/**
|
|
* 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;
|
|
/**
|
|
* Drop the panel's `max-w-sm` cap so it fills the available width. Used by
|
|
* the chat-first canvas open, where the copilot is the whole surface until
|
|
* the graph appears.
|
|
*/
|
|
fullWidth?: boolean;
|
|
}
|
|
|
|
export default function WorkflowCopilotPanel({
|
|
graph,
|
|
flowId = null,
|
|
onProposal,
|
|
onAccept,
|
|
onReject,
|
|
onClose,
|
|
repairSeed = null,
|
|
buildSeed = null,
|
|
onBuildSeedConsumed,
|
|
prefillSeed = null,
|
|
onPrefillSeedConsumed,
|
|
seedThreadId = null,
|
|
onThreadIdChange,
|
|
fullWidth = false,
|
|
}: Props) {
|
|
const { t } = useT();
|
|
const { threadId, sending, proposal, capped, 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<HTMLTextAreaElement | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
const isComposingTextRef = useRef(false);
|
|
|
|
// Surface each NEW proposal to the host exactly once (enter preview overlay).
|
|
const lastSurfacedRef = useRef<WorkflowProposal | null>(null);
|
|
useEffect(() => {
|
|
if (proposal && proposal !== lastSurfacedRef.current) {
|
|
lastSurfacedRef.current = proposal;
|
|
onProposal(proposal);
|
|
}
|
|
}, [proposal, onProposal]);
|
|
|
|
// Holds the ORIGINAL ask when a turn ends without a proposal — i.e. the
|
|
// agent asked a genuinely-ambiguous clarifying question (the prompt's
|
|
// "bucket 3" branch) and stopped rather than revising. `submit` always
|
|
// sends `mode: 'revise'` with the CURRENT graph, but while a question is
|
|
// still open that graph hasn't changed yet, so a bare follow-up answer
|
|
// ("#eng") would be the agent's ENTIRE context for the next turn — the
|
|
// original request ("post a daily summary to Slack") would be lost and the
|
|
// turn renders as "Revise it as follows: #eng" against a stale/blank draft.
|
|
// Prepending the unresolved ask keeps that context alive across the Q&A
|
|
// round-trip; it's cleared once a turn actually proposes (the graph itself
|
|
// then carries the state, so later revises don't need it).
|
|
const pendingAskRef = useRef<string | null>(null);
|
|
|
|
// Sets/clears `pendingAskRef` after a turn settles, logging the decision
|
|
// (stable prefix + thread correlation, never the raw ask/answer text — that
|
|
// may carry user-authored content).
|
|
const updatePendingAsk = useCallback(
|
|
(proposed: boolean, ask: string) => {
|
|
log(
|
|
'pendingAsk: %s thread=%s',
|
|
proposed ? 'cleared (proposal landed)' : 'set (still open)',
|
|
threadId
|
|
);
|
|
pendingAskRef.current = proposed ? null : ask;
|
|
},
|
|
[threadId]
|
|
);
|
|
|
|
// Auto-send the repair turn once when opened from a failed run.
|
|
const repairSentRef = useRef(false);
|
|
useEffect(() => {
|
|
if (!repairSeed || repairSentRef.current) return;
|
|
repairSentRef.current = true;
|
|
const instruction = t('flows.copilot.repairDisplay');
|
|
send({
|
|
displayText: instruction,
|
|
request: {
|
|
mode: 'repair',
|
|
instruction: '',
|
|
graph: repairSeed.graph,
|
|
runId: repairSeed.runId,
|
|
error: repairSeed.error ?? null,
|
|
failingNodeIds: repairSeed.failingNodeIds ?? [],
|
|
},
|
|
}).then(({ proposed }) => {
|
|
updatePendingAsk(proposed, instruction);
|
|
});
|
|
}, [repairSeed, send, t, updatePendingAsk]);
|
|
|
|
// Auto-send the build turn once when opened from the prompt bar's
|
|
// instant-create path: the user's description becomes the first user turn on
|
|
// this thread, and the prompt asks for the full build → dry-run → PROPOSE
|
|
// arc against the just-created flow. Persistence still stays behind the
|
|
// usual Accept + canvas Save; `mode: 'build'` intentionally does NOT save
|
|
// the graph (issue #4596 — a Reject used to leave the graph persisted).
|
|
// Falls back to a plain revise turn if the flow id is somehow missing.
|
|
const buildSentRef = useRef(false);
|
|
useEffect(() => {
|
|
if (!buildSeed || buildSentRef.current) return;
|
|
// Optimistically guard re-entry while the async dispatch is in flight.
|
|
buildSentRef.current = true;
|
|
send({
|
|
displayText: buildSeed.description,
|
|
request: flowId
|
|
? { mode: 'build', instruction: buildSeed.description, graph, flowId }
|
|
: { mode: 'revise', instruction: buildSeed.description, graph, flowId },
|
|
}).then(({ outcome, proposed }) => {
|
|
if (outcome === 'dispatched') {
|
|
// Clear the ephemeral route seed only once the turn actually
|
|
// dispatched, so closing and reopening the panel (which remounts it
|
|
// and resets `buildSentRef`) can't re-fire the same build turn
|
|
// (issue #4597).
|
|
onBuildSeedConsumed?.();
|
|
} else if (outcome === 'skipped') {
|
|
// Retryable no-op (socket not connected yet, or a turn already in
|
|
// flight): keep the seed and release the guard so the effect retries
|
|
// once `send` changes identity on reconnect — otherwise the prompt is
|
|
// lost and the blank flow never auto-builds.
|
|
buildSentRef.current = false;
|
|
}
|
|
// `failed`: the dispatch was attempted but errored (surfaced via
|
|
// `error`). Leave the guard set so THIS mount doesn't auto-resend and
|
|
// duplicate the turn — the user retries from the input instead. Note the
|
|
// route seed is deliberately NOT consumed on failure, so a later close +
|
|
// reopen remounts the panel with a fresh `buildSentRef` and WILL re-fire
|
|
// the build: a reopen is thus an intentional retry, not a manual-only one.
|
|
// That's safe/desired — a failed turn persisted nothing (`mode:'build'`
|
|
// never saves; issue #4596), so there's no partial state to clean up.
|
|
//
|
|
// Regardless of outcome, record whether the turn proposed: when it didn't
|
|
// (a clarifying question, or a no-op/failed turn that never built), carry
|
|
// the original description forward so the user's free-text answer (via
|
|
// `submit` below) doesn't strand the agent with no idea what it was asked
|
|
// to build. A later turn that proposes clears it.
|
|
updatePendingAsk(proposed, buildSeed.description);
|
|
});
|
|
// `graph`/`flowId` are read once for the seed turn — later edits must not
|
|
// re-fire it (guarded by the ref regardless).
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [buildSeed, send, updatePendingAsk]);
|
|
|
|
// Populate the composer's input once when opened with a Suggested Workflows
|
|
// prefill seed — deliberately NEVER calls `send`: the user reviews/edits the
|
|
// pre-filled `build_prompt` and presses Send themselves. Guarded the same
|
|
// way as `buildSentRef`/`repairSentRef` (once per mount) so a re-render
|
|
// doesn't re-fill (and clobber) text the user has already started editing.
|
|
const prefillSentRef = useRef(false);
|
|
// The builder mode the FIRST manual Send after this prefill must use (see
|
|
// `prefillSeed`'s doc comment) — read and cleared by `submit` below once
|
|
// that first Send actually fires, so later Sends fall back to `revise`.
|
|
const pendingPrefillModeRef = useRef<'build' | 'create' | null>(null);
|
|
useEffect(() => {
|
|
if (!prefillSeed || prefillSentRef.current) return;
|
|
prefillSentRef.current = true;
|
|
pendingPrefillModeRef.current = prefillSeed.mode ?? 'build';
|
|
log('prefill seed: populating composer input (unsent), pending mode=%s', prefillSeed.mode);
|
|
setText(prefillSeed.text);
|
|
textInputRef.current?.focus();
|
|
// Consumed synchronously (no async dispatch to await, unlike build/repair)
|
|
// so the host can strip the ephemeral route seed right away — a later
|
|
// remount (close/reopen the copilot) then has no seed left to re-apply.
|
|
onPrefillSeedConsumed?.();
|
|
}, [prefillSeed, onPrefillSeedConsumed]);
|
|
|
|
// Transcript rendering + scroll pinning (stick-to-bottom) are owned by the
|
|
// shared `ChatThreadView` below — the copilot no longer hand-rolls the
|
|
// transcript. This component keeps only the authoring concerns: the
|
|
// structured `flows_build` send path, the seed auto-sends, and the
|
|
// proposal / capped cards surfaced in the footer.
|
|
const submit = useCallback(
|
|
async (raw?: string) => {
|
|
const trimmed = (raw ?? text).trim();
|
|
if (!trimmed || sending) return;
|
|
setText('');
|
|
const priorAsk = pendingAskRef.current;
|
|
const instruction = priorAsk
|
|
? `${priorAsk}\n\n(This is my answer to your question above: ${trimmed})`
|
|
: trimmed;
|
|
// The FIRST Send after a Suggested Workflows prefill seed must run the
|
|
// seed's builder mode (default `build`), not the usual `revise` — the
|
|
// seed's flow was just created blank, so this turn needs the `build`
|
|
// brief (build → dry-run → propose) rather than being treated as a
|
|
// revise of an existing draft. Consumed once: later Sends on this same
|
|
// mount fall back to plain `revise`. Requires a real `flowId` (always
|
|
// true for a prefill seed, which only ever seeds an existing flow's
|
|
// canvas) — falls back to `revise` defensively if it's somehow absent,
|
|
// mirroring `buildSeed`'s own fallback above.
|
|
const prefillMode = pendingPrefillModeRef.current;
|
|
pendingPrefillModeRef.current = null;
|
|
const request =
|
|
prefillMode && flowId
|
|
? { mode: prefillMode, instruction, graph, flowId }
|
|
: { mode: 'revise' as const, instruction, graph, flowId };
|
|
const { proposed } = await send({ displayText: trimmed, request });
|
|
updatePendingAsk(proposed, instruction);
|
|
},
|
|
[text, sending, send, graph, flowId, updatePendingAsk]
|
|
);
|
|
|
|
// (B34) One-click resume for a turn that hit the agent's tool-call budget
|
|
// (`capped`, see `useWorkflowBuilderChat`'s doc) with no proposal yet.
|
|
// Routes through the SAME `submit` path a typed follow-up would — the
|
|
// `pendingAskRef` mechanism (set above, since a capped turn also has
|
|
// `proposed === false`) automatically carries the original ask forward, so
|
|
// the agent picks the build back up with full context, not just "continue"
|
|
// in isolation.
|
|
//
|
|
// What this actually does (Codex review on #4865): `flows_build` spins up a
|
|
// FRESH `workflow_builder` agent per RPC — there is no server-side
|
|
// session/tool-history checkpoint to reattach to, so this is not a literal
|
|
// mid-thought resume. What DOES carry forward, because `submit` always
|
|
// sends `mode: 'revise'` over the CURRENT `graph` + `flowId` (never a blank
|
|
// `create`): (1) the live draft graph — unchanged by a capped turn, since
|
|
// `revise_workflow`/`propose_workflow` never persist without a proposal
|
|
// reaching this panel; and (2) the full accumulated instruction text via
|
|
// `pendingAskRef`. A fresh agent re-reading the same draft plus the same
|
|
// ask, now under the B31 50-iteration budget and B32's no-probing brief,
|
|
// reliably converges — that combination is what the capped card's copy
|
|
// promises ("keep building from the current draft"), not seamless
|
|
// tool-history continuity.
|
|
const continueBuilding = useCallback(() => {
|
|
void submit(t('flows.copilot.continueBuilding'));
|
|
}, [submit, t]);
|
|
|
|
const handleInputKeyDown = useCallback(
|
|
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (event.key === 'Enter' && !event.shiftKey && !isComposingTextRef.current) {
|
|
event.preventDefault();
|
|
void submit();
|
|
}
|
|
},
|
|
[submit]
|
|
);
|
|
|
|
const noopAttach = useCallback(async () => {}, []);
|
|
const noop = useCallback(() => {}, []);
|
|
|
|
// Accept now review-and-saves: `onAccept` (the host's `handleAcceptProposal`)
|
|
// applies the proposal to the draft AND persists it. Track a local
|
|
// `acceptSaving` flag so the button can show a saving state and disable
|
|
// re-clicks while that's in flight. If the host's save throws, leave the
|
|
// proposal card visible (don't `clearProposal()`) so the user can retry —
|
|
// otherwise a failed autosave would silently vanish the only affordance to
|
|
// try again from the copilot itself (the header Save button is a fallback,
|
|
// but this keeps the copilot's own flow self-contained).
|
|
const [acceptSaving, setAcceptSaving] = useState(false);
|
|
const accept = useCallback(async () => {
|
|
// Self-guard against re-entrance: the JSX `disabled={acceptSaving}` on
|
|
// the Accept button prevents a normal double-click, but `acceptSaving`
|
|
// only flips after the FIRST call's `setAcceptSaving(true)` commits — a
|
|
// second invocation racing ahead of that render (e.g. programmatic
|
|
// re-fire) must not start a second concurrent save.
|
|
if (!proposal || acceptSaving) return;
|
|
setAcceptSaving(true);
|
|
log('accept: saving proposal via host onAccept');
|
|
try {
|
|
await onAccept(proposal);
|
|
log('accept: save succeeded, clearing proposal');
|
|
clearProposal();
|
|
lastSurfacedRef.current = null;
|
|
} catch (err) {
|
|
log('accept: save failed, leaving proposal visible for retry err=%o', err);
|
|
} finally {
|
|
setAcceptSaving(false);
|
|
}
|
|
}, [proposal, acceptSaving, onAccept, clearProposal]);
|
|
|
|
const reject = useCallback(() => {
|
|
onReject();
|
|
clearProposal();
|
|
lastSurfacedRef.current = null;
|
|
}, [onReject, clearProposal]);
|
|
|
|
const diff = proposal ? diffGraphs(graph, proposal.graph as WorkflowGraph) : null;
|
|
|
|
return (
|
|
<aside
|
|
data-testid="workflow-copilot-panel"
|
|
className={`flex h-full w-full flex-col border-l border-line bg-surface ${
|
|
fullWidth ? '' : 'max-w-sm'
|
|
}`}>
|
|
<header className="flex items-start gap-2 border-b border-line px-3 py-2.5">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-semibold text-content">{t('flows.copilot.title')}</p>
|
|
<p className="text-[11px] text-content-muted">{t('flows.copilot.subtitle')}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
data-testid="workflow-copilot-close"
|
|
aria-label={t('flows.copilot.close')}
|
|
onClick={onClose}
|
|
className="shrink-0 rounded-full p-1.5 text-content-faint hover:bg-surface-hover hover:text-content-secondary">
|
|
✕
|
|
</button>
|
|
</header>
|
|
|
|
{/* Full builder transcript — the SAME rich renderer the home composer
|
|
chat uses (message bubbles, past-turn insights, the shared tool
|
|
timeline + sub-agent drawer, streaming/interrupted/parallel previews),
|
|
driven by this copilot's DEDICATED thread. `flows_build` streams the
|
|
`workflow_builder` turn onto `threadId` via the global
|
|
`ChatRuntimeProvider`, exactly as a normal chat turn streams, so the
|
|
copilot now reads like the real chat instead of a bespoke transcript.
|
|
The empty hint, proposal preview, and capped card are the copilot's
|
|
own authoring affordances, kept in the footer below. */}
|
|
<ChatThreadView
|
|
threadId={threadId}
|
|
variant="sidebar"
|
|
scrollResetKey="workflow-copilot"
|
|
shareAgentName={t('flows.copilot.title')}
|
|
emptyContent={
|
|
<div className="flex h-full items-center justify-center px-3">
|
|
<p className="text-xs text-content-muted" data-testid="workflow-copilot-empty">
|
|
{t('flows.copilot.emptyState')}
|
|
</p>
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
<div className="space-y-3 border-t border-line px-3 py-2.5">
|
|
{error && (
|
|
<p className="text-xs text-coral" data-testid="workflow-copilot-error">
|
|
{error === 'offline' ? t('flows.copilot.offline') : t('flows.copilot.error')}
|
|
</p>
|
|
)}
|
|
|
|
{proposal && diff && (
|
|
<div
|
|
data-testid="workflow-copilot-proposal"
|
|
className="rounded-xl border border-ocean-300 bg-surface p-3 dark:border-ocean-700">
|
|
<p className="text-xs font-semibold text-ocean-900 dark:text-ocean-100">
|
|
{proposal.name || t('flows.copilot.proposalTitle')}
|
|
</p>
|
|
<p className="mt-1 text-[11px] text-content-muted">{t('flows.copilot.previewHint')}</p>
|
|
|
|
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px]">
|
|
{diff.addedNodeIds.size > 0 && (
|
|
<span
|
|
data-testid="workflow-copilot-added"
|
|
className="rounded-full bg-sage-100 px-2 py-0.5 font-medium text-sage-700 dark:bg-sage-500/15 dark:text-sage-300">
|
|
{t('flows.copilot.added').replace('{count}', String(diff.addedNodeIds.size))}
|
|
</span>
|
|
)}
|
|
{diff.removedNodeIds.size > 0 && (
|
|
<span
|
|
data-testid="workflow-copilot-removed"
|
|
className="rounded-full bg-coral-100 px-2 py-0.5 font-medium text-coral-700 dark:bg-coral-500/15 dark:text-coral-300">
|
|
{t('flows.copilot.removed').replace('{count}', String(diff.removedNodeIds.size))}
|
|
</span>
|
|
)}
|
|
{!diff.hasChanges && (
|
|
<span className="text-content-faint">{t('flows.copilot.noChanges')}</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-3 flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="primary"
|
|
size="sm"
|
|
disabled={acceptSaving}
|
|
data-testid="workflow-copilot-accept"
|
|
onClick={() => void accept()}>
|
|
{acceptSaving ? t('flows.copilot.saving') : t('flows.copilot.acceptAndSave')}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
size="sm"
|
|
disabled={acceptSaving}
|
|
data-testid="workflow-copilot-reject"
|
|
onClick={reject}>
|
|
{t('flows.copilot.reject')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* (B34) The turn hit the agent's iteration limit with no proposal
|
|
yet — distinguish this from a voluntary clarifying question (which
|
|
renders as a plain agent bubble in the transcript, no card) with an
|
|
explicit "reached its iteration limit" signal and a one-click resume
|
|
that continues building from the current draft (see `continueBuilding`
|
|
above for why this is accurate rather than a seamless resume).
|
|
Never shown alongside `sending` (a fresh turn already cleared
|
|
`capped`) or a proposal (mutually exclusive server-side — see
|
|
`ops.rs`). */}
|
|
{capped && !sending && !proposal && (
|
|
<div
|
|
data-testid="workflow-copilot-capped"
|
|
className="rounded-xl border border-amber-300 bg-surface p-3 dark:border-amber-700">
|
|
<p className="text-xs text-content-secondary">{t('flows.copilot.cappedNotice')}</p>
|
|
<div className="mt-2">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
size="sm"
|
|
data-testid="workflow-copilot-continue"
|
|
onClick={continueBuilding}>
|
|
{t('flows.copilot.continueBuilding')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<ChatComposer
|
|
inputValue={text}
|
|
setInputValue={setText}
|
|
onSend={submit}
|
|
textInputRef={textInputRef}
|
|
fileInputRef={fileInputRef}
|
|
composerInteractionBlocked={sending}
|
|
isSending={sending}
|
|
attachments={[]}
|
|
onAttachFiles={noopAttach}
|
|
onRemoveAttachment={noop}
|
|
attachError={null}
|
|
onSwitchToMicCloud={noop}
|
|
handleInputKeyDown={handleInputKeyDown}
|
|
inlineCompletionSuffix=""
|
|
isComposingTextRef={isComposingTextRef}
|
|
maxAttachments={0}
|
|
allowedMimeTypes={[]}
|
|
attachmentsEnabled={false}
|
|
micEnabled={false}
|
|
placeholder={t('flows.copilot.placeholder')}
|
|
/>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|