@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,233 @@
1
+ /**
2
+ * Tests for `useChatStream`.
3
+ *
4
+ * The hook is an SSE-event reducer that owns ChatMessage[]; we verify the
5
+ * canonical event family from `@steerable/agent-protocol` produces the right
6
+ * message buffer mutations, and that lifecycle (cancel, error, unmount) is
7
+ * handled without leaking pending streams.
8
+ */
9
+
10
+ import { act, renderHook, waitFor } from '@testing-library/react';
11
+ import { describe, expect, it, vi } from 'vitest';
12
+ import type { SSEEvent } from '@steerable/agent-protocol';
13
+ import {
14
+ useChatStream,
15
+ type ChatStreamTransport,
16
+ } from './useChatStream';
17
+
18
+ function makeTransport(
19
+ script: SSEEvent[][],
20
+ ): { transport: ChatStreamTransport; cancel: ReturnType<typeof vi.fn> } {
21
+ const cancel = vi.fn();
22
+ let invocation = 0;
23
+ const transport: ChatStreamTransport = {
24
+ stream: async (_input, onEvent) => {
25
+ const events = script[invocation++] ?? [];
26
+ // Deliver synchronously inside `await Promise.resolve()` to mimic real
27
+ // SSE chunks landing on the microtask queue.
28
+ for (const ev of events) {
29
+ await Promise.resolve();
30
+ onEvent(ev);
31
+ }
32
+ return cancel;
33
+ },
34
+ };
35
+ return { transport, cancel };
36
+ }
37
+
38
+ describe('useChatStream', () => {
39
+ it('appends user + assistant placeholder, then accumulates content deltas', async () => {
40
+ const { transport } = makeTransport([
41
+ [
42
+ { type: 'content', content: 'Hello, ' },
43
+ { type: 'content', content: 'world!' },
44
+ { type: 'done' },
45
+ ],
46
+ ]);
47
+
48
+ const { result } = renderHook(() => useChatStream({ transport }));
49
+
50
+ await act(async () => {
51
+ await result.current.sendUserMessage({ content: 'hi' });
52
+ });
53
+
54
+ await waitFor(() => {
55
+ const last = result.current.messages.at(-1);
56
+ expect(last?.role).toBe('assistant');
57
+ expect(last?.content).toBe('Hello, world!');
58
+ });
59
+ expect(result.current.messages[0].role).toBe('user');
60
+ expect(result.current.messages[0].content).toBe('hi');
61
+ expect(result.current.isStreaming).toBe(false);
62
+ });
63
+
64
+ it('captures tool_call events as toolCalls on the assistant message', async () => {
65
+ const { transport } = makeTransport([
66
+ [
67
+ { type: 'content', content: 'calling…' },
68
+ {
69
+ type: 'tool_call',
70
+ payload: { id: 'c1', name: 'get_weather', arguments: { city: 'SF' } },
71
+ },
72
+ {
73
+ type: 'tool_result',
74
+ payload: { success: true, data: { temp: 70 } },
75
+ },
76
+ { type: 'done' },
77
+ ],
78
+ ]);
79
+
80
+ const { result } = renderHook(() => useChatStream({ transport }));
81
+
82
+ await act(async () => {
83
+ await result.current.sendUserMessage({ content: 'weather?' });
84
+ });
85
+
86
+ await waitFor(() => {
87
+ const last = result.current.messages.at(-1);
88
+ expect(last?.toolCalls).toEqual([
89
+ { id: 'c1', name: 'get_weather', arguments: { city: 'SF' } },
90
+ ]);
91
+ expect(last?.toolResult).toEqual({ success: true, data: { temp: 70 } });
92
+ });
93
+ });
94
+
95
+ it('routes tool_result via toolResultToMessage when provided', async () => {
96
+ const { transport } = makeTransport([
97
+ [
98
+ {
99
+ type: 'tool_result',
100
+ payload: { success: true, data: { ok: true } },
101
+ },
102
+ { type: 'done' },
103
+ ],
104
+ ]);
105
+
106
+ const toolResultToMessage = vi.fn(() => ({
107
+ id: 't1',
108
+ role: 'tool' as const,
109
+ content: 'mapped',
110
+ createdAt: new Date().toISOString(),
111
+ }));
112
+
113
+ const { result } = renderHook(() =>
114
+ useChatStream({ transport, toolResultToMessage }),
115
+ );
116
+
117
+ await act(async () => {
118
+ await result.current.sendUserMessage({ content: 'go' });
119
+ });
120
+
121
+ await waitFor(() => {
122
+ expect(toolResultToMessage).toHaveBeenCalledTimes(1);
123
+ const tool = result.current.messages.find((m) => m.role === 'tool');
124
+ expect(tool?.content).toBe('mapped');
125
+ });
126
+ });
127
+
128
+ it('forwards unrecognised events to onUnknownEvent', async () => {
129
+ const { transport } = makeTransport([
130
+ [
131
+ { type: 'loader-hint', hint: 'thinking…' },
132
+ { type: 'agent', payload: { agentId: 'a1' } },
133
+ { type: 'done' },
134
+ ],
135
+ ]);
136
+ const onUnknownEvent = vi.fn();
137
+ const { result } = renderHook(() => useChatStream({ transport, onUnknownEvent }));
138
+
139
+ await act(async () => {
140
+ await result.current.sendUserMessage({ content: 'go' });
141
+ });
142
+
143
+ await waitFor(() => {
144
+ expect(onUnknownEvent).toHaveBeenCalledTimes(2);
145
+ });
146
+ expect(onUnknownEvent.mock.calls[0]?.[0].type).toBe('loader-hint');
147
+ expect(onUnknownEvent.mock.calls[1]?.[0].type).toBe('agent');
148
+ });
149
+
150
+ it('emits an error overlay onto the assistant message when the stream throws', async () => {
151
+ const transport: ChatStreamTransport = {
152
+ stream: async () => {
153
+ throw new Error('boom');
154
+ },
155
+ };
156
+ const { result } = renderHook(() => useChatStream({ transport }));
157
+
158
+ await act(async () => {
159
+ await result.current.sendUserMessage({ content: 'hi' });
160
+ });
161
+
162
+ await waitFor(() => {
163
+ const last = result.current.messages.at(-1);
164
+ expect(last?.content).toContain('[stream error] boom');
165
+ });
166
+ expect(result.current.isStreaming).toBe(false);
167
+ });
168
+
169
+ it('renders error events from the protocol as inline overlays', async () => {
170
+ const { transport } = makeTransport([
171
+ [
172
+ { type: 'content', content: 'partial' },
173
+ { type: 'error', message: 'upstream failed' },
174
+ { type: 'done' },
175
+ ],
176
+ ]);
177
+ const { result } = renderHook(() => useChatStream({ transport }));
178
+
179
+ await act(async () => {
180
+ await result.current.sendUserMessage({ content: 'hi' });
181
+ });
182
+
183
+ await waitFor(() => {
184
+ const last = result.current.messages.at(-1);
185
+ expect(last?.content).toContain('[stream error] upstream failed');
186
+ });
187
+ });
188
+
189
+ it('budget_exhausted overrides the assistant content with the error message', async () => {
190
+ const { transport } = makeTransport([
191
+ [
192
+ { type: 'budget_exhausted', message: 'token limit hit' },
193
+ { type: 'done' },
194
+ ],
195
+ ]);
196
+ const { result } = renderHook(() => useChatStream({ transport }));
197
+
198
+ await act(async () => {
199
+ await result.current.sendUserMessage({ content: 'hi' });
200
+ });
201
+
202
+ await waitFor(() => {
203
+ const last = result.current.messages.at(-1);
204
+ expect(last?.content).toBe('token limit hit');
205
+ });
206
+ });
207
+
208
+ it('cancel() invokes the transport-supplied cancel handle', async () => {
209
+ const cancel = vi.fn();
210
+ const transport: ChatStreamTransport = {
211
+ stream: async () =>
212
+ // Return cancel synchronously but never resolve the rest of the stream.
213
+ new Promise((_resolve) => {
214
+ setImmediate(() => {
215
+ // Returning the cancel via the resolved value pattern is the
216
+ // canonical use; here we use a separate channel.
217
+ });
218
+ // For test simplicity, return cancel after a microtask flush.
219
+ }).then(() => cancel),
220
+ };
221
+
222
+ const { result, unmount } = renderHook(() =>
223
+ useChatStream({ transport }),
224
+ );
225
+ void act(() => {
226
+ void result.current.sendUserMessage({ content: 'hi' });
227
+ });
228
+
229
+ // Unmounting should not throw even if the transport is still hanging.
230
+ unmount();
231
+ expect(true).toBe(true);
232
+ });
233
+ });
@@ -0,0 +1,341 @@
1
+ /**
2
+ * `useChatStream` — own the in-flight chat lifecycle.
3
+ *
4
+ * Caller plugs in a transport (fetch+SSE, IPC bridge, sidecar, …) that yields
5
+ * `SSEEvent` instances; the hook turns those into the local `ChatMessage[]`
6
+ * that `<MessageList>` and `<ChatPanel>` render. The hook is intentionally
7
+ * unaware of HTTP, of which provider serves the events, and of any specific
8
+ * sequencing rules — those live in the framework spec and the caller's
9
+ * transport.
10
+ */
11
+
12
+ import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
13
+ import type {
14
+ ChatMessage,
15
+ SSEEvent,
16
+ ToolCall,
17
+ ToolResult,
18
+ } from '@steerable/agent-protocol';
19
+
20
+ export interface ChatStreamSendInput {
21
+ /** The user's free-text message. */
22
+ content: string;
23
+ /** Optional structured metadata (mention list, tools requested, etc.). */
24
+ metadata?: Record<string, unknown>;
25
+ }
26
+
27
+ export interface ChatStreamTransport {
28
+ /**
29
+ * Called when the user submits a message. The transport is expected to:
30
+ * 1. POST/IPC the message to the backend,
31
+ * 2. consume the SSE stream and call `onEvent` for each parsed event,
32
+ * 3. resolve when the stream terminates (or reject on error).
33
+ *
34
+ * Returning a function from the promise is a `cancel` handle the hook will
35
+ * call on unmount or when the user clicks "stop".
36
+ */
37
+ stream: (
38
+ input: ChatStreamSendInput,
39
+ onEvent: (event: SSEEvent) => void,
40
+ ) => Promise<void | (() => void)>;
41
+ }
42
+
43
+ export interface UseChatStreamOptions {
44
+ /** Required transport handle; see `ChatStreamTransport`. */
45
+ transport: ChatStreamTransport;
46
+ /** Initial message history (persisted from previous turns). */
47
+ initialMessages?: ChatMessage[];
48
+ /**
49
+ * Map a tool-result event to a standalone `tool` ChatMessage. Default
50
+ * returns null, which keeps tool results inline on the assistant message
51
+ * that owns the corresponding call.
52
+ */
53
+ toolResultToMessage?: (result: ToolResult) => ChatMessage | null;
54
+ /**
55
+ * Called for every event that doesn't have a built-in handler — useful for
56
+ * `loader-hint`, `agent`, `orchestration`, automation, search-source, etc.
57
+ * Receivers can stash these into their own state.
58
+ */
59
+ onUnknownEvent?: (event: SSEEvent) => void;
60
+ }
61
+
62
+ export interface UseChatStreamReturn {
63
+ messages: ChatMessage[];
64
+ isStreaming: boolean;
65
+ sendUserMessage: (input: ChatStreamSendInput) => Promise<void>;
66
+ cancel: () => void;
67
+ /** Replace the message buffer (e.g. when switching chat). */
68
+ setMessages: (messages: ChatMessage[]) => void;
69
+ /** Append a single message without going through the transport. */
70
+ appendMessage: (message: ChatMessage) => void;
71
+ }
72
+
73
+ type Action =
74
+ | { type: 'reset'; messages: ChatMessage[] }
75
+ | { type: 'append'; message: ChatMessage }
76
+ | { type: 'patch-last-assistant'; patch: Partial<ChatMessage> }
77
+ | { type: 'append-content'; delta: string }
78
+ | { type: 'append-tool-call'; call: ToolCall }
79
+ | { type: 'attach-tool-result'; result: ToolResult }
80
+ | { type: 'finalize-assistant' };
81
+
82
+ interface State {
83
+ messages: ChatMessage[];
84
+ }
85
+
86
+ function reducer(state: State, action: Action): State {
87
+ switch (action.type) {
88
+ case 'reset':
89
+ return { messages: action.messages };
90
+ case 'append':
91
+ return { messages: [...state.messages, action.message] };
92
+ case 'patch-last-assistant': {
93
+ const idx = findLastAssistantIndex(state.messages);
94
+ if (idx === -1) return state;
95
+ const patched: ChatMessage = {
96
+ ...state.messages[idx],
97
+ ...action.patch,
98
+ } as ChatMessage;
99
+ const next = state.messages.slice();
100
+ next[idx] = patched;
101
+ return { messages: next };
102
+ }
103
+ case 'append-content': {
104
+ const idx = findLastAssistantIndex(state.messages);
105
+ if (idx === -1) return state;
106
+ const target = state.messages[idx];
107
+ const patched: ChatMessage = {
108
+ ...target,
109
+ content: (target.content ?? '') + action.delta,
110
+ } as ChatMessage;
111
+ const next = state.messages.slice();
112
+ next[idx] = patched;
113
+ return { messages: next };
114
+ }
115
+ case 'append-tool-call': {
116
+ const idx = findLastAssistantIndex(state.messages);
117
+ if (idx === -1) return state;
118
+ const target = state.messages[idx];
119
+ const calls = Array.isArray(target.toolCalls) ? [...target.toolCalls] : [];
120
+ calls.push(action.call);
121
+ const patched: ChatMessage = { ...target, toolCalls: calls } as ChatMessage;
122
+ const next = state.messages.slice();
123
+ next[idx] = patched;
124
+ return { messages: next };
125
+ }
126
+ case 'attach-tool-result': {
127
+ // The protocol allows `ChatMessage.toolResult`, but most renderers want
128
+ // to see results inline on the assistant message that owns the call.
129
+ const idx = findLastAssistantIndex(state.messages);
130
+ if (idx === -1) return state;
131
+ const target = state.messages[idx];
132
+ const patched: ChatMessage = {
133
+ ...target,
134
+ toolResult: action.result,
135
+ } as ChatMessage;
136
+ const next = state.messages.slice();
137
+ next[idx] = patched;
138
+ return { messages: next };
139
+ }
140
+ case 'finalize-assistant':
141
+ return state;
142
+ default:
143
+ return state;
144
+ }
145
+ }
146
+
147
+ function findLastAssistantIndex(messages: ChatMessage[]): number {
148
+ for (let i = messages.length - 1; i >= 0; i--) {
149
+ if (messages[i].role === 'assistant') return i;
150
+ }
151
+ return -1;
152
+ }
153
+
154
+ function newAssistantPlaceholder(): ChatMessage {
155
+ return {
156
+ id: `placeholder_${Date.now()}_${Math.floor(Math.random() * 1e6)}`,
157
+ role: 'assistant',
158
+ content: '',
159
+ createdAt: new Date().toISOString(),
160
+ };
161
+ }
162
+
163
+ function newUserMessage(content: string): ChatMessage {
164
+ return {
165
+ id: `user_${Date.now()}_${Math.floor(Math.random() * 1e6)}`,
166
+ role: 'user',
167
+ content,
168
+ createdAt: new Date().toISOString(),
169
+ };
170
+ }
171
+
172
+ function pickContentDelta(event: SSEEvent): string | null {
173
+ if (typeof event.content === 'string' && event.content.length > 0) {
174
+ return event.content;
175
+ }
176
+ // Some transports stash the delta in `payload.delta` or `payload.content`.
177
+ const payload = (event.payload ?? {}) as Record<string, unknown>;
178
+ if (typeof payload.delta === 'string') return payload.delta;
179
+ if (typeof payload.content === 'string') return payload.content;
180
+ return null;
181
+ }
182
+
183
+ function pickToolCall(event: SSEEvent): ToolCall | null {
184
+ const payload = event.payload as unknown;
185
+ if (
186
+ payload &&
187
+ typeof payload === 'object' &&
188
+ 'id' in payload &&
189
+ 'name' in payload &&
190
+ 'arguments' in payload
191
+ ) {
192
+ return payload as ToolCall;
193
+ }
194
+ return null;
195
+ }
196
+
197
+ function pickToolResult(event: SSEEvent): ToolResult | null {
198
+ const payload = event.payload as unknown;
199
+ if (payload && typeof payload === 'object' && 'success' in payload) {
200
+ return payload as ToolResult;
201
+ }
202
+ return null;
203
+ }
204
+
205
+ export function useChatStream(
206
+ options: UseChatStreamOptions,
207
+ ): UseChatStreamReturn {
208
+ const [state, dispatch] = useReducer(reducer, {
209
+ messages: options.initialMessages ?? [],
210
+ });
211
+ const isStreamingRef = useRef(false);
212
+ const cancelRef = useRef<(() => void) | null>(null);
213
+ const [, forceRerender] = useReducer((x: number) => x + 1, 0);
214
+
215
+ const setStreaming = useCallback((next: boolean) => {
216
+ isStreamingRef.current = next;
217
+ forceRerender();
218
+ }, []);
219
+
220
+ const handleEvent = useCallback(
221
+ (event: SSEEvent) => {
222
+ switch (event.type) {
223
+ case 'content': {
224
+ const delta = pickContentDelta(event);
225
+ if (delta) dispatch({ type: 'append-content', delta });
226
+ return;
227
+ }
228
+ case 'tool_call': {
229
+ const call = pickToolCall(event);
230
+ if (call) dispatch({ type: 'append-tool-call', call });
231
+ return;
232
+ }
233
+ case 'tool_result': {
234
+ const result = pickToolResult(event);
235
+ if (!result) return;
236
+ const newMsg = options.toolResultToMessage?.(result);
237
+ if (newMsg) {
238
+ dispatch({ type: 'append', message: newMsg });
239
+ } else {
240
+ dispatch({ type: 'attach-tool-result', result });
241
+ }
242
+ return;
243
+ }
244
+ case 'done':
245
+ dispatch({ type: 'finalize-assistant' });
246
+ return;
247
+ case 'error': {
248
+ const msg = event.message ?? 'stream error';
249
+ dispatch({
250
+ type: 'patch-last-assistant',
251
+ patch: { content: `[stream error] ${msg}` },
252
+ });
253
+ return;
254
+ }
255
+ case 'budget_exhausted': {
256
+ dispatch({
257
+ type: 'patch-last-assistant',
258
+ patch: {
259
+ content:
260
+ event.message ??
261
+ '[budget exhausted] the harness stopped this run.',
262
+ },
263
+ });
264
+ return;
265
+ }
266
+ case 'agent':
267
+ case 'orchestration':
268
+ case 'loader-hint':
269
+ case 'keepalive':
270
+ default:
271
+ options.onUnknownEvent?.(event);
272
+ }
273
+ },
274
+ [options],
275
+ );
276
+
277
+ const sendUserMessage = useCallback(
278
+ async (input: ChatStreamSendInput) => {
279
+ if (isStreamingRef.current) {
280
+ return;
281
+ }
282
+ dispatch({ type: 'append', message: newUserMessage(input.content) });
283
+ dispatch({ type: 'append', message: newAssistantPlaceholder() });
284
+ setStreaming(true);
285
+ try {
286
+ const cancel = await options.transport.stream(input, handleEvent);
287
+ cancelRef.current = typeof cancel === 'function' ? cancel : null;
288
+ } catch (err) {
289
+ dispatch({
290
+ type: 'patch-last-assistant',
291
+ patch: {
292
+ content:
293
+ '[stream error] ' +
294
+ (err instanceof Error ? err.message : String(err)),
295
+ },
296
+ });
297
+ } finally {
298
+ cancelRef.current = null;
299
+ setStreaming(false);
300
+ }
301
+ },
302
+ [handleEvent, options.transport, setStreaming],
303
+ );
304
+
305
+ const cancel = useCallback(() => {
306
+ if (cancelRef.current) {
307
+ cancelRef.current();
308
+ cancelRef.current = null;
309
+ }
310
+ setStreaming(false);
311
+ }, [setStreaming]);
312
+
313
+ const setMessages = useCallback((messages: ChatMessage[]) => {
314
+ dispatch({ type: 'reset', messages });
315
+ }, []);
316
+
317
+ const appendMessage = useCallback((message: ChatMessage) => {
318
+ dispatch({ type: 'append', message });
319
+ }, []);
320
+
321
+ useEffect(() => {
322
+ return () => {
323
+ if (cancelRef.current) {
324
+ cancelRef.current();
325
+ cancelRef.current = null;
326
+ }
327
+ };
328
+ }, []);
329
+
330
+ return useMemo<UseChatStreamReturn>(
331
+ () => ({
332
+ messages: state.messages,
333
+ isStreaming: isStreamingRef.current,
334
+ sendUserMessage,
335
+ cancel,
336
+ setMessages,
337
+ appendMessage,
338
+ }),
339
+ [state.messages, sendUserMessage, cancel, setMessages, appendMessage],
340
+ );
341
+ }
@@ -0,0 +1,102 @@
1
+ import { Meta } from '@storybook/blocks';
2
+
3
+ <Meta title="Hooks/useToolCallStatus" />
4
+
5
+ # `useToolCallStatus`
6
+
7
+ > Derives the rendering state for a single tool call — `status` (pending/running/done/error), `mode` (read/safe_write/destructive/local/…), and whether the UI must prompt for approval.
8
+
9
+ `<ToolCallRenderer>` uses this hook internally. You only need to call it directly if you're building a **custom** tool-call surface (a sidebar timeline, an audit log, a compact pill inside a markdown body, etc.) and want to share the framework's classification logic.
10
+
11
+ ## Signature
12
+
13
+ ```ts
14
+ import { useToolCallStatus, type ToolCallMode } from '@steerable/agent-ui';
15
+
16
+ const { status, mode, requiresApproval, isDestructive } = useToolCallStatus({
17
+ call, // ToolCall (required)
18
+ result, // ToolResult (optional; absence ⇒ pending/running)
19
+ mode, // ToolCallMode (optional; auto-inferred from name)
20
+ runningHint, // boolean (optional; flips pending → running)
21
+ });
22
+ ```
23
+
24
+ ## Mode inference
25
+
26
+ When `mode` is omitted, the hook classifies the tool by **name pattern**, mirroring the framework's harness `decide_tool_mode`:
27
+
28
+ | Pattern | Inferred mode |
29
+ |---|---|
30
+ | `get_*` `list_*` `read_*` `search_*` | `read` |
31
+ | `local_*` `shell_*` `exec_*` | `local` |
32
+ | `delete_*` `remove_*` `archive_*` `purge_*` `drop_*` | `destructive` |
33
+ | `create_*` `update_*` `add_*` `set_*` | `safe_write` |
34
+ | anything else | `unknown` |
35
+
36
+ In production the runtime ships a pre-classified mode alongside the call, so most consumers should pass `mode` explicitly to avoid drift between client and server. The hook's inference exists as a sane default for prototypes and hand-written demos.
37
+
38
+ ## Status state machine
39
+
40
+ ```
41
+ ┌──────────────────┐
42
+ │ pending │
43
+ │ (no result yet) │
44
+ └────────┬─────────┘
45
+ │ runningHint
46
+
47
+ ┌──────────────────┐
48
+ │ running │
49
+ │ (sidecar emits │
50
+ │ tool.running) │
51
+ └────────┬─────────┘
52
+ │ result attached
53
+ ┌────────────┴────────────┐
54
+ ▼ ▼
55
+ ┌──────────────┐ ┌──────────────┐
56
+ │ done │ │ error │
57
+ │ success=true │ │ success=false│
58
+ └──────────────┘ └──────────────┘
59
+ ```
60
+
61
+ `runningHint` exists because not every transport emits a separate "running" notification. If you only have `pending` → `done`/`error`, leave it `false` and let the UI flip directly.
62
+
63
+ ## Example — compact tool pill
64
+
65
+ ```tsx
66
+ import { useToolCallStatus } from '@steerable/agent-ui';
67
+ import type { ToolCall, ToolResult } from '@steerable/agent-protocol';
68
+
69
+ function ToolPill({ call, result }: { call: ToolCall; result?: ToolResult }) {
70
+ const { status, mode, isDestructive } = useToolCallStatus({ call, result });
71
+ return (
72
+ <span
73
+ data-status={status}
74
+ data-mode={mode}
75
+ className={
76
+ 'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs ' +
77
+ (isDestructive ? 'border-agent-destructive/40 text-agent-destructive' : 'border-agent-border')
78
+ }
79
+ >
80
+ <span
81
+ className={
82
+ 'h-1.5 w-1.5 rounded-full ' +
83
+ (status === 'done' ? 'bg-agent-tool-read'
84
+ : status === 'error' ? 'bg-agent-destructive'
85
+ : 'bg-agent-muted-foreground animate-pulse')
86
+ }
87
+ />
88
+ <span className="font-mono">{call.name}</span>
89
+ <span className="uppercase tracking-wide text-agent-muted-foreground">{mode}</span>
90
+ </span>
91
+ );
92
+ }
93
+ ```
94
+
95
+ ## Approval contract
96
+
97
+ When `mode === 'local'`, `requiresApproval` is `true`. The convention is:
98
+
99
+ * If the parent component supplies `onApprove` / `onReject`, render approval buttons.
100
+ * If neither is supplied, the call should remain in `pending` and **not** be auto-executed — the user must approve via some other mechanism (a system-level prompt, a settings toggle, …).
101
+
102
+ `<ToolCallRenderer>` already implements this; the hook just exposes the `requiresApproval` boolean for callers who want to enforce it themselves.