@steerable/agent-ui 0.2.3 → 0.2.5

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 (184) hide show
  1. package/dist/cards/ActionSegmentCard.d.ts +18 -0
  2. package/dist/cards/ActionSegmentCard.d.ts.map +1 -0
  3. package/dist/cards/ActionSegmentCard.js +26 -0
  4. package/dist/cards/ActionSegmentCard.js.map +1 -0
  5. package/dist/cards/AnalysisDocumentCard.d.ts +33 -0
  6. package/dist/cards/AnalysisDocumentCard.d.ts.map +1 -0
  7. package/dist/cards/AnalysisDocumentCard.js +11 -0
  8. package/dist/cards/AnalysisDocumentCard.js.map +1 -0
  9. package/dist/cards/AskUserQuestionsCard.d.ts +43 -0
  10. package/dist/cards/AskUserQuestionsCard.d.ts.map +1 -0
  11. package/dist/cards/AskUserQuestionsCard.js +171 -0
  12. package/dist/cards/AskUserQuestionsCard.js.map +1 -0
  13. package/dist/cards/CoverageReportCard.d.ts +41 -0
  14. package/dist/cards/CoverageReportCard.d.ts.map +1 -0
  15. package/dist/cards/CoverageReportCard.js +55 -0
  16. package/dist/cards/CoverageReportCard.js.map +1 -0
  17. package/dist/cards/OrchestrationPlanCard.d.ts +60 -0
  18. package/dist/cards/OrchestrationPlanCard.d.ts.map +1 -0
  19. package/dist/cards/OrchestrationPlanCard.js +103 -0
  20. package/dist/cards/OrchestrationPlanCard.js.map +1 -0
  21. package/dist/cards/PlanSelectorCard.d.ts +40 -0
  22. package/dist/cards/PlanSelectorCard.d.ts.map +1 -0
  23. package/dist/cards/PlanSelectorCard.js +106 -0
  24. package/dist/cards/PlanSelectorCard.js.map +1 -0
  25. package/dist/cards/PlanStepsCard.d.ts +18 -0
  26. package/dist/cards/PlanStepsCard.d.ts.map +1 -0
  27. package/dist/cards/PlanStepsCard.js +25 -0
  28. package/dist/cards/PlanStepsCard.js.map +1 -0
  29. package/dist/cards/QuizCard.d.ts +44 -0
  30. package/dist/cards/QuizCard.d.ts.map +1 -0
  31. package/dist/cards/QuizCard.js +113 -0
  32. package/dist/cards/QuizCard.js.map +1 -0
  33. package/dist/cards/ResearchPlanCard.d.ts +23 -0
  34. package/dist/cards/ResearchPlanCard.d.ts.map +1 -0
  35. package/dist/cards/ResearchPlanCard.js +50 -0
  36. package/dist/cards/ResearchPlanCard.js.map +1 -0
  37. package/dist/cards/SearchSourcesCard.d.ts +29 -0
  38. package/dist/cards/SearchSourcesCard.d.ts.map +1 -0
  39. package/dist/cards/SearchSourcesCard.js +83 -0
  40. package/dist/cards/SearchSourcesCard.js.map +1 -0
  41. package/dist/cards/SuggestedRepliesCard.d.ts +18 -0
  42. package/dist/cards/SuggestedRepliesCard.d.ts.map +1 -0
  43. package/dist/cards/SuggestedRepliesCard.js +14 -0
  44. package/dist/cards/SuggestedRepliesCard.js.map +1 -0
  45. package/dist/cards/SummaryMessageCard.d.ts +40 -0
  46. package/dist/cards/SummaryMessageCard.d.ts.map +1 -0
  47. package/dist/cards/SummaryMessageCard.js +56 -0
  48. package/dist/cards/SummaryMessageCard.js.map +1 -0
  49. package/dist/cards/ThinkingProcessCard.d.ts +46 -0
  50. package/dist/cards/ThinkingProcessCard.d.ts.map +1 -0
  51. package/dist/cards/ThinkingProcessCard.js +79 -0
  52. package/dist/cards/ThinkingProcessCard.js.map +1 -0
  53. package/dist/cards/ToolExecutionCard.d.ts +21 -0
  54. package/dist/cards/ToolExecutionCard.d.ts.map +1 -0
  55. package/dist/cards/ToolExecutionCard.js +65 -0
  56. package/dist/cards/ToolExecutionCard.js.map +1 -0
  57. package/dist/cards/icons.d.ts +38 -0
  58. package/dist/cards/icons.d.ts.map +1 -0
  59. package/dist/cards/icons.js +87 -0
  60. package/dist/cards/icons.js.map +1 -0
  61. package/dist/cards/index.d.ts +38 -0
  62. package/dist/cards/index.d.ts.map +1 -0
  63. package/dist/cards/index.js +24 -0
  64. package/dist/cards/index.js.map +1 -0
  65. package/dist/cards/types.d.ts +24 -0
  66. package/dist/cards/types.d.ts.map +1 -0
  67. package/dist/cards/types.js +7 -0
  68. package/dist/cards/types.js.map +1 -0
  69. package/dist/components/AgentSelector.d.ts +23 -0
  70. package/dist/components/AgentSelector.d.ts.map +1 -0
  71. package/dist/components/AgentSelector.js +63 -0
  72. package/dist/components/AgentSelector.js.map +1 -0
  73. package/dist/components/AgentSelector.stories.d.ts +12 -0
  74. package/dist/components/AgentSelector.stories.d.ts.map +1 -0
  75. package/dist/components/AgentSelector.stories.js +95 -0
  76. package/dist/components/AgentSelector.stories.js.map +1 -0
  77. package/dist/components/ChatPanel.d.ts +138 -21
  78. package/dist/components/ChatPanel.d.ts.map +1 -1
  79. package/dist/components/ChatPanel.js +181 -26
  80. package/dist/components/ChatPanel.js.map +1 -1
  81. package/dist/components/index.d.ts +3 -1
  82. package/dist/components/index.d.ts.map +1 -1
  83. package/dist/components/index.js +1 -0
  84. package/dist/components/index.js.map +1 -1
  85. package/dist/hooks/useChatStream.d.ts +15 -0
  86. package/dist/hooks/useChatStream.d.ts.map +1 -1
  87. package/dist/hooks/useChatStream.js +13 -1
  88. package/dist/hooks/useChatStream.js.map +1 -1
  89. package/dist/index.d.ts +2 -0
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +5 -0
  92. package/dist/index.js.map +1 -1
  93. package/dist/state/ChatSessionProvider.d.ts +59 -0
  94. package/dist/state/ChatSessionProvider.d.ts.map +1 -0
  95. package/dist/state/ChatSessionProvider.js +71 -0
  96. package/dist/state/ChatSessionProvider.js.map +1 -0
  97. package/dist/state/MockChatStreamTransport.d.ts +54 -0
  98. package/dist/state/MockChatStreamTransport.d.ts.map +1 -0
  99. package/dist/state/MockChatStreamTransport.js +86 -0
  100. package/dist/state/MockChatStreamTransport.js.map +1 -0
  101. package/dist/state/MockChatStreamTransport.stories.d.ts +13 -0
  102. package/dist/state/MockChatStreamTransport.stories.d.ts.map +1 -0
  103. package/dist/state/MockChatStreamTransport.stories.js +60 -0
  104. package/dist/state/MockChatStreamTransport.stories.js.map +1 -0
  105. package/dist/state/bridgeLegacySSE.d.ts +42 -0
  106. package/dist/state/bridgeLegacySSE.d.ts.map +1 -0
  107. package/dist/state/bridgeLegacySSE.js +102 -0
  108. package/dist/state/bridgeLegacySSE.js.map +1 -0
  109. package/dist/state/index.d.ts +32 -0
  110. package/dist/state/index.d.ts.map +1 -0
  111. package/dist/state/index.js +24 -0
  112. package/dist/state/index.js.map +1 -0
  113. package/dist/state/parseSSE.d.ts +50 -0
  114. package/dist/state/parseSSE.d.ts.map +1 -0
  115. package/dist/state/parseSSE.js +112 -0
  116. package/dist/state/parseSSE.js.map +1 -0
  117. package/dist/state/useChatComposer.d.ts +51 -0
  118. package/dist/state/useChatComposer.d.ts.map +1 -0
  119. package/dist/state/useChatComposer.js +82 -0
  120. package/dist/state/useChatComposer.js.map +1 -0
  121. package/dist/state/useChatList.d.ts +73 -0
  122. package/dist/state/useChatList.d.ts.map +1 -0
  123. package/dist/state/useChatList.js +161 -0
  124. package/dist/state/useChatList.js.map +1 -0
  125. package/dist/state/useChatSession.d.ts +29 -0
  126. package/dist/state/useChatSession.d.ts.map +1 -0
  127. package/dist/state/useChatSession.js +37 -0
  128. package/dist/state/useChatSession.js.map +1 -0
  129. package/dist/state/useScrollLock.d.ts +35 -0
  130. package/dist/state/useScrollLock.d.ts.map +1 -0
  131. package/dist/state/useScrollLock.js +74 -0
  132. package/dist/state/useScrollLock.js.map +1 -0
  133. package/dist/state/useToolCallStream.d.ts +38 -0
  134. package/dist/state/useToolCallStream.d.ts.map +1 -0
  135. package/dist/state/useToolCallStream.js +74 -0
  136. package/dist/state/useToolCallStream.js.map +1 -0
  137. package/package.json +11 -3
  138. package/src/cards/ActionSegmentCard.tsx +60 -0
  139. package/src/cards/AnalysisDocumentCard.tsx +81 -0
  140. package/src/cards/AskUserQuestionsCard.tsx +354 -0
  141. package/src/cards/CoverageReportCard.tsx +215 -0
  142. package/src/cards/OrchestrationPlanCard.tsx +328 -0
  143. package/src/cards/PlanSelectorCard.tsx +312 -0
  144. package/src/cards/PlanStepsCard.tsx +52 -0
  145. package/src/cards/QuizCard.tsx +309 -0
  146. package/src/cards/ResearchPlanCard.tsx +169 -0
  147. package/src/cards/SearchSourcesCard.tsx +244 -0
  148. package/src/cards/SuggestedRepliesCard.tsx +48 -0
  149. package/src/cards/SummaryMessageCard.tsx +145 -0
  150. package/src/cards/ThinkingProcessCard.tsx +155 -0
  151. package/src/cards/ToolExecutionCard.tsx +152 -0
  152. package/src/cards/cards.test.tsx +492 -0
  153. package/src/cards/icons.tsx +249 -0
  154. package/src/cards/index.ts +53 -0
  155. package/src/cards/types.ts +27 -0
  156. package/src/components/AgentSelector.stories.tsx +113 -0
  157. package/src/components/AgentSelector.test.tsx +70 -0
  158. package/src/components/AgentSelector.tsx +147 -0
  159. package/src/components/ChatPanel.test.tsx +130 -0
  160. package/src/components/ChatPanel.tsx +612 -138
  161. package/src/components/index.ts +13 -1
  162. package/src/hooks/useChatStream.test.ts +78 -0
  163. package/src/hooks/useChatStream.ts +32 -1
  164. package/src/index.ts +43 -0
  165. package/src/state/ChatSessionProvider.test.tsx +85 -0
  166. package/src/state/ChatSessionProvider.tsx +98 -0
  167. package/src/state/MockChatStreamTransport.stories.tsx +95 -0
  168. package/src/state/MockChatStreamTransport.test.ts +163 -0
  169. package/src/state/MockChatStreamTransport.ts +123 -0
  170. package/src/state/bridgeLegacySSE.test.ts +134 -0
  171. package/src/state/bridgeLegacySSE.ts +134 -0
  172. package/src/state/index.ts +82 -0
  173. package/src/state/parseSSE.test.ts +123 -0
  174. package/src/state/parseSSE.ts +123 -0
  175. package/src/state/useChatComposer.test.ts +145 -0
  176. package/src/state/useChatComposer.ts +117 -0
  177. package/src/state/useChatList.test.ts +135 -0
  178. package/src/state/useChatList.ts +220 -0
  179. package/src/state/useChatSession.test.ts +61 -0
  180. package/src/state/useChatSession.ts +64 -0
  181. package/src/state/useScrollLock.test.ts +99 -0
  182. package/src/state/useScrollLock.ts +105 -0
  183. package/src/state/useToolCallStream.test.ts +86 -0
  184. package/src/state/useToolCallStream.ts +110 -0
@@ -2,7 +2,19 @@ export { MessageList } from './MessageList.js';
2
2
  export type { MessageListProps, MessageRendererProps } from './MessageList.js';
3
3
 
4
4
  export { ChatPanel } from './ChatPanel.js';
5
- export type { ChatPanelProps } from './ChatPanel.js';
5
+ export type {
6
+ ChatPanelProps,
7
+ ChatPanelRootProps,
8
+ ChatPanelHeaderProps,
9
+ ChatPanelMessagesProps,
10
+ ChatPanelInputProps,
11
+ ChatPanelInputHandle,
12
+ ChatPanelEmptyProps,
13
+ ChatPanelStreamingStatusProps,
14
+ } from './ChatPanel.js';
15
+
16
+ export { AgentSelector } from './AgentSelector.js';
17
+ export type { AgentSelectorProps } from './AgentSelector.js';
6
18
 
7
19
  export { OrchestrationPlanCard } from './OrchestrationPlanCard.js';
8
20
  export type {
@@ -231,3 +231,81 @@ describe('useChatStream', () => {
231
231
  expect(true).toBe(true);
232
232
  });
233
233
  });
234
+
235
+ describe('steerUserMessage', () => {
236
+ it('appends the user message when the transport accepts the steer', async () => {
237
+ const steer = vi.fn().mockResolvedValue(true);
238
+ const transport: ChatStreamTransport = {
239
+ stream: vi.fn(async (_input, onEvent) => {
240
+ onEvent({ type: 'content', content: 'working…' });
241
+ // keep the stream open until the test steers
242
+ await new Promise<void>((resolve) => setTimeout(resolve, 30));
243
+ onEvent({ type: 'content', content: 'done' });
244
+ onEvent({ type: 'done' });
245
+ }),
246
+ steer,
247
+ };
248
+ const { result } = renderHook(() => useChatStream({ transport }));
249
+
250
+ let done: Promise<void>;
251
+ act(() => {
252
+ done = result.current.sendUserMessage({ content: 'start' });
253
+ });
254
+ await act(async () => {
255
+ await new Promise((r) => setTimeout(r, 5));
256
+ });
257
+
258
+ let ok = false;
259
+ await act(async () => {
260
+ ok = await result.current.steerUserMessage('补充一句');
261
+ });
262
+ expect(ok).toBe(true);
263
+ expect(steer).toHaveBeenCalledWith('补充一句');
264
+ expect(
265
+ result.current.messages.some((m) => m.role === 'user' && m.content === '补充一句'),
266
+ ).toBe(true);
267
+
268
+ await act(async () => {
269
+ await done;
270
+ });
271
+ });
272
+
273
+ it('returns false and appends nothing when not streaming', async () => {
274
+ const steer = vi.fn().mockResolvedValue(true);
275
+ const transport: ChatStreamTransport = {
276
+ stream: vi.fn(async () => {}),
277
+ steer,
278
+ };
279
+ const { result } = renderHook(() => useChatStream({ transport }));
280
+
281
+ let ok = true;
282
+ await act(async () => {
283
+ ok = await result.current.steerUserMessage('hello');
284
+ });
285
+ expect(ok).toBe(false);
286
+ expect(steer).not.toHaveBeenCalled();
287
+ expect(result.current.messages).toHaveLength(0);
288
+ });
289
+
290
+ it('returns false when the transport has no steer support', async () => {
291
+ const transport: ChatStreamTransport = {
292
+ stream: vi.fn(async () => {
293
+ await new Promise<void>((resolve) => setTimeout(resolve, 20));
294
+ }),
295
+ };
296
+ const { result } = renderHook(() => useChatStream({ transport }));
297
+
298
+ let done: Promise<void>;
299
+ act(() => {
300
+ done = result.current.sendUserMessage({ content: 'start' });
301
+ });
302
+ let ok = true;
303
+ await act(async () => {
304
+ ok = await result.current.steerUserMessage('mid');
305
+ });
306
+ expect(ok).toBe(false);
307
+ await act(async () => {
308
+ await done;
309
+ });
310
+ });
311
+ });
@@ -38,6 +38,14 @@ export interface ChatStreamTransport {
38
38
  input: ChatStreamSendInput,
39
39
  onEvent: (event: SSEEvent) => void,
40
40
  ) => Promise<void | (() => void)>;
41
+ /**
42
+ * Optional mid-turn steering: inject a user message into the currently
43
+ * running turn (CoreLoop `steer`). Resolve `true` when the running turn
44
+ * accepted the message; `false` when no steerable turn is active (the
45
+ * caller should keep the draft — a normal send can follow once the turn
46
+ * ends). When omitted, the UI keeps send-during-streaming disabled.
47
+ */
48
+ steer?: (content: string) => Promise<boolean>;
41
49
  }
42
50
 
43
51
  export interface UseChatStreamOptions {
@@ -63,6 +71,13 @@ export interface UseChatStreamReturn {
63
71
  messages: ChatMessage[];
64
72
  isStreaming: boolean;
65
73
  sendUserMessage: (input: ChatStreamSendInput) => Promise<void>;
74
+ /**
75
+ * Steer the running turn with an extra user message. No-op resolving
76
+ * `false` when not streaming or the transport has no `steer`. On success
77
+ * the message is appended to the visible transcript immediately (the loop
78
+ * consumes it at the next round boundary).
79
+ */
80
+ steerUserMessage: (content: string) => Promise<boolean>;
66
81
  cancel: () => void;
67
82
  /** Replace the message buffer (e.g. when switching chat). */
68
83
  setMessages: (messages: ChatMessage[]) => void;
@@ -310,6 +325,21 @@ export function useChatStream(
310
325
  setStreaming(false);
311
326
  }, [setStreaming]);
312
327
 
328
+ const steerUserMessage = useCallback(
329
+ async (content: string): Promise<boolean> => {
330
+ const steer = options.transport.steer;
331
+ if (!isStreamingRef.current || !steer || !content.trim()) {
332
+ return false;
333
+ }
334
+ const ok = await steer(content);
335
+ if (ok) {
336
+ dispatch({ type: 'append', message: newUserMessage(content) });
337
+ }
338
+ return ok;
339
+ },
340
+ [options.transport],
341
+ );
342
+
313
343
  const setMessages = useCallback((messages: ChatMessage[]) => {
314
344
  dispatch({ type: 'reset', messages });
315
345
  }, []);
@@ -332,10 +362,11 @@ export function useChatStream(
332
362
  messages: state.messages,
333
363
  isStreaming: isStreamingRef.current,
334
364
  sendUserMessage,
365
+ steerUserMessage,
335
366
  cancel,
336
367
  setMessages,
337
368
  appendMessage,
338
369
  }),
339
- [state.messages, sendUserMessage, cancel, setMessages, appendMessage],
370
+ [state.messages, sendUserMessage, steerUserMessage, cancel, setMessages, appendMessage],
340
371
  );
341
372
  }
package/src/index.ts CHANGED
@@ -10,3 +10,46 @@
10
10
 
11
11
  export * from './hooks/index.js';
12
12
  export * from './components/index.js';
13
+
14
+ // `state/` is the home for chat-session primitives (hooks + transports +
15
+ // SSE parsing) lifted out of deeppath / deeppath-agent. The package root
16
+ // re-exports the public surface for convenience; the `./state` subpath is the
17
+ // canonical entrypoint for tree-shaking.
18
+ export {
19
+ SSEParser,
20
+ parseSSEData,
21
+ bridgeLegacySSE,
22
+ MockChatStreamTransport,
23
+ useChatComposer,
24
+ useChatList,
25
+ useChatSession,
26
+ useToolCallStream,
27
+ useScrollLock,
28
+ ChatSessionProvider,
29
+ useChatSessionContext,
30
+ useOptionalChatSession,
31
+ useChatSessionSlice,
32
+ } from './state/index.js';
33
+ export type {
34
+ SSEFrame,
35
+ SSEParserOptions,
36
+ EnvelopeProfile,
37
+ BridgeLegacySSEOptions,
38
+ MockScript,
39
+ MockScriptStep,
40
+ MockChatStreamTransportOptions,
41
+ UseChatComposerOptions,
42
+ UseChatComposerReturn,
43
+ ChatListTransport,
44
+ UseChatListOptions,
45
+ UseChatListReturn,
46
+ UseChatSessionOptions,
47
+ UseChatSessionReturn,
48
+ ToolCallEntry,
49
+ UseToolCallStreamOptions,
50
+ UseToolCallStreamReturn,
51
+ UseScrollLockOptions,
52
+ UseScrollLockReturn,
53
+ ChatSessionContextValue,
54
+ ChatSessionProviderProps,
55
+ } from './state/index.js';
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Smoke tests for `ChatSessionProvider`. Verifies that the provider exposes
3
+ * the active session, that `useChatSessionContext` throws outside it, and
4
+ * that the optional / slice variants degrade gracefully.
5
+ */
6
+ import * as React from 'react';
7
+ import { render, screen, act, fireEvent } from '@testing-library/react';
8
+ import { describe, expect, it, vi } from 'vitest';
9
+
10
+ import {
11
+ ChatSessionProvider,
12
+ useChatSessionContext,
13
+ useOptionalChatSession,
14
+ useChatSessionSlice,
15
+ useChatSession,
16
+ } from './index.js';
17
+ import { MockChatStreamTransport } from './MockChatStreamTransport.js';
18
+
19
+ function Probe({ onValue }: { onValue: (v: unknown) => void }) {
20
+ const ctx = useChatSessionContext();
21
+ React.useEffect(() => {
22
+ onValue({ messages: ctx.messages.length, isStreaming: ctx.isStreaming });
23
+ }, [ctx.messages.length, ctx.isStreaming, onValue]);
24
+ return <span data-testid="ok">ok</span>;
25
+ }
26
+
27
+ function OptionalProbe({ onResult }: { onResult: (v: unknown) => void }) {
28
+ const ctx = useOptionalChatSession();
29
+ React.useEffect(() => onResult(ctx), [ctx, onResult]);
30
+ return <span>maybe</span>;
31
+ }
32
+
33
+ function SliceProbe({ onValue }: { onValue: (v: unknown) => void }) {
34
+ const slice = useChatSessionSlice();
35
+ React.useEffect(() => onValue(slice), [slice, onValue]);
36
+ return <span>slice</span>;
37
+ }
38
+
39
+ function Host({ children }: { children: React.ReactNode }) {
40
+ const transport = React.useMemo(
41
+ () =>
42
+ new MockChatStreamTransport({
43
+ script: [
44
+ { event: { type: 'message.delta', role: 'assistant', delta: 'hi' } },
45
+ { event: { type: 'message.complete' } },
46
+ ],
47
+ }),
48
+ [],
49
+ );
50
+ const session = useChatSession({ transport });
51
+ return <ChatSessionProvider value={session}>{children}</ChatSessionProvider>;
52
+ }
53
+
54
+ describe('ChatSessionProvider', () => {
55
+ it('exposes the session via useChatSessionContext', () => {
56
+ const seen = vi.fn();
57
+ render(
58
+ <Host>
59
+ <Probe onValue={seen} />
60
+ </Host>,
61
+ );
62
+ expect(screen.getByTestId('ok')).toBeTruthy();
63
+ expect(seen).toHaveBeenCalled();
64
+ });
65
+
66
+ it('useChatSessionContext throws outside provider', () => {
67
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
68
+ expect(() => {
69
+ render(<Probe onValue={vi.fn()} />);
70
+ }).toThrow(/inside a <ChatSessionProvider/);
71
+ consoleError.mockRestore();
72
+ });
73
+
74
+ it('useOptionalChatSession returns null outside provider', () => {
75
+ const seen = vi.fn();
76
+ render(<OptionalProbe onResult={seen} />);
77
+ expect(seen).toHaveBeenCalledWith(null);
78
+ });
79
+
80
+ it('useChatSessionSlice returns null outside provider', () => {
81
+ const seen = vi.fn();
82
+ render(<SliceProbe onValue={seen} />);
83
+ expect(seen).toHaveBeenCalledWith(null);
84
+ });
85
+ });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `ChatSessionProvider` + `useChatSessionContext`
3
+ *
4
+ * Lifts the chat-session primitives out of the host app's god-context (e.g.
5
+ * deeppath's `ChatUIContext`, deeppath-agent's hooks) into a framework-owned
6
+ * provider. Concretely it owns:
7
+ *
8
+ * - the streaming transcript: `messages`, `isStreaming`, `streamingMessageId`
9
+ * - send / cancel / clear actions
10
+ * - the composer (draft, send, cancel, key handlers)
11
+ *
12
+ * App-specific concerns that don't belong here (file uploads, geolocation,
13
+ * suggestion banners, chat tool toggles, chat list / agent selector) stay in
14
+ * the host app's own context. The `extras` slot lets apps thread custom
15
+ * values through the same provider so chat cards can call host actions
16
+ * without prop-drilling.
17
+ *
18
+ * Usage in a host app:
19
+ *
20
+ * <ChatSessionProvider
21
+ * value={{ ...useChatSession({ transport }), extras: { ...appHooks } }}
22
+ * >
23
+ * <ChatPanel.Root>...</ChatPanel.Root>
24
+ * </ChatSessionProvider>
25
+ *
26
+ * Cards / shell pieces consume via `useChatSessionContext()` so they don't
27
+ * import the host's context directly. This is the seam that lets the same
28
+ * compound `ChatPanel` work in deeppath, deeppath-agent, and the framework
29
+ * `examples/web-shell`.
30
+ */
31
+ import * as React from 'react';
32
+ import type { ChatMessage } from '@steerable/agent-protocol';
33
+ import type { UseChatSessionReturn } from './useChatSession.js';
34
+
35
+ export interface ChatSessionContextValue<TExtras = unknown> extends UseChatSessionReturn {
36
+ /** Host-specific values threaded through the same provider (geo, files, ...). */
37
+ extras?: TExtras;
38
+ }
39
+
40
+ const ChatSessionContext = React.createContext<ChatSessionContextValue | null>(null);
41
+
42
+ export interface ChatSessionProviderProps<TExtras = unknown> {
43
+ value: ChatSessionContextValue<TExtras>;
44
+ children: React.ReactNode;
45
+ }
46
+
47
+ export function ChatSessionProvider<TExtras = unknown>({
48
+ value,
49
+ children,
50
+ }: ChatSessionProviderProps<TExtras>) {
51
+ return (
52
+ <ChatSessionContext.Provider value={value as ChatSessionContextValue}>
53
+ {children}
54
+ </ChatSessionContext.Provider>
55
+ );
56
+ }
57
+
58
+ /**
59
+ * Read the active chat session. Throws if no `ChatSessionProvider` is
60
+ * mounted above -- this is intentional: cards / shell pieces should be
61
+ * usable both inside the provider (typical) and via direct props for
62
+ * apps that haven't migrated yet (use `useOptionalChatSession()` for that).
63
+ */
64
+ export function useChatSessionContext<TExtras = unknown>(): ChatSessionContextValue<TExtras> {
65
+ const ctx = React.useContext(ChatSessionContext);
66
+ if (!ctx) {
67
+ throw new Error('useChatSessionContext must be used inside a <ChatSessionProvider />');
68
+ }
69
+ return ctx as ChatSessionContextValue<TExtras>;
70
+ }
71
+
72
+ /** Non-throwing variant for components that want to fall back to props. */
73
+ export function useOptionalChatSession<TExtras = unknown>():
74
+ | ChatSessionContextValue<TExtras>
75
+ | null {
76
+ const ctx = React.useContext(ChatSessionContext);
77
+ return ctx as ChatSessionContextValue<TExtras> | null;
78
+ }
79
+
80
+ /**
81
+ * Convenience selector for the most-used slice in cards (`{ messages,
82
+ * isStreaming, sendUserMessage, cancel }`). Returns null when there's no
83
+ * provider so cards can opt into context-driven mode incrementally.
84
+ */
85
+ export function useChatSessionSlice():
86
+ | Pick<ChatSessionContextValue, 'messages' | 'isStreaming' | 'sendUserMessage' | 'cancel'>
87
+ | null {
88
+ const ctx = useOptionalChatSession();
89
+ if (!ctx) return null;
90
+ return {
91
+ messages: ctx.messages,
92
+ isStreaming: ctx.isStreaming,
93
+ sendUserMessage: ctx.sendUserMessage,
94
+ cancel: ctx.cancel,
95
+ };
96
+ }
97
+
98
+ export type { ChatMessage };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Storybook story for `MockChatStreamTransport` paired with `useChatSession`.
3
+ * This is the canonical "framework runs end-to-end with zero backend" demo —
4
+ * the same pattern is used by `examples/web-shell` in wave 5.
5
+ */
6
+
7
+ import type { Meta, StoryObj } from '@storybook/react';
8
+ import { useMemo } from 'react';
9
+ import type { SSEEvent } from '@steerable/agent-protocol';
10
+ import { ChatPanel } from '../components/ChatPanel.js';
11
+ import { MockChatStreamTransport } from './MockChatStreamTransport.js';
12
+ import { useChatSession } from './useChatSession.js';
13
+
14
+ const meta: Meta = {
15
+ title: 'State/MockChatStreamTransport',
16
+ parameters: {
17
+ layout: 'fullscreen',
18
+ docs: {
19
+ description: {
20
+ component:
21
+ 'Scripted SSE transport used in tests, stories, and the framework web-shell example. Pair with `useChatSession` for a fully offline chat demo.',
22
+ },
23
+ },
24
+ },
25
+ };
26
+ export default meta;
27
+ type Story = StoryObj;
28
+
29
+ const baselineScript: SSEEvent[] = [
30
+ { type: 'content', content: 'Sure — ' },
31
+ { type: 'content', content: "here's a plan:\n\n" },
32
+ { type: 'content', content: '1. Outline goals\n' },
33
+ { type: 'content', content: '2. Gather inputs\n' },
34
+ { type: 'content', content: '3. Iterate\n\n' },
35
+ { type: 'content', content: 'Want me to dive deeper into any step?' },
36
+ { type: 'done' },
37
+ ];
38
+
39
+ const toolCallScript: SSEEvent[] = [
40
+ { type: 'content', content: 'Checking the weather…\n' },
41
+ {
42
+ type: 'tool_call',
43
+ payload: { id: 'c1', name: 'get_weather', arguments: { city: 'Tokyo' } },
44
+ },
45
+ {
46
+ type: 'tool_result',
47
+ payload: { success: true, data: { temp: 22, conditions: 'clear' } },
48
+ },
49
+ { type: 'content', content: '\nIt is 22°C and clear in Tokyo right now.' },
50
+ { type: 'done' },
51
+ ];
52
+
53
+ function DemoChat({ scripts, delayMs }: { scripts: SSEEvent[][]; delayMs: number }) {
54
+ const transport = useMemo(
55
+ () =>
56
+ new MockChatStreamTransport({
57
+ scripts,
58
+ defaultDelayMs: delayMs,
59
+ exhaustionPolicy: 'cycle',
60
+ }),
61
+ [scripts, delayMs],
62
+ );
63
+ const session = useChatSession({ transport });
64
+
65
+ return (
66
+ <div className="h-[640px] w-full max-w-3xl border border-agent-border bg-agent-canvas">
67
+ <ChatPanel
68
+ messages={session.messages}
69
+ isStreaming={session.isStreaming}
70
+ onSubmit={async ({ content }) => {
71
+ await session.sendUserMessage({ content });
72
+ }}
73
+ onCancel={session.cancel}
74
+ emptyState={
75
+ <div className="m-auto max-w-sm text-center text-sm text-agent-muted-foreground">
76
+ <p className="font-medium text-agent-foreground">Try the mock transport</p>
77
+ <p className="mt-1">Anything you type triggers the next scripted reply.</p>
78
+ </div>
79
+ }
80
+ />
81
+ </div>
82
+ );
83
+ }
84
+
85
+ export const ContentOnly: Story = {
86
+ render: () => <DemoChat scripts={[baselineScript]} delayMs={30} />,
87
+ };
88
+
89
+ export const WithToolCall: Story = {
90
+ render: () => <DemoChat scripts={[toolCallScript]} delayMs={30} />,
91
+ };
92
+
93
+ export const Cycling: Story = {
94
+ render: () => <DemoChat scripts={[baselineScript, toolCallScript]} delayMs={30} />,
95
+ };
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Tests for `MockChatStreamTransport`.
3
+ *
4
+ * The mock is the source of all canned demos / stories / fixtures, so a
5
+ * regression here cascades. We cover: per-turn script rotation, exhaustion
6
+ * policies, the cancel handle, and per-step delays.
7
+ */
8
+
9
+ import { describe, expect, it, vi } from 'vitest';
10
+ import { MockChatStreamTransport } from './MockChatStreamTransport';
11
+
12
+ describe('MockChatStreamTransport', () => {
13
+ it('replays the script for the current turn', async () => {
14
+ const onEvent = vi.fn();
15
+ const transport = new MockChatStreamTransport({
16
+ scripts: [[
17
+ { type: 'content', content: 'a' },
18
+ { type: 'content', content: 'b' },
19
+ { type: 'done' },
20
+ ]],
21
+ });
22
+ await transport.stream({ content: 'hi' }, onEvent);
23
+ // The mock dispatches synchronously when defaultDelayMs is 0 — but it
24
+ // still goes through a microtask queue inside `run()`. Flush.
25
+ await new Promise((r) => setTimeout(r, 0));
26
+ expect(onEvent).toHaveBeenCalledTimes(3);
27
+ expect(onEvent.mock.calls.map((c) => c[0].type)).toEqual([
28
+ 'content',
29
+ 'content',
30
+ 'done',
31
+ ]);
32
+ });
33
+
34
+ it('cycles scripts when more turns than scripts exist (default)', async () => {
35
+ const transport = new MockChatStreamTransport({
36
+ scripts: [
37
+ [{ type: 'content', content: 'a' }, { type: 'done' }],
38
+ [{ type: 'content', content: 'b' }, { type: 'done' }],
39
+ ],
40
+ });
41
+ const sink: string[] = [];
42
+ const onEvent = (e: any) => {
43
+ if (e.type === 'content') sink.push(e.content);
44
+ };
45
+ await transport.stream({ content: '' }, onEvent);
46
+ await transport.stream({ content: '' }, onEvent);
47
+ await transport.stream({ content: '' }, onEvent);
48
+ await new Promise((r) => setTimeout(r, 0));
49
+ expect(sink).toEqual(['a', 'b', 'a']);
50
+ });
51
+
52
+ it('exhaustionPolicy=last keeps emitting the last script', async () => {
53
+ const transport = new MockChatStreamTransport({
54
+ scripts: [
55
+ [{ type: 'content', content: 'first' }, { type: 'done' }],
56
+ [{ type: 'content', content: 'last' }, { type: 'done' }],
57
+ ],
58
+ exhaustionPolicy: 'last',
59
+ });
60
+ const sink: string[] = [];
61
+ const onEvent = (e: any) => {
62
+ if (e.type === 'content') sink.push(e.content);
63
+ };
64
+ await transport.stream({ content: '' }, onEvent);
65
+ await transport.stream({ content: '' }, onEvent);
66
+ await transport.stream({ content: '' }, onEvent);
67
+ await new Promise((r) => setTimeout(r, 0));
68
+ expect(sink).toEqual(['first', 'last', 'last']);
69
+ });
70
+
71
+ it('exhaustionPolicy=empty emits a bare done event', async () => {
72
+ const transport = new MockChatStreamTransport({
73
+ scripts: [[{ type: 'content', content: 'one' }, { type: 'done' }]],
74
+ exhaustionPolicy: 'empty',
75
+ });
76
+ const events: string[] = [];
77
+ await transport.stream({ content: '' }, (e) => events.push(e.type));
78
+ await transport.stream({ content: '' }, (e) => events.push(e.type));
79
+ await new Promise((r) => setTimeout(r, 0));
80
+ expect(events).toEqual(['content', 'done', 'done']);
81
+ });
82
+
83
+ it('accepts a callback in place of a script array', async () => {
84
+ const transport = new MockChatStreamTransport({
85
+ scripts: (turn, input) => [
86
+ { type: 'content', content: `${input.content}:${turn}` },
87
+ { type: 'done' },
88
+ ],
89
+ });
90
+ const sink: string[] = [];
91
+ await transport.stream({ content: 'hi' }, (e) => {
92
+ if (e.type === 'content') sink.push(e.content!);
93
+ });
94
+ await transport.stream({ content: 'yo' }, (e) => {
95
+ if (e.type === 'content') sink.push(e.content!);
96
+ });
97
+ await new Promise((r) => setTimeout(r, 0));
98
+ expect(sink).toEqual(['hi:0', 'yo:1']);
99
+ });
100
+
101
+ it('honours per-step delayMs', async () => {
102
+ vi.useFakeTimers();
103
+ try {
104
+ const transport = new MockChatStreamTransport({
105
+ scripts: [[
106
+ { event: { type: 'content', content: 'fast' } },
107
+ { event: { type: 'content', content: 'slow' }, delayMs: 100 },
108
+ { type: 'done' },
109
+ ]],
110
+ });
111
+ const events: any[] = [];
112
+ const p = transport.stream({ content: '' }, (e) => events.push(e));
113
+ await p;
114
+ // After the immediate microtask: first event delivered.
115
+ await Promise.resolve();
116
+ expect(events.length).toBeLessThanOrEqual(2);
117
+ await vi.advanceTimersByTimeAsync(100);
118
+ // After the delay: all events delivered.
119
+ expect(events.length).toBe(3);
120
+ expect(events[1].content).toBe('slow');
121
+ } finally {
122
+ vi.useRealTimers();
123
+ }
124
+ });
125
+
126
+ it('cancel handle stops further events', async () => {
127
+ const transport = new MockChatStreamTransport({
128
+ scripts: [[
129
+ { event: { type: 'content', content: 'a' }, delayMs: 10 },
130
+ { event: { type: 'content', content: 'b' }, delayMs: 10 },
131
+ { type: 'done' },
132
+ ]],
133
+ });
134
+ const events: any[] = [];
135
+ const cancel = await transport.stream({ content: '' }, (e) => events.push(e));
136
+ cancel();
137
+ await new Promise((r) => setTimeout(r, 50));
138
+ // Cancel should have prevented at least one of the delayed events from
139
+ // arriving. We don't pin the exact count (timing-dependent), just the
140
+ // strict upper bound.
141
+ expect(events.length).toBeLessThan(3);
142
+ });
143
+
144
+ it('reset() rewinds the turn counter', async () => {
145
+ const transport = new MockChatStreamTransport({
146
+ scripts: [
147
+ [{ type: 'content', content: 'a' }, { type: 'done' }],
148
+ [{ type: 'content', content: 'b' }, { type: 'done' }],
149
+ ],
150
+ exhaustionPolicy: 'empty',
151
+ });
152
+ const sink: string[] = [];
153
+ await transport.stream({ content: '' }, (e) => {
154
+ if (e.type === 'content') sink.push(e.content!);
155
+ });
156
+ transport.reset();
157
+ await transport.stream({ content: '' }, (e) => {
158
+ if (e.type === 'content') sink.push(e.content!);
159
+ });
160
+ await new Promise((r) => setTimeout(r, 0));
161
+ expect(sink).toEqual(['a', 'a']);
162
+ });
163
+ });