mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(flows): render Workflow Copilot chat via the shared composer transcript (ChatThreadView) (#5097)
This commit is contained in:
@@ -1,67 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import ToolActivityChip from './ToolActivityChip';
|
||||
|
||||
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
|
||||
|
||||
describe('ToolActivityChip', () => {
|
||||
it('renders the proposing label for propose_workflow', () => {
|
||||
render(<ToolActivityChip toolNames={['propose_workflow']} />);
|
||||
expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent(
|
||||
'flows.copilot.tool.proposing'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the proposing label for revise_workflow too', () => {
|
||||
render(<ToolActivityChip toolNames={['revise_workflow']} />);
|
||||
expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent(
|
||||
'flows.copilot.tool.proposing'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the dry-running label for dry_run_workflow', () => {
|
||||
render(<ToolActivityChip toolNames={['dry_run_workflow']} />);
|
||||
expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent(
|
||||
'flows.copilot.tool.dryRunning'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the saving label for save_workflow', () => {
|
||||
render(<ToolActivityChip toolNames={['save_workflow']} />);
|
||||
expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent('flows.copilot.tool.saving');
|
||||
});
|
||||
|
||||
it('renders a generic "using tools" label for an unrecognized tool name', () => {
|
||||
render(<ToolActivityChip toolNames={['some_other_tool']} />);
|
||||
expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent(
|
||||
'flows.copilot.tool.usingTools'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders nothing for an empty toolNames array', () => {
|
||||
const { container } = render(<ToolActivityChip toolNames={[]} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders the shared label when every tool name maps to the same label', () => {
|
||||
render(<ToolActivityChip toolNames={['propose_workflow', 'revise_workflow']} />);
|
||||
expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent(
|
||||
'flows.copilot.tool.proposing'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the generic label when tool names map to different labels', () => {
|
||||
render(<ToolActivityChip toolNames={['dry_run_workflow', 'save_workflow']} />);
|
||||
expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent(
|
||||
'flows.copilot.tool.usingTools'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the generic label when one tool is unrecognized, even if another is recognized', () => {
|
||||
render(<ToolActivityChip toolNames={['some_other_tool', 'save_workflow']} />);
|
||||
expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent(
|
||||
'flows.copilot.tool.usingTools'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* ToolActivityChip — replaces raw tool-call JSON in the copilot chat with a
|
||||
* compact, human-readable status pill (B25). Users should know the agent
|
||||
* used a tool (it explains why the turn took longer), but they should never
|
||||
* see the raw JSON arguments (e.g. the whole workflow graph payload).
|
||||
*/
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
|
||||
interface Props {
|
||||
/** Tool names extracted from the turn's tool-call envelope, in call order. */
|
||||
toolNames: string[];
|
||||
}
|
||||
|
||||
/** Tools that map to a specific, more informative status label. */
|
||||
const KNOWN_TOOL_LABEL_KEYS: Record<string, string> = {
|
||||
propose_workflow: 'flows.copilot.tool.proposing',
|
||||
revise_workflow: 'flows.copilot.tool.proposing',
|
||||
dry_run_workflow: 'flows.copilot.tool.dryRunning',
|
||||
save_workflow: 'flows.copilot.tool.saving',
|
||||
};
|
||||
|
||||
export default function ToolActivityChip({ toolNames }: Props) {
|
||||
const { t } = useT();
|
||||
if (toolNames.length === 0) return null;
|
||||
|
||||
// Every tool must map to the SAME recognized label before we show a
|
||||
// specific status (e.g. all of `propose_workflow`/`revise_workflow` map to
|
||||
// "proposing..."); any unrecognized tool, or a mix of tools with different
|
||||
// labels, falls back to a generic "Using tools..." pill rather than
|
||||
// picking one tool's label arbitrarily or dumping tool names verbatim.
|
||||
const labelKeys = toolNames.map(name => KNOWN_TOOL_LABEL_KEYS[name]);
|
||||
const labelKey = labelKeys.every(key => key && key === labelKeys[0]) ? labelKeys[0] : undefined;
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid="tool-activity-chip"
|
||||
className="mt-1 inline-flex w-fit items-center gap-1 rounded-full bg-surface-subtle px-1.5 py-0.5 text-[11px] font-medium text-content-muted">
|
||||
{t(labelKey ?? 'flows.copilot.tool.usingTools')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -2,28 +2,30 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { WorkflowGraph, WorkflowNode } from '../../lib/flows/types';
|
||||
import type { ToolTimelineEntry, WorkflowProposal } from '../../store/chatRuntimeSlice';
|
||||
import type { WorkflowProposal } from '../../store/chatRuntimeSlice';
|
||||
import WorkflowCopilotPanel from './WorkflowCopilotPanel';
|
||||
|
||||
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
|
||||
|
||||
interface MockMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
sender: 'user' | 'agent';
|
||||
extraMetadata?: { isInterim?: boolean };
|
||||
}
|
||||
// The panel now delegates its entire transcript to the shared `ChatThreadView`
|
||||
// (message bubbles, tool timeline, sub-agent drawer, streaming previews). That
|
||||
// component reads the real Redux store; its rendering — including the B25
|
||||
// tool-call-envelope unwrap and interim-narration handling — is covered by
|
||||
// `features/conversations/components/ChatThreadView.test.tsx`. Here we stub it
|
||||
// so these tests stay focused on the copilot's OWN authoring behavior (the
|
||||
// `flows_build` send path, seed auto-sends, and the proposal / capped cards)
|
||||
// without needing a Redux Provider.
|
||||
vi.mock('../../features/conversations/components/ChatThreadView', () => ({
|
||||
ChatThreadView: ({ emptyContent }: { emptyContent?: unknown }) => (
|
||||
<div data-testid="chat-thread-view">{emptyContent as never}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const hookState = vi.hoisted(() => ({
|
||||
threadId: null as string | null,
|
||||
sending: false,
|
||||
proposal: null as WorkflowProposal | null,
|
||||
capped: false,
|
||||
// Panel renders `displayMessages` (already interim-filtered upstream by
|
||||
// `useWorkflowBuilderChat`) — kept separate from `messages` in these tests
|
||||
// so a mismatch between the two proves the panel is reading the right field.
|
||||
displayMessages: [] as MockMessage[],
|
||||
toolTimeline: [] as ToolTimelineEntry[],
|
||||
liveResponse: '',
|
||||
error: null as string | null,
|
||||
send: vi.fn(),
|
||||
clearProposal: vi.fn(),
|
||||
@@ -50,12 +52,10 @@ const baseGraph = graph(['a', 'b']);
|
||||
|
||||
describe('WorkflowCopilotPanel', () => {
|
||||
beforeEach(() => {
|
||||
hookState.threadId = null;
|
||||
hookState.sending = false;
|
||||
hookState.proposal = null;
|
||||
hookState.capped = false;
|
||||
hookState.displayMessages = [];
|
||||
hookState.toolTimeline = [];
|
||||
hookState.liveResponse = '';
|
||||
hookState.error = null;
|
||||
hookState.send = vi.fn().mockResolvedValue({ outcome: 'dispatched', proposed: false });
|
||||
hookState.clearProposal = vi.fn();
|
||||
@@ -147,268 +147,13 @@ describe('WorkflowCopilotPanel', () => {
|
||||
expect(thirdArg.request.instruction).toBe('also add a filter step');
|
||||
});
|
||||
|
||||
it('renders the conversation transcript (user + agent turns)', () => {
|
||||
hookState.displayMessages = [
|
||||
{ id: 'm1', content: 'add a Slack step', sender: 'user' },
|
||||
{ id: 'm2', content: 'Done — proposed a Slack notification.', sender: 'agent' },
|
||||
];
|
||||
render(
|
||||
<WorkflowCopilotPanel
|
||||
graph={baseGraph}
|
||||
onProposal={vi.fn()}
|
||||
onAccept={vi.fn()}
|
||||
onReject={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
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('B25: unwraps a raw tool-call envelope message into clean text + a tool activity chip, never raw JSON', () => {
|
||||
// Repro for B25: a turn that both talks and calls a tool can land in the
|
||||
// thread transcript as the provider wire-format `{ content, tool_calls }`
|
||||
// envelope. The panel must render only the human text — never the raw
|
||||
// JSON — plus a compact status chip for the tool activity.
|
||||
hookState.displayMessages = [
|
||||
{ id: 'm1', content: 'build me a Slack digest', sender: 'user' },
|
||||
{
|
||||
id: 'm2',
|
||||
content: JSON.stringify({
|
||||
content: "Here's the workflow I propose.",
|
||||
tool_calls: [{ id: 'call_1', name: 'propose_workflow', arguments: '{"nodes":[]}' }],
|
||||
}),
|
||||
sender: 'agent',
|
||||
},
|
||||
];
|
||||
render(
|
||||
<WorkflowCopilotPanel
|
||||
graph={baseGraph}
|
||||
onProposal={vi.fn()}
|
||||
onAccept={vi.fn()}
|
||||
onReject={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const bubble = screen.getByTestId('workflow-copilot-agent');
|
||||
expect(bubble).toHaveTextContent("Here's the workflow I propose.");
|
||||
// The raw envelope must never reach the DOM as text.
|
||||
expect(bubble).not.toHaveTextContent('tool_calls');
|
||||
expect(bubble).not.toHaveTextContent('"nodes":[]');
|
||||
expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent(
|
||||
'flows.copilot.tool.proposing'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not render an isInterim agent message as a bubble, only the terminal one', () => {
|
||||
// The panel only ever renders `displayMessages` — the same filtered set
|
||||
// `useWorkflowBuilderChat` computes from the raw transcript (isInterim
|
||||
// agent messages dropped since that narration already streams live via
|
||||
// the tool timeline / live text). Mirror that filter here so this test
|
||||
// documents (and would catch a regression in) the composition: an
|
||||
// isInterim message must never reach the panel as a bubble, while the
|
||||
// terminal (non-interim) answer still does.
|
||||
const raw: MockMessage[] = [
|
||||
{ id: 'm1', content: 'build me a Slack digest', sender: 'user' },
|
||||
{
|
||||
id: 'm2',
|
||||
content: 'Let me check your calendar first.',
|
||||
sender: 'agent',
|
||||
extraMetadata: { isInterim: true },
|
||||
},
|
||||
{ id: 'm3', content: 'Done — proposed a Slack notification.', sender: 'agent' },
|
||||
];
|
||||
hookState.displayMessages = raw.filter(m => m.sender === 'user' || !m.extraMetadata?.isInterim);
|
||||
|
||||
render(
|
||||
<WorkflowCopilotPanel
|
||||
graph={baseGraph}
|
||||
onProposal={vi.fn()}
|
||||
onAccept={vi.fn()}
|
||||
onReject={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText('Let me check your calendar first.')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('workflow-copilot-agent')).toHaveTextContent(
|
||||
'Done — proposed a Slack notification.'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the shared tool timeline + streaming reply during a builder turn', () => {
|
||||
hookState.sending = true;
|
||||
hookState.toolTimeline = [
|
||||
{ id: 'call-1', name: 'propose_workflow', round: 0, status: 'running' } as ToolTimelineEntry,
|
||||
];
|
||||
hookState.liveResponse = 'Drafting your workflow…';
|
||||
render(
|
||||
<WorkflowCopilotPanel
|
||||
graph={baseGraph}
|
||||
onProposal={vi.fn()}
|
||||
onAccept={vi.fn()}
|
||||
onReject={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// The shared ToolTimelineBlock renders (not the bespoke transcript), and the
|
||||
// one-shot "thinking" placeholder is suppressed once activity is streaming.
|
||||
expect(screen.getByTestId('workflow-copilot-timeline')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('workflow-copilot-thinking')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the live reply as a bubble before the first tool call streams', () => {
|
||||
hookState.sending = true;
|
||||
hookState.toolTimeline = [];
|
||||
hookState.liveResponse = 'Thinking about your Slack digest…';
|
||||
render(
|
||||
<WorkflowCopilotPanel
|
||||
graph={baseGraph}
|
||||
onProposal={vi.fn()}
|
||||
onAccept={vi.fn()}
|
||||
onReject={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('workflow-copilot-streaming')).toHaveTextContent(
|
||||
'Thinking about your Slack digest…'
|
||||
);
|
||||
// No tool timeline yet, and the plain "thinking" line is replaced by the
|
||||
// streamed text.
|
||||
expect(screen.queryByTestId('workflow-copilot-timeline')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('workflow-copilot-thinking')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Regression coverage for the "copilot chat gets stuck" bug: the panel used
|
||||
// to force-scroll to the bottom on every render of a streaming turn (an
|
||||
// unconditional `scrollTo` effect keyed on messages/tool timeline/live
|
||||
// text), which fought a user trying to scroll up to read. The panel now
|
||||
// delegates to the shared `useStickToBottom` hook (same one the main chat
|
||||
// surfaces use) — these tests exercise the REAL hook (not mocked) wired
|
||||
// through the actual transcript container.
|
||||
describe('transcript scroll pinning (#regression: chat gets stuck)', () => {
|
||||
function scrollContainer() {
|
||||
return screen.getByTestId('workflow-copilot-transcript');
|
||||
}
|
||||
|
||||
// jsdom performs no real layout, so scroll metrics are inert unless
|
||||
// defined explicitly — mirrors the approach in useStickToBottom.test.ts.
|
||||
function mockScrollMetrics(
|
||||
el: HTMLElement,
|
||||
metrics: { scrollTop: number; scrollHeight: number; clientHeight: number }
|
||||
) {
|
||||
Object.defineProperty(el, 'scrollHeight', {
|
||||
configurable: true,
|
||||
value: metrics.scrollHeight,
|
||||
});
|
||||
Object.defineProperty(el, 'clientHeight', {
|
||||
configurable: true,
|
||||
value: metrics.clientHeight,
|
||||
});
|
||||
Object.defineProperty(el, 'scrollTop', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: metrics.scrollTop,
|
||||
});
|
||||
}
|
||||
|
||||
function renderPanel() {
|
||||
return render(
|
||||
<WorkflowCopilotPanel
|
||||
graph={baseGraph}
|
||||
onProposal={vi.fn()}
|
||||
onAccept={vi.fn()}
|
||||
onReject={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
it('keeps the transcript container freely scrollable (overflow-y-auto)', () => {
|
||||
renderPanel();
|
||||
expect(scrollContainer()).toHaveClass('overflow-y-auto');
|
||||
});
|
||||
|
||||
it('auto-scrolls to the bottom when a new message arrives while the user is pinned to the bottom', () => {
|
||||
hookState.displayMessages = [{ id: 'm1', content: 'hi', sender: 'user' }];
|
||||
const { rerender } = renderPanel();
|
||||
const container = scrollContainer();
|
||||
|
||||
mockScrollMetrics(container, { scrollTop: 50, scrollHeight: 100, clientHeight: 50 });
|
||||
rerender(
|
||||
<WorkflowCopilotPanel
|
||||
graph={baseGraph}
|
||||
onProposal={vi.fn()}
|
||||
onAccept={vi.fn()}
|
||||
onReject={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// A new agent turn lands while the user never scrolled away.
|
||||
hookState.displayMessages = [
|
||||
...hookState.displayMessages,
|
||||
{ id: 'm2', content: 'Done — proposed a Slack notification.', sender: 'agent' },
|
||||
];
|
||||
mockScrollMetrics(container, { scrollTop: 50, scrollHeight: 300, clientHeight: 50 });
|
||||
rerender(
|
||||
<WorkflowCopilotPanel
|
||||
graph={baseGraph}
|
||||
onProposal={vi.fn()}
|
||||
onAccept={vi.fn()}
|
||||
onReject={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.scrollTop).toBe(300);
|
||||
});
|
||||
|
||||
it('does NOT force-scroll the user back down once they have scrolled up to read history', () => {
|
||||
hookState.displayMessages = [{ id: 'm1', content: 'hi', sender: 'user' }];
|
||||
const { rerender } = renderPanel();
|
||||
const container = scrollContainer();
|
||||
|
||||
mockScrollMetrics(container, { scrollTop: 50, scrollHeight: 100, clientHeight: 50 });
|
||||
rerender(
|
||||
<WorkflowCopilotPanel
|
||||
graph={baseGraph}
|
||||
onProposal={vi.fn()}
|
||||
onAccept={vi.fn()}
|
||||
onReject={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// The user scrolls up to read earlier context, well past the stick
|
||||
// threshold (400 - 0 - 50 = 350px from the bottom).
|
||||
mockScrollMetrics(container, { scrollTop: 0, scrollHeight: 400, clientHeight: 50 });
|
||||
fireEvent.scroll(container);
|
||||
|
||||
// A new agent turn streams in regardless — this is exactly the bug:
|
||||
// the old unconditional `scrollTo` effect would yank the reader back
|
||||
// to the bottom here. The container must stay put.
|
||||
hookState.displayMessages = [
|
||||
...hookState.displayMessages,
|
||||
{ id: 'm2', content: 'Still drafting…', sender: 'agent' },
|
||||
];
|
||||
mockScrollMetrics(container, { scrollTop: 0, scrollHeight: 700, clientHeight: 50 });
|
||||
rerender(
|
||||
<WorkflowCopilotPanel
|
||||
graph={baseGraph}
|
||||
onProposal={vi.fn()}
|
||||
onAccept={vi.fn()}
|
||||
onReject={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.scrollTop).toBe(0);
|
||||
});
|
||||
});
|
||||
// Transcript rendering (message bubbles, the shared tool timeline + sub-agent
|
||||
// drawer, streaming previews, the B25 tool-call-envelope unwrap, interim
|
||||
// narration, and stick-to-bottom scroll pinning) now lives in the shared
|
||||
// `ChatThreadView` and is covered by
|
||||
// `features/conversations/components/ChatThreadView.test.tsx`. The panel here
|
||||
// stubs that component (see the mock above), so these tests assert only the
|
||||
// copilot's own authoring surface (send path, seeds, proposal / capped cards).
|
||||
|
||||
it('surfaces a new proposal to the host and shows the added/removed diff', () => {
|
||||
const onProposal = vi.fn();
|
||||
|
||||
@@ -8,13 +8,16 @@
|
||||
* 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 reuses the SHARED chat surface end-to-end — the
|
||||
* same {@link ChatComposer} the main chat windows use (mic/attachments off
|
||||
* here), turns render as bubbles via the shared {@link BubbleMarkdown}, and the
|
||||
* builder turn's live tool activity + streaming reply render through the shared
|
||||
* {@link ToolTimelineBlock} (fed from the runtime's `toolTimelineByThread` /
|
||||
* `streamingAssistantByThread`, streamed here by Phase B). So the copilot reads
|
||||
* like a real chat rather than a one-shot form.
|
||||
* 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
|
||||
@@ -27,18 +30,14 @@
|
||||
import createDebug from 'debug';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { BubbleMarkdown } from '../../features/conversations/components/AgentMessageBubble';
|
||||
import { ToolTimelineBlock } from '../../features/conversations/components/ToolTimelineBlock';
|
||||
import { useStickToBottom } from '../../hooks/useStickToBottom';
|
||||
import { ChatThreadView } from '../../features/conversations/components/ChatThreadView';
|
||||
import { useWorkflowBuilderChat } from '../../hooks/useWorkflowBuilderChat';
|
||||
import { unwrapToolCallEnvelope } from '../../lib/flows/copilotMessageSanitizer';
|
||||
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';
|
||||
import ToolActivityChip from './ToolActivityChip';
|
||||
|
||||
const log = createDebug('app:flows:copilot-panel');
|
||||
|
||||
@@ -154,19 +153,8 @@ export default function WorkflowCopilotPanel({
|
||||
fullWidth = false,
|
||||
}: Props) {
|
||||
const { t } = useT();
|
||||
const {
|
||||
threadId,
|
||||
sending,
|
||||
turnActive,
|
||||
proposal,
|
||||
capped,
|
||||
displayMessages,
|
||||
toolTimeline,
|
||||
liveResponse,
|
||||
error,
|
||||
send,
|
||||
clearProposal,
|
||||
} = useWorkflowBuilderChat(seedThreadId);
|
||||
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 —
|
||||
@@ -313,36 +301,11 @@ export default function WorkflowCopilotPanel({
|
||||
onPrefillSeedConsumed?.();
|
||||
}, [prefillSeed, onPrefillSeedConsumed]);
|
||||
|
||||
// Keep the transcript pinned to the newest message / streamed activity —
|
||||
// but ONLY while the user is already at (or near) the bottom. The previous
|
||||
// implementation here was an unconditional `scrollTo(bottom)` effect keyed
|
||||
// on every streaming dependency (messages, tool timeline, live text, …):
|
||||
// it fired on every streamed token and force-scrolled regardless of where
|
||||
// the user was reading, which is what made the transcript feel "stuck" —
|
||||
// any attempt to scroll up got yanked back down by the very next token.
|
||||
// `useStickToBottom` is the same pinning hook the main chat surfaces use:
|
||||
// it only auto-scrolls while `stickingRef` is true (user at/near bottom),
|
||||
// and permanently disengages the moment the user scrolls away, so reading
|
||||
// history is never fought. `resetKey` is a stable constant here — this
|
||||
// panel is fully unmounted/remounted on close/reopen (see the seed refs
|
||||
// above), so there's no in-place "navigation" case to reset for.
|
||||
const { containerRef: scrollRef } = useStickToBottom(
|
||||
displayMessages,
|
||||
threadId,
|
||||
'workflow-copilot'
|
||||
);
|
||||
useEffect(() => {
|
||||
log(
|
||||
'scroll: stick-to-bottom deps changed messages=%d thread=%s sending=%s hasProposal=%s timeline=%d liveTextLen=%d',
|
||||
displayMessages.length,
|
||||
threadId ?? 'null',
|
||||
sending,
|
||||
Boolean(proposal),
|
||||
toolTimeline.length,
|
||||
liveResponse.length
|
||||
);
|
||||
}, [displayMessages, threadId, sending, proposal, toolTimeline, liveResponse]);
|
||||
|
||||
// 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();
|
||||
@@ -448,14 +411,6 @@ export default function WorkflowCopilotPanel({
|
||||
}, [onReject, clearProposal]);
|
||||
|
||||
const diff = proposal ? diffGraphs(graph, proposal.graph as WorkflowGraph) : null;
|
||||
const hasTimeline = toolTimeline.length > 0;
|
||||
// B25: the in-flight streaming text can also carry the raw tool-call
|
||||
// envelope mid-turn — unwrap once and reuse the clean text everywhere below
|
||||
// (the pre-tool streaming bubble and the shared `ToolTimelineBlock`).
|
||||
const liveResponseText = unwrapToolCallEnvelope(liveResponse).text;
|
||||
const hasLiveText = liveResponseText.trim().length > 0;
|
||||
const isEmpty =
|
||||
displayMessages.length === 0 && !proposal && !sending && !error && !hasTimeline && !hasLiveText;
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -478,80 +433,30 @@ export default function WorkflowCopilotPanel({
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 space-y-3 overflow-y-auto px-3 py-3"
|
||||
data-testid="workflow-copilot-transcript">
|
||||
{isEmpty && (
|
||||
<p className="text-xs text-content-muted" data-testid="workflow-copilot-empty">
|
||||
{t('flows.copilot.emptyState')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Conversation transcript: user turns right-aligned, agent turns left.
|
||||
Renders `displayMessages` (interim narration bubbles filtered out —
|
||||
that narration already streams via the tool timeline / live text
|
||||
below it double-renders it as a bubble too, see B4). */}
|
||||
{displayMessages.map(message => {
|
||||
if (message.sender === 'user') {
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className="flex justify-end"
|
||||
data-testid="workflow-copilot-user">
|
||||
<div className="max-w-[85%] rounded-2xl bg-primary-500 px-3 py-1.5 text-sm text-content-inverted">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// B25: a turn that both talks and calls a tool can carry the
|
||||
// provider wire-format `{ content, tool_calls }` envelope as this
|
||||
// message's raw `content` — unwrap it so the bubble renders only
|
||||
// the human text (+ a compact tool-activity chip), never raw JSON.
|
||||
const { text, toolNames } = unwrapToolCallEnvelope(message.content);
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className="max-w-[92%] rounded-2xl bg-surface-subtle px-3 py-1.5"
|
||||
data-testid="workflow-copilot-agent">
|
||||
<BubbleMarkdown content={text} />
|
||||
<ToolActivityChip toolNames={toolNames} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Live builder activity — the SHARED tool timeline (tool cards + the
|
||||
streaming reply) the main chat uses, fed from the runtime's streamed
|
||||
per-thread state. Renders nothing until the turn produces a tool
|
||||
call. */}
|
||||
{hasTimeline && (
|
||||
<div data-testid="workflow-copilot-timeline">
|
||||
<ToolTimelineBlock
|
||||
entries={toolTimeline}
|
||||
liveResponse={hasLiveText ? liveResponseText : undefined}
|
||||
turnActive={turnActive}
|
||||
/>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Pre-tool phase: the reply is streaming but no tool has run yet, so the
|
||||
timeline is still empty — surface the live text as an agent bubble so
|
||||
the copilot never looks frozen. */}
|
||||
{hasLiveText && !hasTimeline && (
|
||||
<div
|
||||
className="max-w-[92%] rounded-2xl bg-surface-subtle px-3 py-1.5"
|
||||
data-testid="workflow-copilot-streaming">
|
||||
<BubbleMarkdown content={liveResponseText} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sending && !hasTimeline && !hasLiveText && (
|
||||
<p className="text-xs text-content-muted" data-testid="workflow-copilot-thinking">
|
||||
{t('flows.copilot.thinking')}
|
||||
</p>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
|
||||
<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')}
|
||||
@@ -612,9 +517,9 @@ export default function WorkflowCopilotPanel({
|
||||
|
||||
{/* (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 above, no card) with an explicit
|
||||
"reached its iteration limit" signal and a one-click resume that
|
||||
continues building from the current draft (see `continueBuilding`
|
||||
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
|
||||
@@ -636,9 +541,7 @@ export default function WorkflowCopilotPanel({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-line px-3 py-2.5">
|
||||
<ChatComposer
|
||||
inputValue={text}
|
||||
setInputValue={setText}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Focused behavior tests for the extracted transcript scroll body. Mirrors
|
||||
* the store-mounting conventions used by
|
||||
* `src/pages/__tests__/Conversations.render.test.tsx` (the home chat's own
|
||||
* smoke suite), but scoped to `ChatThreadView` in isolation so a second host
|
||||
* (e.g. the Workflow Copilot) can be confident the component works when
|
||||
* driven purely by its `threadId` prop rather than the global
|
||||
* `state.thread.selectedThreadId`.
|
||||
*/
|
||||
import { combineReducers, configureStore } from '@reduxjs/toolkit';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import chatRuntimeReducer from '../../../store/chatRuntimeSlice';
|
||||
import themeReducer from '../../../store/themeSlice';
|
||||
import threadReducer from '../../../store/threadSlice';
|
||||
import type { Thread, ThreadMessage } from '../../../types/thread';
|
||||
import { ChatThreadView } from './ChatThreadView';
|
||||
|
||||
// useStickToBottom returns refs; mock it so layout-effects don't fire in
|
||||
// jsdom (same stub the home-chat render suite uses).
|
||||
vi.mock('../../../hooks/useStickToBottom', () => ({
|
||||
useStickToBottom: vi.fn(() => ({ containerRef: { current: null }, endRef: { current: null } })),
|
||||
}));
|
||||
|
||||
function makeThread(overrides: Partial<Thread> = {}): Thread {
|
||||
return {
|
||||
id: 't-1',
|
||||
title: 'Test thread',
|
||||
chatId: null,
|
||||
isActive: false,
|
||||
messageCount: 0,
|
||||
lastMessageAt: '2026-01-01T00:00:00.000Z',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
labels: ['general'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildStore(preload: Record<string, unknown> = {}) {
|
||||
return configureStore({
|
||||
reducer: combineReducers({
|
||||
thread: threadReducer,
|
||||
chatRuntime: chatRuntimeReducer,
|
||||
theme: themeReducer,
|
||||
}),
|
||||
preloadedState: preload as never,
|
||||
});
|
||||
}
|
||||
|
||||
const emptyThreadState = {
|
||||
threads: [],
|
||||
selectedThreadId: null,
|
||||
activeThreadIds: {},
|
||||
welcomeThreadId: null,
|
||||
messagesByThreadId: {},
|
||||
messages: [],
|
||||
isLoadingThreads: false,
|
||||
isLoadingMessages: false,
|
||||
messagesError: null,
|
||||
};
|
||||
|
||||
function renderThreadView(
|
||||
props: Partial<ComponentProps<typeof ChatThreadView>> & { threadId: string | null },
|
||||
preload: Record<string, unknown> = {}
|
||||
) {
|
||||
const store = buildStore(preload);
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<ChatThreadView {...props} />
|
||||
</Provider>
|
||||
);
|
||||
return store;
|
||||
}
|
||||
|
||||
describe('ChatThreadView', () => {
|
||||
it('renders the chat-messages-scroll container', () => {
|
||||
renderThreadView({ threadId: null }, { thread: emptyThreadState });
|
||||
|
||||
expect(screen.getByTestId('chat-messages-scroll')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows emptyContent when the thread has no messages, footer content, or live activity', () => {
|
||||
const thread = makeThread({ id: 't-empty' });
|
||||
renderThreadView(
|
||||
{ threadId: thread.id, emptyContent: <p>Nothing here yet</p> },
|
||||
{
|
||||
thread: {
|
||||
...emptyThreadState,
|
||||
threads: [thread],
|
||||
selectedThreadId: thread.id,
|
||||
messagesByThreadId: { [thread.id]: [] },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(screen.getByText('Nothing here yet')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('chat-message-list')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a user and an agent message for the given threadId', () => {
|
||||
const thread = makeThread({ id: 't-msgs' });
|
||||
const messages: ThreadMessage[] = [
|
||||
{
|
||||
id: 'm-user',
|
||||
sender: 'user',
|
||||
type: 'text',
|
||||
content: 'Hello there',
|
||||
extraMetadata: {},
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'm-agent',
|
||||
sender: 'agent',
|
||||
type: 'text',
|
||||
content: 'General Kenobi',
|
||||
extraMetadata: {},
|
||||
createdAt: '2026-01-01T00:01:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
renderThreadView(
|
||||
{ threadId: thread.id },
|
||||
{
|
||||
thread: {
|
||||
...emptyThreadState,
|
||||
threads: [thread],
|
||||
selectedThreadId: thread.id,
|
||||
messagesByThreadId: { [thread.id]: messages },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('chat-message-list')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hello there')).toBeInTheDocument();
|
||||
expect(screen.getByText('General Kenobi')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reads messages by the threadId prop, not a global selected thread', () => {
|
||||
// Two threads hydrated in the store; only the prop-driven thread's
|
||||
// messages should render — proving the component doesn't fall back to
|
||||
// `state.thread.selectedThreadId`.
|
||||
const threadA = makeThread({ id: 't-a' });
|
||||
const threadB = makeThread({ id: 't-b' });
|
||||
const messagesA: ThreadMessage[] = [
|
||||
{
|
||||
id: 'a-1',
|
||||
sender: 'user',
|
||||
type: 'text',
|
||||
content: 'Message in thread A',
|
||||
extraMetadata: {},
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
const messagesB: ThreadMessage[] = [
|
||||
{
|
||||
id: 'b-1',
|
||||
sender: 'user',
|
||||
type: 'text',
|
||||
content: 'Message in thread B',
|
||||
extraMetadata: {},
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
renderThreadView(
|
||||
{ threadId: threadB.id },
|
||||
{
|
||||
thread: {
|
||||
...emptyThreadState,
|
||||
threads: [threadA, threadB],
|
||||
// Deliberately select A globally — the component must still
|
||||
// render B's messages because it reads `threadId` prop, not
|
||||
// `selectedThreadId`.
|
||||
selectedThreadId: threadA.id,
|
||||
messagesByThreadId: { [threadA.id]: messagesA, [threadB.id]: messagesB },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(screen.getByText('Message in thread B')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Message in thread A')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('B25: unwraps a raw tool-call envelope agent message to clean text, never raw JSON', () => {
|
||||
// A `workflow_builder` turn that both talks AND calls a tool can land in
|
||||
// the transcript as the provider wire-format `{ content, tool_calls }`
|
||||
// envelope. The shared renderer (used by both the home chat and the
|
||||
// workflow copilot) must show only the human text — never the raw JSON.
|
||||
const thread = makeThread({ id: 't-envelope' });
|
||||
const messages: ThreadMessage[] = [
|
||||
{
|
||||
id: 'm-user',
|
||||
sender: 'user',
|
||||
type: 'text',
|
||||
content: 'build me a Slack digest',
|
||||
extraMetadata: {},
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'm-agent',
|
||||
sender: 'agent',
|
||||
type: 'text',
|
||||
content: JSON.stringify({
|
||||
content: "Here's the workflow I propose.",
|
||||
tool_calls: [{ id: 'call_1', name: 'propose_workflow', arguments: '{"nodes":[]}' }],
|
||||
}),
|
||||
extraMetadata: {},
|
||||
createdAt: '2026-01-01T00:01:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
renderThreadView(
|
||||
{ threadId: thread.id },
|
||||
{
|
||||
thread: {
|
||||
...emptyThreadState,
|
||||
threads: [thread],
|
||||
selectedThreadId: thread.id,
|
||||
messagesByThreadId: { [thread.id]: messages },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const list = screen.getByTestId('chat-message-list');
|
||||
expect(list).toHaveTextContent("Here's the workflow I propose.");
|
||||
// The raw envelope must never reach the DOM as text.
|
||||
expect(list).not.toHaveTextContent('tool_calls');
|
||||
expect(list).not.toHaveTextContent('"nodes":[]');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { unwrapToolCallEnvelope } from './copilotMessageSanitizer';
|
||||
import { unwrapToolCallEnvelope } from './toolCallEnvelope';
|
||||
|
||||
describe('unwrapToolCallEnvelope', () => {
|
||||
it('returns plain text unchanged with no tool names', () => {
|
||||
@@ -1,9 +1,12 @@
|
||||
import createDebug from 'debug';
|
||||
|
||||
/**
|
||||
* copilotMessageSanitizer — defends the Flows copilot chat against rendering
|
||||
* the raw provider wire-format envelope instead of clean assistant text
|
||||
* (B25).
|
||||
* toolCallEnvelope — defends any chat transcript against rendering the raw
|
||||
* provider wire-format envelope instead of clean assistant text (B25).
|
||||
*
|
||||
* Applied by the shared `ChatThreadView` transcript renderer, so it guards
|
||||
* BOTH the home chat and the workflow copilot (which now reuses that
|
||||
* renderer). Originally lived under `lib/flows` for the copilot alone.
|
||||
*
|
||||
* The Rust core's `NativeToolDispatcher::to_provider_messages`
|
||||
* (`src/openhuman/agent/dispatcher.rs`) and the tinyagents bridge's
|
||||
@@ -14,7 +17,7 @@ import createDebug from 'debug';
|
||||
* That envelope is meant to stay internal to the agent session; this
|
||||
* sanitizer is a shape-based (not string-match) belt-and-suspenders guard so
|
||||
* that if it ever leaks into a chat message's `content` (from any Rust-side
|
||||
* vector, present or future), the copilot still renders only the human
|
||||
* vector, present or future), the transcript still renders only the human
|
||||
* text — never the raw JSON — mirroring the `unwrapPayloadEnvelope`
|
||||
* philosophy from PR #4822 (`app/src/lib/flows/runItems.ts`).
|
||||
*/
|
||||
Reference in New Issue
Block a user