@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
package/package.json
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@steerable/agent-ui",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Steerable framework Tier 4 — headless React hooks (useAgentSession, useChatStream, useToolCallStatus) and Tailwind-themed components (ChatPanel, MessageList, OrchestrationPlanCard, ToolCallRenderer, SSEStreamView) for embedding Steerable agents in any React app.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"homepage": "https://pathlyapp.github.io/steerable-framework/storybook/",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/pathlyapp/steerable-framework.git",
|
|
10
|
+
"directory": "packages/agent-ui/ts"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/pathlyapp/steerable-framework/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"steerable",
|
|
17
|
+
"agent",
|
|
18
|
+
"ui",
|
|
19
|
+
"react",
|
|
20
|
+
"hooks",
|
|
21
|
+
"chat",
|
|
22
|
+
"sse",
|
|
23
|
+
"tailwind"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "dist/index.js",
|
|
27
|
+
"types": "dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./tailwind-preset": {
|
|
34
|
+
"types": "./dist/tailwind-preset.d.ts",
|
|
35
|
+
"import": "./dist/tailwind-preset.js"
|
|
36
|
+
},
|
|
37
|
+
"./hooks": {
|
|
38
|
+
"types": "./dist/hooks/index.d.ts",
|
|
39
|
+
"import": "./dist/hooks/index.js"
|
|
40
|
+
},
|
|
41
|
+
"./components": {
|
|
42
|
+
"types": "./dist/components/index.d.ts",
|
|
43
|
+
"import": "./dist/components/index.js"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"dist",
|
|
48
|
+
"src",
|
|
49
|
+
"README.md"
|
|
50
|
+
],
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@steerable/agent-protocol": "0.2.0"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"react": ">=18.0.0",
|
|
56
|
+
"react-dom": ">=18.0.0"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@playwright/test": "^1.60.0",
|
|
60
|
+
"@storybook/addon-a11y": "^8.6.18",
|
|
61
|
+
"@storybook/addon-essentials": "^8.6.14",
|
|
62
|
+
"@storybook/addon-interactions": "^8.6.14",
|
|
63
|
+
"@storybook/react": "^8.6.18",
|
|
64
|
+
"@storybook/react-vite": "^8.6.18",
|
|
65
|
+
"@storybook/test": "^8.6.15",
|
|
66
|
+
"@storybook/test-runner": "^0.21.3",
|
|
67
|
+
"@tailwindcss/vite": "^4.3.0",
|
|
68
|
+
"@testing-library/react": "^16.1.0",
|
|
69
|
+
"@types/react": "^18.3.12",
|
|
70
|
+
"@types/react-dom": "^18.3.1",
|
|
71
|
+
"@vitejs/plugin-react": "^4.7.0",
|
|
72
|
+
"axe-playwright": "^2.2.2",
|
|
73
|
+
"happy-dom": "^15.11.7",
|
|
74
|
+
"react": "^18.3.1",
|
|
75
|
+
"react-dom": "^18.3.1",
|
|
76
|
+
"storybook": "^8.6.18",
|
|
77
|
+
"tailwindcss": "^4.3.0",
|
|
78
|
+
"typescript": "^5.8.3",
|
|
79
|
+
"vite": "^5.4.21",
|
|
80
|
+
"vitest": "^3.2.4"
|
|
81
|
+
},
|
|
82
|
+
"publishConfig": {
|
|
83
|
+
"access": "public",
|
|
84
|
+
"provenance": true
|
|
85
|
+
},
|
|
86
|
+
"scripts": {
|
|
87
|
+
"build": "tsc -p tsconfig.json",
|
|
88
|
+
"test": "vitest run",
|
|
89
|
+
"lint": "tsc -p tsconfig.json --noEmit",
|
|
90
|
+
"storybook": "storybook dev -p 6006 --no-open",
|
|
91
|
+
"storybook:build": "storybook build -o storybook-static",
|
|
92
|
+
"storybook:test": "test-storybook --url http://127.0.0.1:6006",
|
|
93
|
+
"storybook:vrt": "playwright test --config playwright.config.ts",
|
|
94
|
+
"storybook:vrt:update": "playwright test --config playwright.config.ts --update-snapshots",
|
|
95
|
+
"storybook:ci": "pnpm storybook:build && pnpm storybook:vrt"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Meta } from '@storybook/blocks';
|
|
2
|
+
|
|
3
|
+
<Meta title="Introduction" />
|
|
4
|
+
|
|
5
|
+
# @steerable/agent-ui
|
|
6
|
+
|
|
7
|
+
> Headless React building blocks for Steerable agent UIs — chat shells, tool-call cards, orchestration plans, raw SSE inspectors, plus the hooks that wire them to any transport.
|
|
8
|
+
|
|
9
|
+
This package ships the visual surface area you need to drop a Steerable agent into a React app, plus the lifecycle hooks that own message state and tool-call status. Components are intentionally **headless**: they render through Tailwind utility classes built on a small set of `--agent-*` design tokens, so consuming apps can theme them, replace any subtree, or compose them into bespoke layouts without forking.
|
|
10
|
+
|
|
11
|
+
## What's in this Storybook
|
|
12
|
+
|
|
13
|
+
* **Components** — visual primitives:
|
|
14
|
+
* [`ChatPanel`](?path=/docs/components-chatpanel--docs) — the foundational chat shell (`MessageList` + composer + stop button).
|
|
15
|
+
* [`MessageList`](?path=/docs/components-messagelist--docs) — auto-scrolling list with a render-prop bubble.
|
|
16
|
+
* [`OrchestrationPlanCard`](?path=/docs/components-orchestrationplancard--docs) — multi-step plan with per-step status.
|
|
17
|
+
* [`ToolCallRenderer`](?path=/docs/components-toolcallrenderer--docs) — tool-call card with mode badges, approval, expandable args/result.
|
|
18
|
+
* [`SSEStreamView`](?path=/docs/components-ssestreamview--docs) — append-only SSE event log, useful in trace tabs and dev consoles.
|
|
19
|
+
* **Hooks** — transport-agnostic state:
|
|
20
|
+
* [`useChatStream`](?path=/docs/hooks-usechatstream--docs) — owns message state and translates `SSEEvent` into `ChatMessage` mutations.
|
|
21
|
+
* [`useToolCallStatus`](?path=/docs/hooks-usetoolcallstatus--docs) — derives status + mode + approval requirements for a single tool call.
|
|
22
|
+
* [`useAgentSession`](?path=/docs/hooks-useagentsession--docs) — wraps create / resume / list against any session storage adapter.
|
|
23
|
+
|
|
24
|
+
## Installation (consuming app)
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pnpm add @steerable/agent-ui @steerable/agent-protocol react react-dom
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
If you use Tailwind v3, register the preset:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// tailwind.config.ts
|
|
34
|
+
import preset from '@steerable/agent-ui/tailwind-preset';
|
|
35
|
+
export default {
|
|
36
|
+
presets: [preset],
|
|
37
|
+
content: [
|
|
38
|
+
'./src/**/*.{ts,tsx}',
|
|
39
|
+
'./node_modules/@steerable/agent-ui/dist/**/*.{js,mjs}',
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For Tailwind v4 callers, mirror the design tokens with `@theme` (see `apps/web/src/app/globals.css` in `deeppath` for an in-tree example, and `packages/agent-ui/ts/.storybook/preview.css` here for a minimal copy you can paste).
|
|
45
|
+
|
|
46
|
+
## Design philosophy
|
|
47
|
+
|
|
48
|
+
1. **Headless first.** Every component takes a render prop or accepts `children` for the parts a real app wants to customise (markdown rendering, mention chips, search-source pills, …). Defaults are dumb on purpose so you don't end up forking the whole component to swap one row.
|
|
49
|
+
2. **One token vocabulary.** All colors, radii, and animations come from `--agent-*` variables. Themes ship as CSS overrides, never as React props.
|
|
50
|
+
3. **Transport-agnostic hooks.** `useChatStream` and friends never speak HTTP or know which provider serves the events — they call into a transport you pass in. This is what lets `deeppath/apps/web` and `deeppath-agent` share the same chat surface while one talks to FastAPI/SSE and the other talks to the embedded sidecar over IPC.
|
|
51
|
+
4. **Mirror the framework spec.** `ChatMessage`, `ToolCall`, `ToolResult`, `SSEEvent`, `AgentSession` are imported from `@steerable/agent-protocol`, which is generated from the JSON schemas in `spec/`. The TS and Py worlds stay lock-step.
|
|
52
|
+
|
|
53
|
+
## Working with this Storybook locally
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
cd packages/agent-ui/ts
|
|
57
|
+
pnpm storybook # http://localhost:6006
|
|
58
|
+
pnpm storybook:build # static bundle in storybook-static/
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
To regenerate visual snapshots (Phase 6, Playwright VRT):
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pnpm storybook:vrt --update-snapshots
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
To run the headless mount + a11y test suite:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pnpm storybook:test # expects pnpm storybook to be running on :6006
|
|
71
|
+
```
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import type { ChatMessage } from '@steerable/agent-protocol';
|
|
4
|
+
import { ChatPanel } from './ChatPanel.js';
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof ChatPanel> = {
|
|
7
|
+
title: 'Components/ChatPanel',
|
|
8
|
+
component: ChatPanel,
|
|
9
|
+
parameters: {
|
|
10
|
+
layout: 'fullscreen',
|
|
11
|
+
actions: { argTypesRegex: '^on[A-Z].*' },
|
|
12
|
+
docs: {
|
|
13
|
+
description: {
|
|
14
|
+
component:
|
|
15
|
+
'Foundational orchestration shell. Renders a `MessageList` plus a minimal input row and a stop button. Production callers typically wrap it with their own header/composer and only feed in `messages` + `onSubmit`.',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
args: {
|
|
20
|
+
onSubmit: () => {},
|
|
21
|
+
inputPlaceholder: 'Send a message…',
|
|
22
|
+
},
|
|
23
|
+
decorators: [
|
|
24
|
+
(Story) => (
|
|
25
|
+
<div className="h-[640px] w-full max-w-3xl border border-agent-border bg-agent-canvas">
|
|
26
|
+
<Story />
|
|
27
|
+
</div>
|
|
28
|
+
),
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export default meta;
|
|
33
|
+
type Story = StoryObj<typeof ChatPanel>;
|
|
34
|
+
|
|
35
|
+
const NOW = '2026-05-15T00:00:00Z';
|
|
36
|
+
|
|
37
|
+
const sampleMessages: ChatMessage[] = [
|
|
38
|
+
{
|
|
39
|
+
id: 'm-1',
|
|
40
|
+
role: 'user',
|
|
41
|
+
content: 'Plan a 3-day trip to Tokyo for a first-time visitor.',
|
|
42
|
+
createdAt: NOW,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'm-2',
|
|
46
|
+
role: 'assistant',
|
|
47
|
+
content:
|
|
48
|
+
"Sure — here's a balanced 3-day plan covering culture, food and a half-day trip outside the city. I'll list each day with morning/afternoon/evening blocks.",
|
|
49
|
+
createdAt: NOW,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'm-3',
|
|
53
|
+
role: 'user',
|
|
54
|
+
content: 'Great. Also include a vegetarian dinner each night.',
|
|
55
|
+
createdAt: NOW,
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
export const Empty: Story = {
|
|
60
|
+
args: {
|
|
61
|
+
messages: [],
|
|
62
|
+
emptyState: (
|
|
63
|
+
<div className="m-auto max-w-sm text-center text-sm text-agent-muted-foreground">
|
|
64
|
+
<p className="font-medium text-agent-foreground">Start a new conversation</p>
|
|
65
|
+
<p className="mt-1">Ask anything — I'll plan, search and call tools as needed.</p>
|
|
66
|
+
</div>
|
|
67
|
+
),
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const WithMessages: Story = {
|
|
72
|
+
args: {
|
|
73
|
+
messages: sampleMessages,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const Streaming: Story = {
|
|
78
|
+
args: {
|
|
79
|
+
messages: [
|
|
80
|
+
...sampleMessages,
|
|
81
|
+
{
|
|
82
|
+
id: 'm-4',
|
|
83
|
+
role: 'assistant',
|
|
84
|
+
content: "Day 1 — Asakusa & Akihabara. Morning: visit Sensō-ji",
|
|
85
|
+
createdAt: NOW,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
isStreaming: true,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const Disabled: Story = {
|
|
93
|
+
args: {
|
|
94
|
+
messages: sampleMessages,
|
|
95
|
+
disabled: true,
|
|
96
|
+
inputPlaceholder: 'Loading conversation…',
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const WithToolCalls: Story = {
|
|
101
|
+
args: {
|
|
102
|
+
messages: [
|
|
103
|
+
sampleMessages[0],
|
|
104
|
+
{
|
|
105
|
+
id: 'm-tc',
|
|
106
|
+
role: 'assistant',
|
|
107
|
+
content: 'Looking up current restaurant options in Tokyo for you.',
|
|
108
|
+
createdAt: NOW,
|
|
109
|
+
toolCalls: [
|
|
110
|
+
{
|
|
111
|
+
id: 'call-1',
|
|
112
|
+
name: 'web_search',
|
|
113
|
+
arguments: { query: 'best vegetarian restaurants Shibuya 2026' },
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
toolResult: {
|
|
117
|
+
success: true,
|
|
118
|
+
message: 'Found 2 results.',
|
|
119
|
+
data: {
|
|
120
|
+
hits: [
|
|
121
|
+
{ title: 'T\u2019s Tantan (Shibuya)', url: 'https://example.com' },
|
|
122
|
+
{ title: 'AIN SOPH. Soar', url: 'https://example.com' },
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Demonstrates the full controlled pattern: parent owns the message list,
|
|
133
|
+
* appends a new turn on submit, and toggles `isStreaming` while the assistant
|
|
134
|
+
* is "responding". Useful for sandbox-testing custom renderers.
|
|
135
|
+
*/
|
|
136
|
+
export const Interactive: Story = {
|
|
137
|
+
args: {
|
|
138
|
+
messages: sampleMessages,
|
|
139
|
+
},
|
|
140
|
+
render: (args) => {
|
|
141
|
+
const InteractiveChat = () => {
|
|
142
|
+
const [messages, setMessages] = useState<ChatMessage[]>(args.messages);
|
|
143
|
+
const [streaming, setStreaming] = useState(false);
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<ChatPanel
|
|
147
|
+
{...args}
|
|
148
|
+
messages={messages}
|
|
149
|
+
isStreaming={streaming}
|
|
150
|
+
onSubmit={async ({ content }) => {
|
|
151
|
+
setMessages((prev) => [
|
|
152
|
+
...prev,
|
|
153
|
+
{ id: `u-${prev.length}`, role: 'user', content, createdAt: new Date().toISOString() },
|
|
154
|
+
]);
|
|
155
|
+
setStreaming(true);
|
|
156
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
157
|
+
setMessages((prev) => [
|
|
158
|
+
...prev,
|
|
159
|
+
{
|
|
160
|
+
id: `a-${prev.length}`,
|
|
161
|
+
role: 'assistant',
|
|
162
|
+
content: `Echo: ${content}`,
|
|
163
|
+
createdAt: new Date().toISOString(),
|
|
164
|
+
},
|
|
165
|
+
]);
|
|
166
|
+
setStreaming(false);
|
|
167
|
+
}}
|
|
168
|
+
/>
|
|
169
|
+
);
|
|
170
|
+
};
|
|
171
|
+
return <InteractiveChat />;
|
|
172
|
+
},
|
|
173
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ChatPanel` — the foundational orchestration shell.
|
|
3
|
+
*
|
|
4
|
+
* Composes `MessageList` + a minimal input row + a stop button. The component
|
|
5
|
+
* is intentionally minimal so consumers can layer on the production-grade
|
|
6
|
+
* extras (model selector, tool toggles, mention picker, slash commands, file
|
|
7
|
+
* upload, automation alerts, share-image button, multi-tab agent switcher,
|
|
8
|
+
* orchestration sidebar) without having to fork the chat itself.
|
|
9
|
+
*
|
|
10
|
+
* Production callers in `deeppath/apps/web` will build their fancier
|
|
11
|
+
* `<ChatHeader />` and `<ChatInput />` separately and feed `ChatPanel` only
|
|
12
|
+
* the `messages` + `onSubmit` props — the rest is rendered around it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useCallback, useState } from 'react';
|
|
16
|
+
import type { ChatMessage, ToolCall, ToolResult } from '@steerable/agent-protocol';
|
|
17
|
+
import { MessageList, type MessageRendererProps } from './MessageList.js';
|
|
18
|
+
import { ToolCallRenderer } from './ToolCallRenderer.js';
|
|
19
|
+
import { cn } from './cn.js';
|
|
20
|
+
|
|
21
|
+
export interface ChatPanelProps {
|
|
22
|
+
messages: ChatMessage[];
|
|
23
|
+
isStreaming?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Called when the user submits the input. Receives the trimmed string.
|
|
26
|
+
* The parent is responsible for calling `useChatStream().sendUserMessage`.
|
|
27
|
+
*/
|
|
28
|
+
onSubmit: (input: { content: string }) => void | Promise<void>;
|
|
29
|
+
onCancel?: () => void;
|
|
30
|
+
className?: string;
|
|
31
|
+
inputPlaceholder?: string;
|
|
32
|
+
/** Slot rendered above the message list (header, agent picker, …). */
|
|
33
|
+
header?: React.ReactNode;
|
|
34
|
+
/**
|
|
35
|
+
* Tool-call renderer. Default uses the framework's `ToolCallRenderer`;
|
|
36
|
+
* pass `() => null` to suppress, or your own component to inject a
|
|
37
|
+
* domain-specific renderer (CIFLog cards, SQL query previews, …).
|
|
38
|
+
*/
|
|
39
|
+
renderToolCall?: (call: ToolCall, result?: ToolResult) => React.ReactNode;
|
|
40
|
+
/** Empty-state slot for the message list. */
|
|
41
|
+
emptyState?: React.ReactNode;
|
|
42
|
+
/** Disable the input (e.g. during initial chat load). */
|
|
43
|
+
disabled?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function defaultRenderToolCall(call: ToolCall, result?: ToolResult) {
|
|
47
|
+
return <ToolCallRenderer key={call.id} call={call} result={result} />;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function ChatPanel(props: ChatPanelProps) {
|
|
51
|
+
const {
|
|
52
|
+
messages,
|
|
53
|
+
isStreaming = false,
|
|
54
|
+
onSubmit,
|
|
55
|
+
onCancel,
|
|
56
|
+
className,
|
|
57
|
+
inputPlaceholder = 'Send a message…',
|
|
58
|
+
header,
|
|
59
|
+
renderToolCall = defaultRenderToolCall,
|
|
60
|
+
emptyState,
|
|
61
|
+
disabled = false,
|
|
62
|
+
} = props;
|
|
63
|
+
const [draft, setDraft] = useState('');
|
|
64
|
+
|
|
65
|
+
const handleSubmit = useCallback(
|
|
66
|
+
async (e?: React.FormEvent) => {
|
|
67
|
+
e?.preventDefault();
|
|
68
|
+
const trimmed = draft.trim();
|
|
69
|
+
if (!trimmed || disabled) return;
|
|
70
|
+
setDraft('');
|
|
71
|
+
await onSubmit({ content: trimmed });
|
|
72
|
+
},
|
|
73
|
+
[draft, disabled, onSubmit],
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const renderMessage = useCallback(
|
|
77
|
+
(rendererProps: MessageRendererProps) => {
|
|
78
|
+
const { message, isLastAssistant, isStreaming: streaming } = rendererProps;
|
|
79
|
+
const isAssistant = message.role === 'assistant';
|
|
80
|
+
return (
|
|
81
|
+
<div
|
|
82
|
+
key={message.id ?? rendererProps.index}
|
|
83
|
+
data-role={message.role}
|
|
84
|
+
className={cn(
|
|
85
|
+
'flex w-full',
|
|
86
|
+
isAssistant ? 'justify-start' : 'justify-end',
|
|
87
|
+
)}
|
|
88
|
+
>
|
|
89
|
+
<div
|
|
90
|
+
className={cn(
|
|
91
|
+
'flex max-w-[80%] flex-col gap-2',
|
|
92
|
+
isAssistant ? 'items-start' : 'items-end',
|
|
93
|
+
)}
|
|
94
|
+
>
|
|
95
|
+
<div
|
|
96
|
+
className={cn(
|
|
97
|
+
'rounded-agent-md border px-3 py-2 text-sm leading-relaxed',
|
|
98
|
+
isAssistant
|
|
99
|
+
? 'border-agent-border bg-agent-muted text-agent-foreground'
|
|
100
|
+
: 'border-transparent bg-agent-accent text-agent-accent-foreground',
|
|
101
|
+
)}
|
|
102
|
+
>
|
|
103
|
+
<div className="whitespace-pre-wrap">
|
|
104
|
+
{message.content || (isLastAssistant && streaming ? '…' : '')}
|
|
105
|
+
</div>
|
|
106
|
+
{isLastAssistant && streaming ? (
|
|
107
|
+
<span
|
|
108
|
+
aria-hidden
|
|
109
|
+
className="ml-0.5 inline-block h-3 w-1.5 align-baseline bg-agent-foreground animate-agent-cursor-blink"
|
|
110
|
+
/>
|
|
111
|
+
) : null}
|
|
112
|
+
</div>
|
|
113
|
+
{Array.isArray(message.toolCalls) && message.toolCalls.length > 0 ? (
|
|
114
|
+
<div className="flex w-full flex-col gap-1.5">
|
|
115
|
+
{message.toolCalls.map((call) =>
|
|
116
|
+
renderToolCall(
|
|
117
|
+
call,
|
|
118
|
+
// The hook attaches a single `toolResult` to the message;
|
|
119
|
+
// production callers that want per-call results should
|
|
120
|
+
// override `renderToolCall` and look them up themselves.
|
|
121
|
+
message.toolResult,
|
|
122
|
+
),
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
) : null}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
},
|
|
130
|
+
[renderToolCall],
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div
|
|
135
|
+
className={cn(
|
|
136
|
+
'flex h-full w-full flex-col bg-agent-canvas text-agent-foreground',
|
|
137
|
+
className,
|
|
138
|
+
)}
|
|
139
|
+
>
|
|
140
|
+
{header}
|
|
141
|
+
<div className="flex-1 overflow-hidden">
|
|
142
|
+
<MessageList
|
|
143
|
+
messages={messages}
|
|
144
|
+
isStreaming={isStreaming}
|
|
145
|
+
renderMessage={renderMessage}
|
|
146
|
+
emptyState={emptyState}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
<form
|
|
150
|
+
className="flex items-center gap-2 border-t border-agent-border bg-agent-canvas px-3 py-2"
|
|
151
|
+
onSubmit={handleSubmit}
|
|
152
|
+
>
|
|
153
|
+
<input
|
|
154
|
+
type="text"
|
|
155
|
+
value={draft}
|
|
156
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
157
|
+
placeholder={inputPlaceholder}
|
|
158
|
+
disabled={disabled}
|
|
159
|
+
className={cn(
|
|
160
|
+
'flex-1 rounded-agent-md border border-agent-border bg-agent-canvas px-3 py-2 text-sm text-agent-foreground placeholder:text-agent-muted-foreground',
|
|
161
|
+
'focus:border-agent-accent focus:outline-none focus:ring-1 focus:ring-agent-accent',
|
|
162
|
+
disabled && 'opacity-50',
|
|
163
|
+
)}
|
|
164
|
+
/>
|
|
165
|
+
{isStreaming && onCancel ? (
|
|
166
|
+
<button
|
|
167
|
+
type="button"
|
|
168
|
+
onClick={onCancel}
|
|
169
|
+
className="rounded-agent-md border border-agent-border px-3 py-2 text-sm text-agent-foreground hover:bg-agent-muted"
|
|
170
|
+
>
|
|
171
|
+
Stop
|
|
172
|
+
</button>
|
|
173
|
+
) : (
|
|
174
|
+
<button
|
|
175
|
+
type="submit"
|
|
176
|
+
disabled={disabled || !draft.trim()}
|
|
177
|
+
className={cn(
|
|
178
|
+
'rounded-agent-md bg-agent-accent px-3 py-2 text-sm font-medium text-agent-accent-foreground',
|
|
179
|
+
(disabled || !draft.trim()) && 'opacity-50',
|
|
180
|
+
)}
|
|
181
|
+
>
|
|
182
|
+
Send
|
|
183
|
+
</button>
|
|
184
|
+
)}
|
|
185
|
+
</form>
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import type { ChatMessage } from '@steerable/agent-protocol';
|
|
3
|
+
import { MessageList } from './MessageList.js';
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof MessageList> = {
|
|
6
|
+
title: 'Components/MessageList',
|
|
7
|
+
component: MessageList,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: 'centered',
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
'Headless message list with auto-scroll-to-bottom. Pass a `renderMessage` prop to swap in your own bubble. The default renderer just shows role + content and is meant for sandbox / debugging only.',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
decorators: [
|
|
18
|
+
(Story) => (
|
|
19
|
+
<div className="h-[480px] w-[640px] border border-agent-border bg-agent-canvas">
|
|
20
|
+
<Story />
|
|
21
|
+
</div>
|
|
22
|
+
),
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default meta;
|
|
27
|
+
type Story = StoryObj<typeof MessageList>;
|
|
28
|
+
|
|
29
|
+
const NOW = '2026-05-15T00:00:00Z';
|
|
30
|
+
|
|
31
|
+
const baseMessages: ChatMessage[] = [
|
|
32
|
+
{ id: '1', role: 'user', content: 'Hi! What can you help me with?', createdAt: NOW },
|
|
33
|
+
{
|
|
34
|
+
id: '2',
|
|
35
|
+
role: 'assistant',
|
|
36
|
+
content:
|
|
37
|
+
'I can plan trips, draft emails, query your knowledge base and run safe shell commands. What would you like to start with?',
|
|
38
|
+
createdAt: NOW,
|
|
39
|
+
},
|
|
40
|
+
{ id: '3', role: 'user', content: 'Plan a 2-day weekend in Kyoto.', createdAt: NOW },
|
|
41
|
+
{
|
|
42
|
+
id: '4',
|
|
43
|
+
role: 'assistant',
|
|
44
|
+
content:
|
|
45
|
+
"Sure — Day 1: Fushimi Inari at sunrise, then Gion in the afternoon. Day 2: Arashiyama bamboo grove and a kaiseki dinner. I'll add restaurant picks next.",
|
|
46
|
+
createdAt: NOW,
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
export const Default: Story = {
|
|
51
|
+
args: {
|
|
52
|
+
messages: baseMessages,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const Empty: Story = {
|
|
57
|
+
args: {
|
|
58
|
+
messages: [],
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const EmptyWithCustomState: Story = {
|
|
63
|
+
args: {
|
|
64
|
+
messages: [],
|
|
65
|
+
emptyState: (
|
|
66
|
+
<div className="m-auto max-w-xs text-center text-sm text-agent-muted-foreground">
|
|
67
|
+
<p className="font-medium text-agent-foreground">Nothing to show yet</p>
|
|
68
|
+
<p className="mt-1">Send your first message to start a conversation.</p>
|
|
69
|
+
</div>
|
|
70
|
+
),
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const Streaming: Story = {
|
|
75
|
+
args: {
|
|
76
|
+
messages: [
|
|
77
|
+
...baseMessages,
|
|
78
|
+
{
|
|
79
|
+
id: '5',
|
|
80
|
+
role: 'assistant',
|
|
81
|
+
content: "Day 2 — Morning: rent a bike near Arashiyama station and",
|
|
82
|
+
createdAt: NOW,
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
isStreaming: true,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const StreamingEmptyAssistant: Story = {
|
|
90
|
+
args: {
|
|
91
|
+
messages: [
|
|
92
|
+
...baseMessages,
|
|
93
|
+
{ id: '6', role: 'assistant', content: '', createdAt: NOW },
|
|
94
|
+
],
|
|
95
|
+
isStreaming: true,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const LongConversation: Story = {
|
|
100
|
+
args: {
|
|
101
|
+
messages: Array.from({ length: 30 }, (_, i): ChatMessage => ({
|
|
102
|
+
id: `m-${i}`,
|
|
103
|
+
role: i % 2 === 0 ? 'user' : 'assistant',
|
|
104
|
+
content:
|
|
105
|
+
i % 2 === 0
|
|
106
|
+
? `User question #${i / 2 + 1}: tell me about topic ${i}.`
|
|
107
|
+
: `Assistant answer to question #${(i - 1) / 2 + 1}. ` +
|
|
108
|
+
'This response wraps across multiple lines so we exercise the auto-scroll behaviour and verify the bubble shape under longer content.',
|
|
109
|
+
createdAt: NOW,
|
|
110
|
+
})),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import type { ChatMessage } from '@steerable/agent-protocol';
|
|
4
|
+
import { MessageList } from './MessageList';
|
|
5
|
+
|
|
6
|
+
const baseMessages: ChatMessage[] = [
|
|
7
|
+
{
|
|
8
|
+
id: 'u1',
|
|
9
|
+
role: 'user',
|
|
10
|
+
content: 'Hi there!',
|
|
11
|
+
createdAt: new Date('2025-01-01T00:00:00Z').toISOString(),
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
id: 'a1',
|
|
15
|
+
role: 'assistant',
|
|
16
|
+
content: 'Hello! How can I help?',
|
|
17
|
+
createdAt: new Date('2025-01-01T00:00:01Z').toISOString(),
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
describe('MessageList', () => {
|
|
22
|
+
it('renders each message with the role data-attribute', () => {
|
|
23
|
+
render(<MessageList messages={baseMessages} />);
|
|
24
|
+
expect(screen.getByText('Hi there!')).toBeTruthy();
|
|
25
|
+
expect(screen.getByText('Hello! How can I help?')).toBeTruthy();
|
|
26
|
+
const roles = Array.from(
|
|
27
|
+
document.querySelectorAll('[data-role]'),
|
|
28
|
+
).map((el) => el.getAttribute('data-role'));
|
|
29
|
+
expect(roles).toEqual(['user', 'assistant']);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('renders the empty state slot when no messages and one is provided', () => {
|
|
33
|
+
render(
|
|
34
|
+
<MessageList
|
|
35
|
+
messages={[]}
|
|
36
|
+
emptyState={<div>start a conversation</div>}
|
|
37
|
+
/>,
|
|
38
|
+
);
|
|
39
|
+
expect(screen.getByText('start a conversation')).toBeTruthy();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('uses the supplied renderMessage when provided', () => {
|
|
43
|
+
render(
|
|
44
|
+
<MessageList
|
|
45
|
+
messages={baseMessages}
|
|
46
|
+
renderMessage={({ message }) => (
|
|
47
|
+
<span data-testid="custom">CUSTOM:{message.content}</span>
|
|
48
|
+
)}
|
|
49
|
+
/>,
|
|
50
|
+
);
|
|
51
|
+
const items = screen.getAllByTestId('custom');
|
|
52
|
+
expect(items.map((el) => el.textContent)).toEqual([
|
|
53
|
+
'CUSTOM:Hi there!',
|
|
54
|
+
'CUSTOM:Hello! How can I help?',
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
});
|