@steerable/agent-ui 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +91 -0
- package/README.md +118 -0
- package/dist/components/ChatPanel.d.ts +42 -0
- package/dist/components/ChatPanel.d.ts.map +1 -0
- package/dist/components/ChatPanel.js +46 -0
- package/dist/components/ChatPanel.js.map +1 -0
- package/dist/components/ChatPanel.stories.d.ts +17 -0
- package/dist/components/ChatPanel.stories.d.ts.map +1 -0
- package/dist/components/ChatPanel.stories.js +143 -0
- package/dist/components/ChatPanel.stories.js.map +1 -0
- package/dist/components/MessageList.d.ts +44 -0
- package/dist/components/MessageList.d.ts.map +1 -0
- package/dist/components/MessageList.js +43 -0
- package/dist/components/MessageList.js.map +1 -0
- package/dist/components/MessageList.stories.d.ts +12 -0
- package/dist/components/MessageList.stories.d.ts.map +1 -0
- package/dist/components/MessageList.stories.js +88 -0
- package/dist/components/MessageList.stories.js.map +1 -0
- package/dist/components/OrchestrationPlanCard.d.ts +32 -0
- package/dist/components/OrchestrationPlanCard.d.ts.map +1 -0
- package/dist/components/OrchestrationPlanCard.js +48 -0
- package/dist/components/OrchestrationPlanCard.js.map +1 -0
- package/dist/components/OrchestrationPlanCard.stories.d.ts +12 -0
- package/dist/components/OrchestrationPlanCard.stories.d.ts.map +1 -0
- package/dist/components/OrchestrationPlanCard.stories.js +84 -0
- package/dist/components/OrchestrationPlanCard.stories.js.map +1 -0
- package/dist/components/SSEStreamView.d.ts +29 -0
- package/dist/components/SSEStreamView.d.ts.map +1 -0
- package/dist/components/SSEStreamView.js +78 -0
- package/dist/components/SSEStreamView.js.map +1 -0
- package/dist/components/SSEStreamView.stories.d.ts +12 -0
- package/dist/components/SSEStreamView.stories.d.ts.map +1 -0
- package/dist/components/SSEStreamView.stories.js +86 -0
- package/dist/components/SSEStreamView.stories.js.map +1 -0
- package/dist/components/ToolCallRenderer.d.ts +42 -0
- package/dist/components/ToolCallRenderer.d.ts.map +1 -0
- package/dist/components/ToolCallRenderer.js +70 -0
- package/dist/components/ToolCallRenderer.js.map +1 -0
- package/dist/components/ToolCallRenderer.stories.d.ts +13 -0
- package/dist/components/ToolCallRenderer.stories.d.ts.map +1 -0
- package/dist/components/ToolCallRenderer.stories.js +112 -0
- package/dist/components/ToolCallRenderer.stories.js.map +1 -0
- package/dist/components/cn.d.ts +7 -0
- package/dist/components/cn.d.ts.map +1 -0
- package/dist/components/cn.js +9 -0
- package/dist/components/cn.js.map +1 -0
- package/dist/components/index.d.ts +11 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +6 -0
- package/dist/components/index.js.map +1 -0
- package/dist/hooks/index.d.ts +7 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +4 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/useAgentSession.d.ts +56 -0
- package/dist/hooks/useAgentSession.d.ts.map +1 -0
- package/dist/hooks/useAgentSession.js +128 -0
- package/dist/hooks/useAgentSession.js.map +1 -0
- package/dist/hooks/useChatStream.d.ts +59 -0
- package/dist/hooks/useChatStream.d.ts.map +1 -0
- package/dist/hooks/useChatStream.js +251 -0
- package/dist/hooks/useChatStream.js.map +1 -0
- package/dist/hooks/useToolCallStatus.d.ts +47 -0
- package/dist/hooks/useToolCallStatus.d.ts.map +1 -0
- package/dist/hooks/useToolCallStatus.js +57 -0
- package/dist/hooks/useToolCallStatus.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/tailwind-preset.d.ts +76 -0
- package/dist/tailwind-preset.d.ts.map +1 -0
- package/dist/tailwind-preset.js +104 -0
- package/dist/tailwind-preset.js.map +1 -0
- package/package.json +97 -0
- package/src/Introduction.mdx +71 -0
- package/src/components/ChatPanel.stories.tsx +173 -0
- package/src/components/ChatPanel.tsx +188 -0
- package/src/components/MessageList.stories.tsx +112 -0
- package/src/components/MessageList.test.tsx +57 -0
- package/src/components/MessageList.tsx +144 -0
- package/src/components/OrchestrationPlanCard.stories.tsx +98 -0
- package/src/components/OrchestrationPlanCard.test.tsx +34 -0
- package/src/components/OrchestrationPlanCard.tsx +159 -0
- package/src/components/SSEStreamView.stories.tsx +99 -0
- package/src/components/SSEStreamView.test.tsx +42 -0
- package/src/components/SSEStreamView.tsx +155 -0
- package/src/components/ToolCallRenderer.stories.tsx +152 -0
- package/src/components/ToolCallRenderer.test.tsx +93 -0
- package/src/components/ToolCallRenderer.tsx +213 -0
- package/src/components/cn.ts +10 -0
- package/src/components/index.ts +18 -0
- package/src/hooks/index.ts +21 -0
- package/src/hooks/useAgentSession.mdx +115 -0
- package/src/hooks/useAgentSession.test.ts +127 -0
- package/src/hooks/useAgentSession.ts +181 -0
- package/src/hooks/useChatStream.mdx +129 -0
- package/src/hooks/useChatStream.test.ts +233 -0
- package/src/hooks/useChatStream.ts +341 -0
- package/src/hooks/useToolCallStatus.mdx +102 -0
- package/src/hooks/useToolCallStatus.test.ts +99 -0
- package/src/hooks/useToolCallStatus.ts +99 -0
- package/src/index.ts +12 -0
- package/src/tailwind-preset.ts +109 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MessageList` — render a list of `ChatMessage`s with auto-scroll-to-bottom
|
|
3
|
+
* behaviour. The component is intentionally **headless**: it knows the list
|
|
4
|
+
* shape but lets the caller render every individual bubble.
|
|
5
|
+
*
|
|
6
|
+
* Why a render prop instead of a fixed bubble component? Because every
|
|
7
|
+
* consumer wants to layer additional UI (markdown, search-source pills,
|
|
8
|
+
* mention chips, action renderers) onto the assistant message and we don't
|
|
9
|
+
* want to ship every pluggable variant from `deeppath/apps/web` here.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
13
|
+
import type { ChatMessage } from '@steerable/agent-protocol';
|
|
14
|
+
import { cn } from './cn.js';
|
|
15
|
+
|
|
16
|
+
export interface MessageRendererProps {
|
|
17
|
+
message: ChatMessage;
|
|
18
|
+
index: number;
|
|
19
|
+
isLastAssistant: boolean;
|
|
20
|
+
isStreaming: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MessageListProps {
|
|
24
|
+
messages: ChatMessage[];
|
|
25
|
+
isStreaming?: boolean;
|
|
26
|
+
className?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Bubble renderer. If omitted, `MessageList` falls back to the built-in
|
|
29
|
+
* `DefaultMessageBubble` which just renders role + content.
|
|
30
|
+
*/
|
|
31
|
+
renderMessage?: (props: MessageRendererProps) => React.ReactNode;
|
|
32
|
+
/**
|
|
33
|
+
* If true (default), scrolls the container to the bottom every time the
|
|
34
|
+
* message list grows or the last assistant message gets new content.
|
|
35
|
+
*/
|
|
36
|
+
autoScroll?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Slot rendered above the first message — typically a "start of conversation"
|
|
39
|
+
* marker or a system prompt summary.
|
|
40
|
+
*/
|
|
41
|
+
header?: React.ReactNode;
|
|
42
|
+
/**
|
|
43
|
+
* Slot rendered when `messages` is empty. Production callers usually drop
|
|
44
|
+
* an `<EmptyChat />` here.
|
|
45
|
+
*/
|
|
46
|
+
emptyState?: React.ReactNode;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function DefaultMessageBubble({ message, isLastAssistant, isStreaming }: MessageRendererProps) {
|
|
50
|
+
const isAssistant = message.role === 'assistant';
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
data-role={message.role}
|
|
54
|
+
className={cn(
|
|
55
|
+
'flex w-full',
|
|
56
|
+
isAssistant ? 'justify-start' : 'justify-end',
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
<div
|
|
60
|
+
className={cn(
|
|
61
|
+
'max-w-[80%] rounded-agent-md border px-3 py-2 text-sm leading-relaxed',
|
|
62
|
+
isAssistant
|
|
63
|
+
? 'border-agent-border bg-agent-muted text-agent-foreground'
|
|
64
|
+
: 'border-transparent bg-agent-accent text-agent-accent-foreground',
|
|
65
|
+
)}
|
|
66
|
+
>
|
|
67
|
+
<div className="whitespace-pre-wrap">{message.content || (isLastAssistant && isStreaming ? '…' : '')}</div>
|
|
68
|
+
{isLastAssistant && isStreaming ? (
|
|
69
|
+
<span
|
|
70
|
+
aria-hidden
|
|
71
|
+
className="ml-0.5 inline-block h-3 w-1.5 align-baseline bg-agent-foreground animate-agent-cursor-blink"
|
|
72
|
+
/>
|
|
73
|
+
) : null}
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function MessageList(props: MessageListProps) {
|
|
80
|
+
const {
|
|
81
|
+
messages,
|
|
82
|
+
isStreaming = false,
|
|
83
|
+
className,
|
|
84
|
+
renderMessage,
|
|
85
|
+
autoScroll = true,
|
|
86
|
+
header,
|
|
87
|
+
emptyState,
|
|
88
|
+
} = props;
|
|
89
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
90
|
+
|
|
91
|
+
const lastAssistantIndex = useMemo(() => {
|
|
92
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
93
|
+
if (messages[i].role === 'assistant') return i;
|
|
94
|
+
}
|
|
95
|
+
return -1;
|
|
96
|
+
}, [messages]);
|
|
97
|
+
|
|
98
|
+
// Scroll to bottom on growth or when the streaming assistant gets longer.
|
|
99
|
+
const lastAssistantContent =
|
|
100
|
+
lastAssistantIndex >= 0 ? messages[lastAssistantIndex].content : '';
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!autoScroll) return;
|
|
104
|
+
const el = containerRef.current;
|
|
105
|
+
if (!el) return;
|
|
106
|
+
el.scrollTop = el.scrollHeight;
|
|
107
|
+
}, [autoScroll, messages.length, lastAssistantContent]);
|
|
108
|
+
|
|
109
|
+
const Renderer = renderMessage ?? DefaultMessageBubble;
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div
|
|
113
|
+
ref={containerRef}
|
|
114
|
+
className={cn(
|
|
115
|
+
'flex h-full w-full flex-col gap-3 overflow-y-auto bg-agent-canvas px-4 py-3',
|
|
116
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-agent-accent',
|
|
117
|
+
className,
|
|
118
|
+
)}
|
|
119
|
+
role="log"
|
|
120
|
+
aria-live="polite"
|
|
121
|
+
aria-label="Conversation"
|
|
122
|
+
tabIndex={0}
|
|
123
|
+
>
|
|
124
|
+
{header}
|
|
125
|
+
{messages.length === 0 ? (
|
|
126
|
+
emptyState ?? (
|
|
127
|
+
<div className="m-auto text-sm text-agent-muted-foreground">
|
|
128
|
+
No messages yet.
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
) : (
|
|
132
|
+
messages.map((message, index) => (
|
|
133
|
+
<Renderer
|
|
134
|
+
key={message.id ?? index}
|
|
135
|
+
message={message}
|
|
136
|
+
index={index}
|
|
137
|
+
isLastAssistant={index === lastAssistantIndex}
|
|
138
|
+
isStreaming={isStreaming}
|
|
139
|
+
/>
|
|
140
|
+
))
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { OrchestrationPlanCard, type OrchestrationStep } from './OrchestrationPlanCard.js';
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof OrchestrationPlanCard> = {
|
|
5
|
+
title: 'Components/OrchestrationPlanCard',
|
|
6
|
+
component: OrchestrationPlanCard,
|
|
7
|
+
parameters: {
|
|
8
|
+
layout: 'centered',
|
|
9
|
+
actions: { argTypesRegex: '^on[A-Z].*' },
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
'Read-only multi-step plan with per-step status icons and a header summary. Status values are `pending` / `running` / `done` / `error` / `skipped`. Pass `onStepClick` to make rows interactive (used in production to open a step\u2019s trace timeline in a side panel).',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
args: {
|
|
18
|
+
title: 'Plan',
|
|
19
|
+
},
|
|
20
|
+
decorators: [
|
|
21
|
+
(Story) => (
|
|
22
|
+
<div className="w-[480px]">
|
|
23
|
+
<Story />
|
|
24
|
+
</div>
|
|
25
|
+
),
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export default meta;
|
|
30
|
+
type Story = StoryObj<typeof OrchestrationPlanCard>;
|
|
31
|
+
|
|
32
|
+
const steps: OrchestrationStep[] = [
|
|
33
|
+
{ id: 's1', title: 'Parse user request', status: 'done', kind: 'decision' },
|
|
34
|
+
{
|
|
35
|
+
id: 's2',
|
|
36
|
+
title: 'Look up matching projects',
|
|
37
|
+
status: 'done',
|
|
38
|
+
kind: 'tool',
|
|
39
|
+
description: 'web_search → project_index',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 's3',
|
|
43
|
+
title: 'Draft proposal outline',
|
|
44
|
+
status: 'running',
|
|
45
|
+
kind: 'agent:writer',
|
|
46
|
+
},
|
|
47
|
+
{ id: 's4', title: 'Estimate budget', status: 'pending', kind: 'tool' },
|
|
48
|
+
{ id: 's5', title: 'Send for review', status: 'pending', kind: 'wait' },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
export const InProgress: Story = {
|
|
52
|
+
args: {
|
|
53
|
+
steps,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const AllDone: Story = {
|
|
58
|
+
args: {
|
|
59
|
+
steps: steps.map((s) => ({ ...s, status: 'done' as const, finishedAt: '15:32' })),
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const WithError: Story = {
|
|
64
|
+
args: {
|
|
65
|
+
steps: [
|
|
66
|
+
...steps.slice(0, 2),
|
|
67
|
+
{
|
|
68
|
+
...steps[2],
|
|
69
|
+
status: 'error',
|
|
70
|
+
description: 'writer_agent rejected: missing required tone parameter',
|
|
71
|
+
},
|
|
72
|
+
...steps.slice(3),
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const SkippedBranch: Story = {
|
|
78
|
+
args: {
|
|
79
|
+
steps: [
|
|
80
|
+
...steps.slice(0, 3).map((s) => ({ ...s, status: 'done' as const })),
|
|
81
|
+
{ ...steps[3], status: 'skipped' as const, description: 'no budget needed' },
|
|
82
|
+
{ ...steps[4], status: 'done' as const, finishedAt: '15:55' },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const Empty: Story = {
|
|
88
|
+
args: {
|
|
89
|
+
steps: [],
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const Interactive: Story = {
|
|
94
|
+
args: {
|
|
95
|
+
steps,
|
|
96
|
+
onStepClick: () => {},
|
|
97
|
+
},
|
|
98
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
OrchestrationPlanCard,
|
|
5
|
+
type OrchestrationStep,
|
|
6
|
+
} from './OrchestrationPlanCard';
|
|
7
|
+
|
|
8
|
+
const steps: OrchestrationStep[] = [
|
|
9
|
+
{ id: '1', title: 'Plan trip', status: 'done', kind: 'agent:planner' },
|
|
10
|
+
{ id: '2', title: 'Book flights', status: 'running', kind: 'tool' },
|
|
11
|
+
{ id: '3', title: 'Reserve hotel', status: 'pending' },
|
|
12
|
+
{ id: '4', title: 'Email itinerary', status: 'error', description: 'SMTP timeout' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
describe('OrchestrationPlanCard', () => {
|
|
16
|
+
it('renders the right summary count + each step with status data attribute', () => {
|
|
17
|
+
render(<OrchestrationPlanCard steps={steps} />);
|
|
18
|
+
expect(screen.getByText('1/4')).toBeTruthy();
|
|
19
|
+
expect(screen.getByText('1 error')).toBeTruthy();
|
|
20
|
+
expect(screen.getByText('Plan trip')).toBeTruthy();
|
|
21
|
+
expect(screen.getByText('SMTP timeout')).toBeTruthy();
|
|
22
|
+
const errorRow = screen.getByText('Email itinerary').closest('[data-status]');
|
|
23
|
+
expect(errorRow?.getAttribute('data-status')).toBe('error');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('makes rows clickable when onStepClick is provided and reports the right step', () => {
|
|
27
|
+
const onStepClick = vi.fn();
|
|
28
|
+
render(<OrchestrationPlanCard steps={steps} onStepClick={onStepClick} />);
|
|
29
|
+
fireEvent.click(screen.getByText('Book flights'));
|
|
30
|
+
expect(onStepClick).toHaveBeenCalledWith(
|
|
31
|
+
expect.objectContaining({ id: '2', status: 'running' }),
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `OrchestrationPlanCard` — render a multi-step plan with per-step status.
|
|
3
|
+
*
|
|
4
|
+
* Maps to the `orchestration` SSE event family: the harness emits a structured
|
|
5
|
+
* plan up-front and updates step status as execution progresses. The card is
|
|
6
|
+
* intentionally read-only; consumers that want inline edit (`deeppath/apps/web`'s
|
|
7
|
+
* "PlanSteps") should compose their own editor using `useChatStream`'s
|
|
8
|
+
* `onUnknownEvent` hook to track plan events and feed updated steps in here.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useMemo } from 'react';
|
|
12
|
+
import { cn } from './cn.js';
|
|
13
|
+
|
|
14
|
+
export type OrchestrationStepStatus =
|
|
15
|
+
| 'pending'
|
|
16
|
+
| 'running'
|
|
17
|
+
| 'done'
|
|
18
|
+
| 'error'
|
|
19
|
+
| 'skipped';
|
|
20
|
+
|
|
21
|
+
export interface OrchestrationStep {
|
|
22
|
+
id: string;
|
|
23
|
+
title: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
status: OrchestrationStepStatus;
|
|
26
|
+
/** Optional badge (`tool`, `wait`, `decision`, `agent:<name>`, …). */
|
|
27
|
+
kind?: string;
|
|
28
|
+
/** Optional ISO datetime when the step finished, used for the right column. */
|
|
29
|
+
finishedAt?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface OrchestrationPlanCardProps {
|
|
33
|
+
title?: string;
|
|
34
|
+
steps: OrchestrationStep[];
|
|
35
|
+
className?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Optional click handler — useful when you want to open a step's trace
|
|
38
|
+
* timeline in a side panel. Receives the step that was clicked.
|
|
39
|
+
*/
|
|
40
|
+
onStepClick?: (step: OrchestrationStep) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const statusToIcon: Record<OrchestrationStepStatus, string> = {
|
|
44
|
+
pending: '○',
|
|
45
|
+
running: '◐',
|
|
46
|
+
done: '●',
|
|
47
|
+
error: '✕',
|
|
48
|
+
skipped: '—',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const statusToColor: Record<OrchestrationStepStatus, string> = {
|
|
52
|
+
pending: 'text-agent-muted-foreground',
|
|
53
|
+
running: 'text-agent-accent',
|
|
54
|
+
done: 'text-agent-tool-read',
|
|
55
|
+
error: 'text-agent-destructive',
|
|
56
|
+
skipped: 'text-agent-muted-foreground line-through',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function OrchestrationPlanCard(props: OrchestrationPlanCardProps) {
|
|
60
|
+
const { title = 'Plan', steps, className, onStepClick } = props;
|
|
61
|
+
|
|
62
|
+
const summary = useMemo(() => {
|
|
63
|
+
const total = steps.length;
|
|
64
|
+
const done = steps.filter((s) => s.status === 'done').length;
|
|
65
|
+
const errored = steps.filter((s) => s.status === 'error').length;
|
|
66
|
+
return { total, done, errored };
|
|
67
|
+
}, [steps]);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div
|
|
71
|
+
className={cn(
|
|
72
|
+
'rounded-agent-lg border border-agent-border bg-agent-canvas p-4',
|
|
73
|
+
className,
|
|
74
|
+
)}
|
|
75
|
+
data-testid="orchestration-plan-card"
|
|
76
|
+
>
|
|
77
|
+
<div className="mb-3 flex items-center justify-between">
|
|
78
|
+
<div className="flex items-center gap-2">
|
|
79
|
+
<span className="text-sm font-semibold text-agent-foreground">
|
|
80
|
+
{title}
|
|
81
|
+
</span>
|
|
82
|
+
<span className="rounded-full border border-agent-border px-2 py-0.5 text-[10px] uppercase tracking-wide text-agent-muted-foreground">
|
|
83
|
+
{summary.done}/{summary.total}
|
|
84
|
+
</span>
|
|
85
|
+
</div>
|
|
86
|
+
{summary.errored > 0 ? (
|
|
87
|
+
<span className="text-[11px] font-medium text-agent-destructive">
|
|
88
|
+
{summary.errored} error{summary.errored === 1 ? '' : 's'}
|
|
89
|
+
</span>
|
|
90
|
+
) : null}
|
|
91
|
+
</div>
|
|
92
|
+
<ol className="space-y-1.5">
|
|
93
|
+
{steps.map((step, idx) => {
|
|
94
|
+
const interactive = Boolean(onStepClick);
|
|
95
|
+
const Tag: 'button' | 'div' = interactive ? 'button' : 'div';
|
|
96
|
+
return (
|
|
97
|
+
<li key={step.id}>
|
|
98
|
+
<Tag
|
|
99
|
+
{...(interactive
|
|
100
|
+
? {
|
|
101
|
+
type: 'button' as const,
|
|
102
|
+
onClick: () => onStepClick?.(step),
|
|
103
|
+
}
|
|
104
|
+
: {})}
|
|
105
|
+
className={cn(
|
|
106
|
+
'flex w-full items-start gap-2 rounded-agent-sm px-2 py-1.5 text-left',
|
|
107
|
+
interactive
|
|
108
|
+
? 'transition-colors hover:bg-agent-muted'
|
|
109
|
+
: '',
|
|
110
|
+
)}
|
|
111
|
+
data-status={step.status}
|
|
112
|
+
>
|
|
113
|
+
<span
|
|
114
|
+
className={cn(
|
|
115
|
+
'mt-0.5 select-none font-mono text-base leading-none',
|
|
116
|
+
statusToColor[step.status],
|
|
117
|
+
)}
|
|
118
|
+
aria-hidden
|
|
119
|
+
>
|
|
120
|
+
{statusToIcon[step.status]}
|
|
121
|
+
</span>
|
|
122
|
+
<div className="min-w-0 flex-1">
|
|
123
|
+
<div className="flex items-center gap-2">
|
|
124
|
+
<span className="truncate text-xs font-mono text-agent-muted-foreground">
|
|
125
|
+
{(idx + 1).toString().padStart(2, '0')}
|
|
126
|
+
</span>
|
|
127
|
+
<span
|
|
128
|
+
className={cn(
|
|
129
|
+
'truncate text-sm font-medium',
|
|
130
|
+
statusToColor[step.status],
|
|
131
|
+
)}
|
|
132
|
+
>
|
|
133
|
+
{step.title}
|
|
134
|
+
</span>
|
|
135
|
+
{step.kind ? (
|
|
136
|
+
<span className="shrink-0 rounded-full border border-agent-border bg-agent-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-agent-muted-foreground">
|
|
137
|
+
{step.kind}
|
|
138
|
+
</span>
|
|
139
|
+
) : null}
|
|
140
|
+
</div>
|
|
141
|
+
{step.description ? (
|
|
142
|
+
<p className="mt-0.5 text-xs text-agent-muted-foreground">
|
|
143
|
+
{step.description}
|
|
144
|
+
</p>
|
|
145
|
+
) : null}
|
|
146
|
+
</div>
|
|
147
|
+
{step.finishedAt ? (
|
|
148
|
+
<span className="shrink-0 text-[10px] text-agent-muted-foreground">
|
|
149
|
+
{step.finishedAt}
|
|
150
|
+
</span>
|
|
151
|
+
) : null}
|
|
152
|
+
</Tag>
|
|
153
|
+
</li>
|
|
154
|
+
);
|
|
155
|
+
})}
|
|
156
|
+
</ol>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import type { SSEEvent } from '@steerable/agent-protocol';
|
|
3
|
+
import { SSEStreamView } from './SSEStreamView.js';
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof SSEStreamView> = {
|
|
6
|
+
title: 'Components/SSEStreamView',
|
|
7
|
+
component: SSEStreamView,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: 'centered',
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
'Append-only debug view of the raw SSE event log. Used in production for the "Trace" tab in `deeppath/apps/web` and as a dev console inside `deeppath-agent`. Toggle `verbose` to inspect full JSON payloads, or pass `filterTypes` to narrow to a single event family.',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
decorators: [
|
|
18
|
+
(Story) => (
|
|
19
|
+
<div className="h-[400px] w-[640px]">
|
|
20
|
+
<Story />
|
|
21
|
+
</div>
|
|
22
|
+
),
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default meta;
|
|
27
|
+
type Story = StoryObj<typeof SSEStreamView>;
|
|
28
|
+
|
|
29
|
+
const events: SSEEvent[] = [
|
|
30
|
+
{ type: 'agent', agentId: 'planner', model: 'gpt-4o-mini' },
|
|
31
|
+
{
|
|
32
|
+
type: 'orchestration',
|
|
33
|
+
orchestrationGroupId: 'plan-1',
|
|
34
|
+
payload: { phase: 'plan_started' },
|
|
35
|
+
},
|
|
36
|
+
{ type: 'content', content: 'Looking up the latest project status…' },
|
|
37
|
+
{
|
|
38
|
+
type: 'tool_call',
|
|
39
|
+
payload: {
|
|
40
|
+
callId: 'call_1',
|
|
41
|
+
name: 'project_search',
|
|
42
|
+
arguments: { query: 'Q4 roadmap', limit: 5 },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
type: 'tool_result',
|
|
47
|
+
payload: { callId: 'call_1', name: 'project_search', status: 'success', count: 3 },
|
|
48
|
+
},
|
|
49
|
+
{ type: 'content', content: 'Found 3 active projects. Drafting summary…' },
|
|
50
|
+
{ type: 'loader-hint', hint: 'Composing response…' },
|
|
51
|
+
{ type: 'content', content: 'Here are the highlights:\n1. Project Alpha\n2. Project Beta' },
|
|
52
|
+
{ type: 'done' },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
export const Empty: Story = {
|
|
56
|
+
args: {
|
|
57
|
+
events: [],
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const Default: Story = {
|
|
62
|
+
args: {
|
|
63
|
+
events,
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const Verbose: Story = {
|
|
68
|
+
args: {
|
|
69
|
+
events,
|
|
70
|
+
verbose: true,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const FilteredToolEvents: Story = {
|
|
75
|
+
args: {
|
|
76
|
+
events,
|
|
77
|
+
filterTypes: ['tool_call', 'tool_result'],
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const ErrorAndBudget: Story = {
|
|
82
|
+
args: {
|
|
83
|
+
events: [
|
|
84
|
+
...events.slice(0, 5),
|
|
85
|
+
{ type: 'budget_exhausted', message: 'token budget reached after 3.2k tokens' },
|
|
86
|
+
{ type: 'error', message: 'tool_dispatch failed: timeout after 30s' },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const HighVolume: Story = {
|
|
92
|
+
args: {
|
|
93
|
+
events: Array.from({ length: 200 }, (_, i): SSEEvent => {
|
|
94
|
+
if (i % 13 === 0) return { type: 'tool_call', payload: { callId: `c${i}`, name: 'noop' } };
|
|
95
|
+
if (i % 13 === 6) return { type: 'tool_result', payload: { callId: `c${i - 6}`, status: 'success' } };
|
|
96
|
+
return { type: 'content', content: `chunk ${i}: ` + 'lorem ipsum dolor sit amet '.repeat(2) };
|
|
97
|
+
}),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import type { SSEEvent } from '@steerable/agent-protocol';
|
|
4
|
+
import { SSEStreamView } from './SSEStreamView';
|
|
5
|
+
|
|
6
|
+
const events: SSEEvent[] = [
|
|
7
|
+
{ type: 'content', content: 'Hello world' },
|
|
8
|
+
{
|
|
9
|
+
type: 'tool_call',
|
|
10
|
+
payload: { id: 'c1', name: 'get_weather', arguments: { city: 'SF' } },
|
|
11
|
+
},
|
|
12
|
+
{ type: 'tool_result', payload: { success: true } },
|
|
13
|
+
{ type: 'done' },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
describe('SSEStreamView', () => {
|
|
17
|
+
it('renders one row per event with the type label uppercase', () => {
|
|
18
|
+
render(<SSEStreamView events={events} />);
|
|
19
|
+
expect(screen.getByText('content')).toBeTruthy();
|
|
20
|
+
expect(screen.getByText('tool_call')).toBeTruthy();
|
|
21
|
+
expect(screen.getByText('tool_result')).toBeTruthy();
|
|
22
|
+
expect(screen.getByText('done')).toBeTruthy();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('filters by type when filterTypes is provided', () => {
|
|
26
|
+
render(<SSEStreamView events={events} filterTypes={['tool_call', 'tool_result']} />);
|
|
27
|
+
expect(screen.queryByText('content')).toBeNull();
|
|
28
|
+
expect(screen.queryByText('done')).toBeNull();
|
|
29
|
+
expect(screen.getByText('tool_call')).toBeTruthy();
|
|
30
|
+
expect(screen.getByText('tool_result')).toBeTruthy();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('renders the empty state when there are no events', () => {
|
|
34
|
+
render(<SSEStreamView events={[]} emptyState={<span>(idle)</span>} />);
|
|
35
|
+
expect(screen.getByText('(idle)')).toBeTruthy();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('renders pretty-printed JSON in verbose mode', () => {
|
|
39
|
+
render(<SSEStreamView events={events.slice(1, 2)} verbose />);
|
|
40
|
+
expect(screen.getByText(/"name": "get_weather"/)).toBeTruthy();
|
|
41
|
+
});
|
|
42
|
+
});
|