@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
@@ -0,0 +1,123 @@
1
+ /**
2
+ * `parseSSE` — wire-level Server-Sent-Events parser.
3
+ *
4
+ * Feed it raw text chunks from any source (fetch ReadableStream chunks,
5
+ * Electron IPC `webContents.send` payloads, mocked fixtures, …). It buffers,
6
+ * splits on the `\r?\n\r?\n` frame terminator, and emits one
7
+ * `{ event?, data?, id?, retry? }` per complete frame. JSON parsing of `data`
8
+ * is intentionally out of scope; downstream layers like {@link bridgeLegacySSE}
9
+ * handle that.
10
+ *
11
+ * Replaces deeppath's `SSEParser` and deeppath-agent's `LocalBackendSseParser`
12
+ * with a single dependency-free implementation that compiles to both Node and
13
+ * the browser. Heartbeat-timeout watchdogs (deeppath had a 30 s gate) live on
14
+ * the transport, not here — this class is a pure parser.
15
+ */
16
+
17
+ export interface SSEFrame {
18
+ /** The `event: …` field; undefined for default `message` events. */
19
+ event?: string;
20
+ /** Concatenated `data: …` lines (newline-joined per the SSE spec). */
21
+ data?: string;
22
+ /** The `id: …` field. */
23
+ id?: string;
24
+ /** The `retry: …` field, as milliseconds. */
25
+ retry?: number;
26
+ }
27
+
28
+ export interface SSEParserOptions {
29
+ onFrame: (frame: SSEFrame) => void;
30
+ onError?: (err: Error) => void;
31
+ /** Called when the stream emits the canonical `data: [DONE]` terminator. */
32
+ onComplete?: () => void;
33
+ }
34
+
35
+ export class SSEParser {
36
+ private buffer = '';
37
+ private completed = false;
38
+
39
+ constructor(private readonly options: SSEParserOptions) {}
40
+
41
+ /** Feed one chunk. Safe to call with partial UTF-8 strings (we don't decode). */
42
+ feed(chunk: string): void {
43
+ if (this.completed) return;
44
+ this.buffer += chunk;
45
+ const parts = this.buffer.split(/\r?\n\r?\n/);
46
+ this.buffer = parts.pop() ?? '';
47
+ for (const frame of parts) {
48
+ if (frame.length > 0) this.processFrame(frame);
49
+ }
50
+ }
51
+
52
+ /** Call when the source signals end-of-stream. Flushes any trailing frame. */
53
+ end(): void {
54
+ if (this.completed) return;
55
+ if (this.buffer.trim().length > 0) this.processFrame(this.buffer);
56
+ this.buffer = '';
57
+ }
58
+
59
+ /** Drop all state and stop emitting. */
60
+ cleanup(): void {
61
+ this.buffer = '';
62
+ this.completed = true;
63
+ }
64
+
65
+ private processFrame(raw: string): void {
66
+ const frame: SSEFrame = {};
67
+ const dataLines: string[] = [];
68
+ for (const line of raw.split(/\r?\n/)) {
69
+ if (line.length === 0) continue;
70
+ // Comments per the SSE spec — leading `:` lines.
71
+ if (line.startsWith(':')) continue;
72
+ const colon = line.indexOf(':');
73
+ const field = colon === -1 ? line : line.substring(0, colon);
74
+ let value = colon === -1 ? '' : line.substring(colon + 1);
75
+ if (value.startsWith(' ')) value = value.substring(1);
76
+ switch (field) {
77
+ case 'event':
78
+ frame.event = value;
79
+ break;
80
+ case 'data':
81
+ dataLines.push(value);
82
+ break;
83
+ case 'id':
84
+ frame.id = value;
85
+ break;
86
+ case 'retry': {
87
+ const ms = Number.parseInt(value, 10);
88
+ if (Number.isFinite(ms)) frame.retry = ms;
89
+ break;
90
+ }
91
+ default:
92
+ break;
93
+ }
94
+ }
95
+ if (dataLines.length === 0 && frame.event === undefined && frame.id === undefined) {
96
+ return;
97
+ }
98
+ if (dataLines.length > 0) frame.data = dataLines.join('\n');
99
+
100
+ if (frame.data === '[DONE]' || frame.data === '"[DONE]"') {
101
+ this.completed = true;
102
+ this.options.onComplete?.();
103
+ return;
104
+ }
105
+ try {
106
+ this.options.onFrame(frame);
107
+ } catch (err) {
108
+ this.options.onError?.(err instanceof Error ? err : new Error(String(err)));
109
+ }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Tolerant JSON parser for SSE `data:` payloads — returns the raw string when
115
+ * the body isn't JSON, which deeppath relies on for bare-text content deltas.
116
+ */
117
+ export function parseSSEData(data: string): unknown {
118
+ try {
119
+ return JSON.parse(data);
120
+ } catch {
121
+ return data;
122
+ }
123
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Tests for `useChatComposer`. The hook is intentionally tiny so the suite is
3
+ * focused — keyboard semantics, the streaming guard, and the trim/clear
4
+ * contract are what consumers actually pin behaviour against.
5
+ */
6
+
7
+ import { describe, expect, it, vi } from 'vitest';
8
+ import { act, renderHook } from '@testing-library/react';
9
+ import { useChatComposer } from './useChatComposer';
10
+
11
+ function makeKey(key: string, extras: Partial<KeyboardEvent> = {}) {
12
+ // Construct a minimal React keyboard event matching what the hook uses.
13
+ return {
14
+ key,
15
+ shiftKey: extras.shiftKey ?? false,
16
+ metaKey: extras.metaKey ?? false,
17
+ ctrlKey: extras.ctrlKey ?? false,
18
+ preventDefault: vi.fn(),
19
+ } as unknown as React.KeyboardEvent<HTMLElement>;
20
+ }
21
+
22
+ describe('useChatComposer', () => {
23
+ it('submits the trimmed value and clears the draft', async () => {
24
+ const onSend = vi.fn().mockResolvedValue(undefined);
25
+ const { result } = renderHook(() =>
26
+ useChatComposer({ onSend, initialValue: ' hello ' }),
27
+ );
28
+
29
+ await act(async () => {
30
+ await result.current.submit();
31
+ });
32
+
33
+ expect(onSend).toHaveBeenCalledWith('hello');
34
+ expect(result.current.value).toBe('');
35
+ });
36
+
37
+ it('does not submit empty / whitespace-only drafts', async () => {
38
+ const onSend = vi.fn();
39
+ const { result } = renderHook(() =>
40
+ useChatComposer({ onSend, initialValue: ' ' }),
41
+ );
42
+
43
+ await act(async () => {
44
+ await result.current.submit();
45
+ });
46
+
47
+ expect(onSend).not.toHaveBeenCalled();
48
+ });
49
+
50
+ it('Enter submits, Shift+Enter inserts a newline (default)', async () => {
51
+ const onSend = vi.fn();
52
+ const { result } = renderHook(() => useChatComposer({ onSend, initialValue: 'hi' }));
53
+
54
+ const submit = makeKey('Enter');
55
+ await act(async () => {
56
+ result.current.handleKeyDown(submit);
57
+ });
58
+ expect(submit.preventDefault).toHaveBeenCalled();
59
+ expect(onSend).toHaveBeenCalledWith('hi');
60
+
61
+ const shift = makeKey('Enter', { shiftKey: true });
62
+ await act(async () => {
63
+ result.current.handleKeyDown(shift);
64
+ });
65
+ expect(shift.preventDefault).not.toHaveBeenCalled();
66
+ });
67
+
68
+ it('isStreaming flips Enter into a cancel', async () => {
69
+ const onSend = vi.fn();
70
+ const onCancel = vi.fn();
71
+ const { result } = renderHook(() =>
72
+ useChatComposer({ onSend, onCancel, isStreaming: true, initialValue: 'x' }),
73
+ );
74
+
75
+ const ev = makeKey('Enter');
76
+ await act(async () => {
77
+ result.current.handleKeyDown(ev);
78
+ });
79
+ expect(onCancel).toHaveBeenCalledOnce();
80
+ expect(onSend).not.toHaveBeenCalled();
81
+ });
82
+
83
+ it('Escape cancels while streaming', async () => {
84
+ const onCancel = vi.fn();
85
+ const { result } = renderHook(() =>
86
+ useChatComposer({ onSend: vi.fn(), onCancel, isStreaming: true }),
87
+ );
88
+ const ev = makeKey('Escape');
89
+ await act(async () => {
90
+ result.current.handleKeyDown(ev);
91
+ });
92
+ expect(onCancel).toHaveBeenCalledOnce();
93
+ });
94
+
95
+ it('enterToSubmit=false requires Cmd/Ctrl+Enter', async () => {
96
+ const onSend = vi.fn();
97
+ const { result } = renderHook(() =>
98
+ useChatComposer({ onSend, enterToSubmit: false, initialValue: 'hi' }),
99
+ );
100
+
101
+ const plain = makeKey('Enter');
102
+ await act(async () => {
103
+ result.current.handleKeyDown(plain);
104
+ });
105
+ expect(plain.preventDefault).not.toHaveBeenCalled();
106
+ expect(onSend).not.toHaveBeenCalled();
107
+
108
+ const cmd = makeKey('Enter', { metaKey: true });
109
+ await act(async () => {
110
+ result.current.handleKeyDown(cmd);
111
+ });
112
+ expect(cmd.preventDefault).toHaveBeenCalled();
113
+ expect(onSend).toHaveBeenCalledWith('hi');
114
+ });
115
+
116
+ it('disabled state suppresses both submit and key handling', async () => {
117
+ const onSend = vi.fn();
118
+ const { result } = renderHook(() =>
119
+ useChatComposer({ onSend, disabled: true, initialValue: 'hi' }),
120
+ );
121
+
122
+ await act(async () => {
123
+ await result.current.submit();
124
+ });
125
+ const ev = makeKey('Enter');
126
+ await act(async () => {
127
+ result.current.handleKeyDown(ev);
128
+ });
129
+ expect(onSend).not.toHaveBeenCalled();
130
+ expect(ev.preventDefault).not.toHaveBeenCalled();
131
+ expect(result.current.canSubmit).toBe(false);
132
+ });
133
+
134
+ it('clear() empties the draft without sending', async () => {
135
+ const onSend = vi.fn();
136
+ const { result } = renderHook(() =>
137
+ useChatComposer({ onSend, initialValue: 'hi' }),
138
+ );
139
+ await act(async () => {
140
+ result.current.clear();
141
+ });
142
+ expect(result.current.value).toBe('');
143
+ expect(onSend).not.toHaveBeenCalled();
144
+ });
145
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `useChatComposer` — input draft + send/cancel + keyboard glue.
3
+ *
4
+ * Lifted from deeppath-agent's `LocalChatPanel.tsx` (inline `inputValue` /
5
+ * `handleSubmit`) and deeppath's `ChatInput` (cancel handling). The hook is
6
+ * intentionally UI-free: it owns the draft string and the keyboard
7
+ * dispatcher; the visible textarea and send button live in the consuming
8
+ * component.
9
+ *
10
+ * Typical wiring:
11
+ *
12
+ * const composer = useChatComposer({ onSend: send, onCancel: cancel });
13
+ *
14
+ * <textarea
15
+ * value={composer.value}
16
+ * onChange={(e) => composer.setValue(e.target.value)}
17
+ * onKeyDown={composer.handleKeyDown}
18
+ * />
19
+ * <button onClick={composer.submit}>Send</button>
20
+ */
21
+
22
+ import { useCallback, useRef, useState } from 'react';
23
+
24
+ export interface UseChatComposerOptions {
25
+ initialValue?: string;
26
+ /** Called with the trimmed value when the user submits a non-empty draft. */
27
+ onSend: (value: string) => void | Promise<void>;
28
+ /** Called when the user wants to cancel the in-flight stream. Optional. */
29
+ onCancel?: () => void;
30
+ /**
31
+ * If true (default), Enter submits and Shift+Enter inserts a newline. Set
32
+ * to false for "Enter inserts newline, Cmd/Ctrl+Enter submits" semantics.
33
+ */
34
+ enterToSubmit?: boolean;
35
+ /** Whether the composer is currently disabled (no submit / no key handling). */
36
+ disabled?: boolean;
37
+ /**
38
+ * Whether the parent reports an in-flight stream. When true, `submit()` is
39
+ * a no-op and Enter triggers `onCancel` instead. Mirrors the cloud product.
40
+ */
41
+ isStreaming?: boolean;
42
+ }
43
+
44
+ export interface UseChatComposerReturn {
45
+ value: string;
46
+ setValue: (value: string) => void;
47
+ submit: () => Promise<void> | void;
48
+ cancel: () => void;
49
+ clear: () => void;
50
+ handleKeyDown: (e: React.KeyboardEvent<HTMLElement>) => void;
51
+ /** True if `value.trim()` is non-empty and we're not disabled. */
52
+ canSubmit: boolean;
53
+ }
54
+
55
+ export function useChatComposer(options: UseChatComposerOptions): UseChatComposerReturn {
56
+ const [value, setValue] = useState(options.initialValue ?? '');
57
+ const optionsRef = useRef(options);
58
+ optionsRef.current = options;
59
+
60
+ const submit = useCallback(async () => {
61
+ const opts = optionsRef.current;
62
+ if (opts.disabled || opts.isStreaming) return;
63
+ const trimmed = value.trim();
64
+ if (!trimmed) return;
65
+ // Clear synchronously so the user can keep typing while the request flies.
66
+ setValue('');
67
+ await opts.onSend(trimmed);
68
+ }, [value]);
69
+
70
+ const cancel = useCallback(() => {
71
+ optionsRef.current.onCancel?.();
72
+ }, []);
73
+
74
+ const clear = useCallback(() => setValue(''), []);
75
+
76
+ const handleKeyDown = useCallback(
77
+ (e: React.KeyboardEvent<HTMLElement>) => {
78
+ const opts = optionsRef.current;
79
+ if (opts.disabled) return;
80
+ const enterSubmits = opts.enterToSubmit ?? true;
81
+ if (e.key === 'Enter') {
82
+ if (enterSubmits && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
83
+ e.preventDefault();
84
+ if (opts.isStreaming) {
85
+ cancel();
86
+ } else {
87
+ void submit();
88
+ }
89
+ return;
90
+ }
91
+ if (!enterSubmits && (e.metaKey || e.ctrlKey)) {
92
+ e.preventDefault();
93
+ if (opts.isStreaming) cancel();
94
+ else void submit();
95
+ return;
96
+ }
97
+ }
98
+ if (e.key === 'Escape' && opts.isStreaming) {
99
+ e.preventDefault();
100
+ cancel();
101
+ }
102
+ },
103
+ [submit, cancel],
104
+ );
105
+
106
+ const canSubmit = value.trim().length > 0 && !(options.disabled || options.isStreaming);
107
+
108
+ return {
109
+ value,
110
+ setValue,
111
+ submit,
112
+ cancel,
113
+ clear,
114
+ handleKeyDown,
115
+ canSubmit,
116
+ };
117
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Tests for `useChatList`. The generics are deliberately unconstrained, so
3
+ * here we instantiate with simple `{id,title}`-shaped chats and agents to
4
+ * keep the suite readable.
5
+ */
6
+
7
+ import { describe, expect, it, vi } from 'vitest';
8
+ import { act, renderHook, waitFor } from '@testing-library/react';
9
+ import {
10
+ useChatList,
11
+ type ChatListTransport,
12
+ } from './useChatList';
13
+
14
+ interface ChatDouble {
15
+ id: string;
16
+ title: string;
17
+ }
18
+
19
+ interface AgentDouble {
20
+ id: string;
21
+ }
22
+
23
+ function makeTransport(): {
24
+ transport: ChatListTransport<ChatDouble, AgentDouble>;
25
+ listChats: ReturnType<typeof vi.fn>;
26
+ listAgents: ReturnType<typeof vi.fn>;
27
+ createChat: ReturnType<typeof vi.fn>;
28
+ deleteChat: ReturnType<typeof vi.fn>;
29
+ } {
30
+ const listChats = vi.fn(async ({ page }: { page: number }) => ({
31
+ chats:
32
+ page === 1
33
+ ? [
34
+ { id: 'c1', title: 'First' },
35
+ { id: 'c2', title: 'Second' },
36
+ ]
37
+ : [{ id: 'c3', title: 'Third' }],
38
+ hasMore: page === 1,
39
+ }));
40
+ const listAgents = vi.fn(async () => [{ id: 'a1' }, { id: 'a2' }]);
41
+ const createChat = vi.fn(async () => ({ id: 'c-new', title: 'New' }));
42
+ const deleteChat = vi.fn(async () => true);
43
+ return {
44
+ transport: {
45
+ listChats,
46
+ listAgents,
47
+ createChat,
48
+ deleteChat,
49
+ getChatId: (c) => c.id,
50
+ setChatTitle: (c, title) => ({ ...c, title }),
51
+ getAgentId: (a) => a.id,
52
+ },
53
+ listChats,
54
+ listAgents,
55
+ createChat,
56
+ deleteChat,
57
+ };
58
+ }
59
+
60
+ describe('useChatList', () => {
61
+ it('loads chats + agents on mount', async () => {
62
+ const { transport } = makeTransport();
63
+ const { result } = renderHook(() => useChatList({ transport, pageSize: 50 }));
64
+
65
+ await waitFor(() => {
66
+ expect(result.current.chats.length).toBe(2);
67
+ expect(result.current.agents.length).toBe(2);
68
+ });
69
+ expect(result.current.hasMoreChats).toBe(true);
70
+ });
71
+
72
+ it('loadMoreChats appends page 2 results', async () => {
73
+ const { transport } = makeTransport();
74
+ const { result } = renderHook(() => useChatList({ transport, pageSize: 50 }));
75
+
76
+ await waitFor(() => expect(result.current.chats.length).toBe(2));
77
+
78
+ await act(async () => {
79
+ await result.current.loadMoreChats();
80
+ });
81
+
82
+ expect(result.current.chats.map((c) => c.id)).toEqual(['c1', 'c2', 'c3']);
83
+ expect(result.current.hasMoreChats).toBe(false);
84
+ });
85
+
86
+ it('patchChatTitle updates without re-fetching', async () => {
87
+ const { transport, listChats } = makeTransport();
88
+ const { result } = renderHook(() => useChatList({ transport, pageSize: 50 }));
89
+ await waitFor(() => expect(result.current.chats.length).toBe(2));
90
+
91
+ act(() => {
92
+ result.current.patchChatTitle('c1', 'Renamed');
93
+ });
94
+
95
+ expect(result.current.chats[0].title).toBe('Renamed');
96
+ expect(listChats).toHaveBeenCalledTimes(1);
97
+ });
98
+
99
+ it('createChat triggers refresh + returns id', async () => {
100
+ const { transport } = makeTransport();
101
+ const { result } = renderHook(() => useChatList({ transport, pageSize: 50 }));
102
+ await waitFor(() => expect(result.current.chats.length).toBe(2));
103
+
104
+ let id: string | null = null;
105
+ await act(async () => {
106
+ id = await result.current.createChat();
107
+ });
108
+ expect(id).toBe('c-new');
109
+ });
110
+
111
+ it('deleteChat optimistically removes the chat', async () => {
112
+ const { transport } = makeTransport();
113
+ const { result } = renderHook(() => useChatList({ transport, pageSize: 50 }));
114
+ await waitFor(() => expect(result.current.chats.length).toBe(2));
115
+
116
+ await act(async () => {
117
+ await result.current.deleteChat('c1');
118
+ });
119
+
120
+ expect(result.current.chats.map((c) => c.id)).toEqual(['c2']);
121
+ });
122
+
123
+ it('selectedAgentId is settable and persists across renders', async () => {
124
+ const { transport } = makeTransport();
125
+ const { result, rerender } = renderHook(() =>
126
+ useChatList({ transport, pageSize: 50 }),
127
+ );
128
+ await waitFor(() => expect(result.current.agents.length).toBe(2));
129
+
130
+ act(() => result.current.setSelectedAgentId('a2'));
131
+ expect(result.current.selectedAgentId).toBe('a2');
132
+ rerender();
133
+ expect(result.current.selectedAgentId).toBe('a2');
134
+ });
135
+ });