@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.
Files changed (104) hide show
  1. package/LICENSE +91 -0
  2. package/README.md +118 -0
  3. package/dist/components/ChatPanel.d.ts +42 -0
  4. package/dist/components/ChatPanel.d.ts.map +1 -0
  5. package/dist/components/ChatPanel.js +46 -0
  6. package/dist/components/ChatPanel.js.map +1 -0
  7. package/dist/components/ChatPanel.stories.d.ts +17 -0
  8. package/dist/components/ChatPanel.stories.d.ts.map +1 -0
  9. package/dist/components/ChatPanel.stories.js +143 -0
  10. package/dist/components/ChatPanel.stories.js.map +1 -0
  11. package/dist/components/MessageList.d.ts +44 -0
  12. package/dist/components/MessageList.d.ts.map +1 -0
  13. package/dist/components/MessageList.js +43 -0
  14. package/dist/components/MessageList.js.map +1 -0
  15. package/dist/components/MessageList.stories.d.ts +12 -0
  16. package/dist/components/MessageList.stories.d.ts.map +1 -0
  17. package/dist/components/MessageList.stories.js +88 -0
  18. package/dist/components/MessageList.stories.js.map +1 -0
  19. package/dist/components/OrchestrationPlanCard.d.ts +32 -0
  20. package/dist/components/OrchestrationPlanCard.d.ts.map +1 -0
  21. package/dist/components/OrchestrationPlanCard.js +48 -0
  22. package/dist/components/OrchestrationPlanCard.js.map +1 -0
  23. package/dist/components/OrchestrationPlanCard.stories.d.ts +12 -0
  24. package/dist/components/OrchestrationPlanCard.stories.d.ts.map +1 -0
  25. package/dist/components/OrchestrationPlanCard.stories.js +84 -0
  26. package/dist/components/OrchestrationPlanCard.stories.js.map +1 -0
  27. package/dist/components/SSEStreamView.d.ts +29 -0
  28. package/dist/components/SSEStreamView.d.ts.map +1 -0
  29. package/dist/components/SSEStreamView.js +78 -0
  30. package/dist/components/SSEStreamView.js.map +1 -0
  31. package/dist/components/SSEStreamView.stories.d.ts +12 -0
  32. package/dist/components/SSEStreamView.stories.d.ts.map +1 -0
  33. package/dist/components/SSEStreamView.stories.js +86 -0
  34. package/dist/components/SSEStreamView.stories.js.map +1 -0
  35. package/dist/components/ToolCallRenderer.d.ts +42 -0
  36. package/dist/components/ToolCallRenderer.d.ts.map +1 -0
  37. package/dist/components/ToolCallRenderer.js +70 -0
  38. package/dist/components/ToolCallRenderer.js.map +1 -0
  39. package/dist/components/ToolCallRenderer.stories.d.ts +13 -0
  40. package/dist/components/ToolCallRenderer.stories.d.ts.map +1 -0
  41. package/dist/components/ToolCallRenderer.stories.js +112 -0
  42. package/dist/components/ToolCallRenderer.stories.js.map +1 -0
  43. package/dist/components/cn.d.ts +7 -0
  44. package/dist/components/cn.d.ts.map +1 -0
  45. package/dist/components/cn.js +9 -0
  46. package/dist/components/cn.js.map +1 -0
  47. package/dist/components/index.d.ts +11 -0
  48. package/dist/components/index.d.ts.map +1 -0
  49. package/dist/components/index.js +6 -0
  50. package/dist/components/index.js.map +1 -0
  51. package/dist/hooks/index.d.ts +7 -0
  52. package/dist/hooks/index.d.ts.map +1 -0
  53. package/dist/hooks/index.js +4 -0
  54. package/dist/hooks/index.js.map +1 -0
  55. package/dist/hooks/useAgentSession.d.ts +56 -0
  56. package/dist/hooks/useAgentSession.d.ts.map +1 -0
  57. package/dist/hooks/useAgentSession.js +128 -0
  58. package/dist/hooks/useAgentSession.js.map +1 -0
  59. package/dist/hooks/useChatStream.d.ts +59 -0
  60. package/dist/hooks/useChatStream.d.ts.map +1 -0
  61. package/dist/hooks/useChatStream.js +251 -0
  62. package/dist/hooks/useChatStream.js.map +1 -0
  63. package/dist/hooks/useToolCallStatus.d.ts +47 -0
  64. package/dist/hooks/useToolCallStatus.d.ts.map +1 -0
  65. package/dist/hooks/useToolCallStatus.js +57 -0
  66. package/dist/hooks/useToolCallStatus.js.map +1 -0
  67. package/dist/index.d.ts +12 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +12 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/tailwind-preset.d.ts +76 -0
  72. package/dist/tailwind-preset.d.ts.map +1 -0
  73. package/dist/tailwind-preset.js +104 -0
  74. package/dist/tailwind-preset.js.map +1 -0
  75. package/package.json +97 -0
  76. package/src/Introduction.mdx +71 -0
  77. package/src/components/ChatPanel.stories.tsx +173 -0
  78. package/src/components/ChatPanel.tsx +188 -0
  79. package/src/components/MessageList.stories.tsx +112 -0
  80. package/src/components/MessageList.test.tsx +57 -0
  81. package/src/components/MessageList.tsx +144 -0
  82. package/src/components/OrchestrationPlanCard.stories.tsx +98 -0
  83. package/src/components/OrchestrationPlanCard.test.tsx +34 -0
  84. package/src/components/OrchestrationPlanCard.tsx +159 -0
  85. package/src/components/SSEStreamView.stories.tsx +99 -0
  86. package/src/components/SSEStreamView.test.tsx +42 -0
  87. package/src/components/SSEStreamView.tsx +155 -0
  88. package/src/components/ToolCallRenderer.stories.tsx +152 -0
  89. package/src/components/ToolCallRenderer.test.tsx +93 -0
  90. package/src/components/ToolCallRenderer.tsx +213 -0
  91. package/src/components/cn.ts +10 -0
  92. package/src/components/index.ts +18 -0
  93. package/src/hooks/index.ts +21 -0
  94. package/src/hooks/useAgentSession.mdx +115 -0
  95. package/src/hooks/useAgentSession.test.ts +127 -0
  96. package/src/hooks/useAgentSession.ts +181 -0
  97. package/src/hooks/useChatStream.mdx +129 -0
  98. package/src/hooks/useChatStream.test.ts +233 -0
  99. package/src/hooks/useChatStream.ts +341 -0
  100. package/src/hooks/useToolCallStatus.mdx +102 -0
  101. package/src/hooks/useToolCallStatus.test.ts +99 -0
  102. package/src/hooks/useToolCallStatus.ts +99 -0
  103. package/src/index.ts +12 -0
  104. package/src/tailwind-preset.ts +109 -0
@@ -0,0 +1,115 @@
1
+ import { Meta } from '@storybook/blocks';
2
+
3
+ <Meta title="Hooks/useAgentSession" />
4
+
5
+ # `useAgentSession`
6
+
7
+ > Light-weight wrapper around the agent-session lifecycle. Wraps `create` / `resume` / `list` against any storage adapter and gives you a stable `current` + `sessions` view.
8
+
9
+ In most production setups, sessions are created lazily on the first user message — but the desktop and web shells still need a deterministic handle to fetch a session, resume an existing one, or render a "your active sessions" picker. `useAgentSession` exposes that surface in a transport-agnostic shape that maps 1:1 onto the framework's storage adapters (`InMemoryStorage`, `SqlAlchemyStorage`).
10
+
11
+ ## Signature
12
+
13
+ ```ts
14
+ import { useAgentSession } from '@steerable/agent-ui';
15
+
16
+ const {
17
+ sessions, // AgentSession[] — active sessions for the current filter
18
+ current, // AgentSession | null
19
+ isLoading,
20
+ error,
21
+ create, // (input) => Promise<AgentSession>
22
+ resume, // (sessionId) => Promise<AgentSession>
23
+ refresh, // () => Promise<void>
24
+ setCurrent, // (session) => void
25
+ } = useAgentSession({
26
+ transport, // AgentSessionTransport (required)
27
+ autoLoad, // { userId?, chatId?, activeOnly? } (optional)
28
+ });
29
+ ```
30
+
31
+ ## The transport
32
+
33
+ ```ts
34
+ interface AgentSessionTransport {
35
+ create: (input: {
36
+ chatId: string;
37
+ userId: string;
38
+ projectId?: string | null;
39
+ scenario?: string;
40
+ stageData?: Record<string, unknown> | null;
41
+ }) => Promise<AgentSession>;
42
+ resume: (sessionId: string) => Promise<AgentSession>;
43
+ list: (filter: {
44
+ userId?: string;
45
+ chatId?: string;
46
+ activeOnly?: boolean;
47
+ }) => Promise<AgentSession[]>;
48
+ }
49
+ ```
50
+
51
+ You can plug in:
52
+
53
+ * a thin REST client (`fetch('/api/sessions/...')`),
54
+ * an IPC bridge (`window.electron.session.list(...)`),
55
+ * a sidecar JSON-RPC client (`sidecar.call('session.list', filter)`),
56
+ * an in-memory mock for Storybook / tests.
57
+
58
+ ## Stability contract
59
+
60
+ Two subtle but important behaviours:
61
+
62
+ 1. **`autoLoad` may be a fresh object every render.** The hook keys the `list` effect on the **values** in `autoLoad` (`userId` / `chatId` / `activeOnly`), not the object identity. Inline literals like `{ userId, activeOnly: true }` are safe.
63
+ 2. **`transport` is held by ref.** Swapping the transport implementation mid-session does **not** trigger a refetch. If you want one, call `refresh()` explicitly. This avoids surprise refetches when the parent re-renders and creates a new transport closure.
64
+
65
+ ## Example — REST-backed session picker
66
+
67
+ ```tsx
68
+ import { useAgentSession } from '@steerable/agent-ui';
69
+
70
+ function SessionPicker({ userId }: { userId: string }) {
71
+ const { sessions, current, isLoading, resume, setCurrent } = useAgentSession({
72
+ transport: {
73
+ create: (input) =>
74
+ fetch('/api/sessions', { method: 'POST', body: JSON.stringify(input) })
75
+ .then((r) => r.json()),
76
+ resume: (sessionId) =>
77
+ fetch(`/api/sessions/${sessionId}`).then((r) => r.json()),
78
+ list: (filter) => {
79
+ const qs = new URLSearchParams();
80
+ if (filter.userId) qs.set('userId', filter.userId);
81
+ if (filter.chatId) qs.set('chatId', filter.chatId);
82
+ if (filter.activeOnly) qs.set('activeOnly', 'true');
83
+ return fetch(`/api/sessions?${qs}`).then((r) => r.json());
84
+ },
85
+ },
86
+ autoLoad: { userId, activeOnly: true },
87
+ });
88
+
89
+ if (isLoading && sessions.length === 0) return <p>Loading sessions…</p>;
90
+
91
+ return (
92
+ <ul>
93
+ {sessions.map((s) => (
94
+ <li key={s.sessionId} aria-current={current?.sessionId === s.sessionId}>
95
+ <button
96
+ type="button"
97
+ onClick={async () => {
98
+ const fresh = await resume(s.sessionId);
99
+ setCurrent(fresh);
100
+ }}
101
+ >
102
+ {s.sessionId} — {s.scenario}
103
+ </button>
104
+ </li>
105
+ ))}
106
+ </ul>
107
+ );
108
+ }
109
+ ```
110
+
111
+ ## Caveats
112
+
113
+ * `current` is **the session created or resumed via this hook**, not whatever your global app state thinks is current. Mirror it into your store with `setCurrent` if you need cross-component access.
114
+ * Errors from `create` are re-thrown after being captured into `error` state, so you can `await create(...)` in a try/catch **and** still render a banner from `error`.
115
+ * `refresh` is a no-op when `autoLoad` is undefined. Pass an explicit filter to enable polling-style refetches.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Tests for `useAgentSession` — the hook is a thin transport adapter, but we
3
+ * still want to nail down the autoLoad behaviour and current-session pointer
4
+ * because callers route routing decisions off `current?.sessionId`.
5
+ */
6
+
7
+ import { act, renderHook, waitFor } from '@testing-library/react';
8
+ import { describe, expect, it, vi } from 'vitest';
9
+ import type { AgentSession } from '@steerable/agent-protocol';
10
+ import { useAgentSession, type AgentSessionTransport } from './useAgentSession';
11
+
12
+ function makeSession(overrides: Partial<AgentSession>): AgentSession {
13
+ return {
14
+ id: 'row1',
15
+ sessionId: 's1',
16
+ userId: 'u1',
17
+ chatId: 'c1',
18
+ currentStage: 'INIT',
19
+ isActive: true,
20
+ createdAt: new Date('2025-01-01').toISOString(),
21
+ updatedAt: new Date('2025-01-01').toISOString(),
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ function makeTransport(
27
+ list: AgentSession[] = [],
28
+ ): { transport: AgentSessionTransport; spies: Record<string, ReturnType<typeof vi.fn>> } {
29
+ const spies = {
30
+ create: vi.fn(async (input: Parameters<AgentSessionTransport['create']>[0]) =>
31
+ makeSession({
32
+ sessionId: 'created_' + input.chatId,
33
+ userId: input.userId,
34
+ chatId: input.chatId,
35
+ }),
36
+ ),
37
+ resume: vi.fn(async (sessionId: string) =>
38
+ makeSession({ sessionId, currentStage: 'RESUMED' }),
39
+ ),
40
+ list: vi.fn(async () => list),
41
+ };
42
+ return { transport: spies as unknown as AgentSessionTransport, spies };
43
+ }
44
+
45
+ describe('useAgentSession', () => {
46
+ it('does not call list when autoLoad is omitted', async () => {
47
+ const { transport, spies } = makeTransport();
48
+ renderHook(() => useAgentSession({ transport }));
49
+ await waitFor(() => {
50
+ expect(spies.list).not.toHaveBeenCalled();
51
+ });
52
+ });
53
+
54
+ it('calls list on mount when autoLoad is provided and exposes the result', async () => {
55
+ const sessions = [makeSession({ sessionId: 's1' }), makeSession({ sessionId: 's2' })];
56
+ const { transport, spies } = makeTransport(sessions);
57
+ const { result } = renderHook(() =>
58
+ useAgentSession({
59
+ transport,
60
+ autoLoad: { userId: 'u1', activeOnly: true },
61
+ }),
62
+ );
63
+
64
+ await waitFor(() => {
65
+ expect(spies.list).toHaveBeenCalledTimes(1);
66
+ expect(result.current.sessions).toHaveLength(2);
67
+ });
68
+ });
69
+
70
+ it('create() pushes the new session in front and updates current', async () => {
71
+ const { transport, spies } = makeTransport([]);
72
+ const { result } = renderHook(() =>
73
+ useAgentSession({ transport, autoLoad: { userId: 'u1' } }),
74
+ );
75
+
76
+ await waitFor(() => expect(spies.list).toHaveBeenCalled());
77
+
78
+ let created: AgentSession | undefined;
79
+ await act(async () => {
80
+ created = await result.current.create({
81
+ chatId: 'chat-x',
82
+ userId: 'u1',
83
+ });
84
+ });
85
+
86
+ expect(created?.sessionId).toBe('created_chat-x');
87
+ await waitFor(() => {
88
+ expect(result.current.current?.sessionId).toBe('created_chat-x');
89
+ expect(result.current.sessions[0]?.sessionId).toBe('created_chat-x');
90
+ });
91
+ });
92
+
93
+ it('resume() updates current without mutating sessions', async () => {
94
+ const list = [makeSession({ sessionId: 's-old' })];
95
+ const { transport, spies } = makeTransport(list);
96
+ const { result } = renderHook(() =>
97
+ useAgentSession({ transport, autoLoad: { userId: 'u1' } }),
98
+ );
99
+
100
+ await waitFor(() => expect(spies.list).toHaveBeenCalled());
101
+
102
+ await act(async () => {
103
+ await result.current.resume('s-old');
104
+ });
105
+
106
+ expect(result.current.current?.sessionId).toBe('s-old');
107
+ expect(result.current.current?.currentStage).toBe('RESUMED');
108
+ expect(result.current.sessions).toHaveLength(1);
109
+ });
110
+
111
+ it('captures errors from list() into the error field without throwing', async () => {
112
+ const transport: AgentSessionTransport = {
113
+ create: vi.fn(),
114
+ resume: vi.fn(),
115
+ list: vi.fn(async () => {
116
+ throw new Error('db down');
117
+ }),
118
+ };
119
+ const { result } = renderHook(() =>
120
+ useAgentSession({ transport, autoLoad: { userId: 'u1' } }),
121
+ );
122
+
123
+ await waitFor(() => {
124
+ expect(result.current.error?.message).toBe('db down');
125
+ });
126
+ });
127
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * `useAgentSession` — light-weight wrapper around the session lifecycle.
3
+ *
4
+ * Sessions are created lazily on first message in most production setups, but
5
+ * the desktop / web clients still need a deterministic handle to fetch /
6
+ * resume / list. The hook exposes a transport-agnostic API matching the
7
+ * methods on the framework's storage adapter (`InMemoryStorage`,
8
+ * `SqlAlchemyStorage`) so consumers can wire any backend.
9
+ *
10
+ * Stability contract:
11
+ * The hook tolerates inline-object literals for `autoLoad`. We re-run the
12
+ * `list` effect only when the **values** in `autoLoad` change, not when the
13
+ * object identity changes (otherwise an unmemoised consumer would loop).
14
+ * `transport` is held by ref so swapping the implementation mid-session
15
+ * doesn't trigger a refetch — call `refresh()` explicitly if you need that.
16
+ */
17
+
18
+ import {
19
+ useCallback,
20
+ useEffect,
21
+ useMemo,
22
+ useRef,
23
+ useState,
24
+ } from 'react';
25
+ import type { AgentSession } from '@steerable/agent-protocol';
26
+
27
+ export interface AgentSessionTransport {
28
+ create: (input: {
29
+ chatId: string;
30
+ userId: string;
31
+ projectId?: string | null;
32
+ scenario?: string;
33
+ stageData?: Record<string, unknown> | null;
34
+ }) => Promise<AgentSession>;
35
+ resume: (sessionId: string) => Promise<AgentSession>;
36
+ list: (filter: {
37
+ userId?: string;
38
+ chatId?: string;
39
+ activeOnly?: boolean;
40
+ }) => Promise<AgentSession[]>;
41
+ }
42
+
43
+ export interface UseAgentSessionOptions {
44
+ transport: AgentSessionTransport;
45
+ /** Optional auto-load filter; when set the hook calls `list` on mount. */
46
+ autoLoad?: { userId?: string; chatId?: string; activeOnly?: boolean };
47
+ }
48
+
49
+ export interface UseAgentSessionReturn {
50
+ /** Active sessions for the current filter. */
51
+ sessions: AgentSession[];
52
+ /** The session created/resumed most recently via this hook. */
53
+ current: AgentSession | null;
54
+ isLoading: boolean;
55
+ error: Error | null;
56
+ create: AgentSessionTransport['create'];
57
+ resume: AgentSessionTransport['resume'];
58
+ refresh: () => Promise<void>;
59
+ /** Set the current session pointer without re-fetching. */
60
+ setCurrent: (session: AgentSession | null) => void;
61
+ }
62
+
63
+ function autoLoadKey(
64
+ autoLoad: UseAgentSessionOptions['autoLoad'],
65
+ ): string | null {
66
+ if (!autoLoad) return null;
67
+ // Stable, deterministic key — values are primitives so JSON.stringify is fine.
68
+ return JSON.stringify({
69
+ userId: autoLoad.userId ?? null,
70
+ chatId: autoLoad.chatId ?? null,
71
+ activeOnly: autoLoad.activeOnly ?? null,
72
+ });
73
+ }
74
+
75
+ export function useAgentSession(
76
+ options: UseAgentSessionOptions,
77
+ ): UseAgentSessionReturn {
78
+ const [sessions, setSessions] = useState<AgentSession[]>([]);
79
+ const [current, setCurrent] = useState<AgentSession | null>(null);
80
+ const [isLoading, setLoading] = useState(false);
81
+ const [error, setError] = useState<Error | null>(null);
82
+ const mountedRef = useRef(true);
83
+ const transportRef = useRef(options.transport);
84
+ transportRef.current = options.transport;
85
+
86
+ useEffect(() => {
87
+ mountedRef.current = true;
88
+ return () => {
89
+ mountedRef.current = false;
90
+ };
91
+ }, []);
92
+
93
+ const autoLoadKeyVal = autoLoadKey(options.autoLoad);
94
+
95
+ const refresh = useCallback(async () => {
96
+ if (!options.autoLoad) return;
97
+ setLoading(true);
98
+ try {
99
+ const list = await transportRef.current.list(options.autoLoad);
100
+ if (mountedRef.current) {
101
+ setSessions(list);
102
+ setError(null);
103
+ }
104
+ } catch (err) {
105
+ if (mountedRef.current) {
106
+ setError(err instanceof Error ? err : new Error(String(err)));
107
+ }
108
+ } finally {
109
+ if (mountedRef.current) setLoading(false);
110
+ }
111
+ // We intentionally key on the serialised filter, not the object identity.
112
+ // eslint-disable-next-line react-hooks/exhaustive-deps
113
+ }, [autoLoadKeyVal]);
114
+
115
+ useEffect(() => {
116
+ if (autoLoadKeyVal === null) return;
117
+ void refresh();
118
+ }, [autoLoadKeyVal, refresh]);
119
+
120
+ const create = useCallback<AgentSessionTransport['create']>(
121
+ async (input) => {
122
+ setLoading(true);
123
+ try {
124
+ const session = await transportRef.current.create(input);
125
+ if (mountedRef.current) {
126
+ setCurrent(session);
127
+ setSessions((prev) => [
128
+ session,
129
+ ...prev.filter((s) => s.sessionId !== session.sessionId),
130
+ ]);
131
+ setError(null);
132
+ }
133
+ return session;
134
+ } catch (err) {
135
+ if (mountedRef.current) {
136
+ setError(err instanceof Error ? err : new Error(String(err)));
137
+ }
138
+ throw err;
139
+ } finally {
140
+ if (mountedRef.current) setLoading(false);
141
+ }
142
+ },
143
+ [],
144
+ );
145
+
146
+ const resume = useCallback<AgentSessionTransport['resume']>(
147
+ async (sessionId) => {
148
+ setLoading(true);
149
+ try {
150
+ const session = await transportRef.current.resume(sessionId);
151
+ if (mountedRef.current) {
152
+ setCurrent(session);
153
+ setError(null);
154
+ }
155
+ return session;
156
+ } catch (err) {
157
+ if (mountedRef.current) {
158
+ setError(err instanceof Error ? err : new Error(String(err)));
159
+ }
160
+ throw err;
161
+ } finally {
162
+ if (mountedRef.current) setLoading(false);
163
+ }
164
+ },
165
+ [],
166
+ );
167
+
168
+ return useMemo<UseAgentSessionReturn>(
169
+ () => ({
170
+ sessions,
171
+ current,
172
+ isLoading,
173
+ error,
174
+ create,
175
+ resume,
176
+ refresh,
177
+ setCurrent,
178
+ }),
179
+ [sessions, current, isLoading, error, create, resume, refresh],
180
+ );
181
+ }
@@ -0,0 +1,129 @@
1
+ import { Meta } from '@storybook/blocks';
2
+
3
+ <Meta title="Hooks/useChatStream" />
4
+
5
+ # `useChatStream`
6
+
7
+ > Owns the in-flight chat lifecycle. Translates an `SSEEvent` stream into `ChatMessage[]` mutations, exposes a `sendUserMessage` / `cancel` API, and stays unaware of HTTP / IPC / sidecar.
8
+
9
+ ## When to use
10
+
11
+ Reach for `useChatStream` when you need to hold the message list **and** drive a streaming reply. If you just need to render messages owned somewhere else, drop `<MessageList>` directly and skip the hook.
12
+
13
+ The hook does:
14
+
15
+ * Append the user message + a placeholder assistant message on `sendUserMessage`.
16
+ * Append `content` deltas onto the latest assistant turn.
17
+ * Attach `tool_call` / `tool_result` events inline on the assistant message.
18
+ * Surface `error` and `budget_exhausted` as the assistant content (so the UI shows the failure inline rather than crashing).
19
+ * Forward every other event type (`agent`, `orchestration`, `loader-hint`, `keepalive`, …) to `onUnknownEvent` so callers can stash them into their own state.
20
+
21
+ It does **not**:
22
+
23
+ * Speak HTTP. You give it a `transport.stream()` function and it consumes whatever events you yield.
24
+ * Persist messages. `initialMessages` seeds the buffer; `setMessages` replaces it. Persistence is the caller's job.
25
+ * Know which provider you use. The framework spec defines the `SSEEvent` envelope — pick whichever LLM/agent backend you want as long as it speaks that envelope.
26
+
27
+ ## Signature
28
+
29
+ ```ts
30
+ import { useChatStream } from '@steerable/agent-ui';
31
+
32
+ const {
33
+ messages, // ChatMessage[]
34
+ isStreaming, // boolean
35
+ sendUserMessage, // ({ content }) => Promise<void>
36
+ cancel, // () => void
37
+ setMessages, // (messages) => void
38
+ appendMessage, // (message) => void
39
+ } = useChatStream({
40
+ transport, // ChatStreamTransport (required)
41
+ initialMessages, // ChatMessage[] (optional)
42
+ toolResultToMessage, // (result) => ChatMessage | null (optional)
43
+ onUnknownEvent, // (event) => void (optional)
44
+ });
45
+ ```
46
+
47
+ ## Minimal example — fetch-based SSE transport
48
+
49
+ ```tsx
50
+ import { ChatPanel, useChatStream } from '@steerable/agent-ui';
51
+ import type { SSEEvent } from '@steerable/agent-protocol';
52
+
53
+ function Chat({ chatId }: { chatId: string }) {
54
+ const { messages, isStreaming, sendUserMessage, cancel } = useChatStream({
55
+ transport: {
56
+ stream: async ({ content }, onEvent) => {
57
+ const ctrl = new AbortController();
58
+ const res = await fetch(`/api/chats/${chatId}/send`, {
59
+ method: 'POST',
60
+ headers: { 'Content-Type': 'application/json' },
61
+ body: JSON.stringify({ content }),
62
+ signal: ctrl.signal,
63
+ });
64
+ if (!res.body) throw new Error('no SSE body');
65
+ const reader = res.body.getReader();
66
+ const decoder = new TextDecoder();
67
+ let buffer = '';
68
+ while (true) {
69
+ const { done, value } = await reader.read();
70
+ if (done) break;
71
+ buffer += decoder.decode(value, { stream: true });
72
+ for (const chunk of buffer.split('\n\n')) {
73
+ if (!chunk.startsWith('data: ')) continue;
74
+ try {
75
+ const event = JSON.parse(chunk.slice('data: '.length)) as SSEEvent;
76
+ onEvent(event);
77
+ } catch {
78
+ // ignore partial / heartbeat lines
79
+ }
80
+ }
81
+ buffer = '';
82
+ }
83
+ return () => ctrl.abort();
84
+ },
85
+ },
86
+ });
87
+
88
+ return (
89
+ <ChatPanel
90
+ messages={messages}
91
+ isStreaming={isStreaming}
92
+ onSubmit={sendUserMessage}
93
+ onCancel={cancel}
94
+ />
95
+ );
96
+ }
97
+ ```
98
+
99
+ ## Custom event handling
100
+
101
+ The default routing covers `content` / `tool_call` / `tool_result` / `done` / `error` / `budget_exhausted`. Everything else is forwarded to `onUnknownEvent`. Stash those in your own state for things like loader hints, agent-switch banners, or orchestration plan updates:
102
+
103
+ ```tsx
104
+ const [hint, setHint] = useState<string | null>(null);
105
+ const [plan, setPlan] = useState<OrchestrationStep[]>([]);
106
+
107
+ const { ... } = useChatStream({
108
+ transport,
109
+ onUnknownEvent: (event) => {
110
+ if (event.type === 'loader-hint') setHint(event.hint ?? null);
111
+ if (event.type === 'orchestration') setPlan(parsePlan(event));
112
+ },
113
+ });
114
+ ```
115
+
116
+ ## Cancellation
117
+
118
+ `transport.stream` may return a `() => void` cancel handle. The hook stores it and calls it when:
119
+
120
+ * the consumer calls `cancel()` (e.g. user taps "Stop"),
121
+ * the component unmounts mid-stream.
122
+
123
+ If your transport can't cancel, return `void` — the hook will simply mark `isStreaming` as `false` when the promise settles.
124
+
125
+ ## Caveats
126
+
127
+ * `sendUserMessage` is a no-op while `isStreaming` is true. Wire your composer's send button to `disabled={isStreaming}` to mirror this in the UI.
128
+ * Tool results are attached to the **latest assistant message**. If you need per-call lookup, override `renderToolCall` on `ChatPanel` and pull the result from your own map.
129
+ * Setting `toolResultToMessage` to a non-null returner switches to a "tool result lives in its own message" model — useful if you want to render tool results as standalone bubbles. Otherwise the result rides inline on the assistant turn.