@steerable/agent-ui 0.2.3 → 0.2.4

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 (178) 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/index.d.ts +2 -0
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +5 -0
  88. package/dist/index.js.map +1 -1
  89. package/dist/state/ChatSessionProvider.d.ts +59 -0
  90. package/dist/state/ChatSessionProvider.d.ts.map +1 -0
  91. package/dist/state/ChatSessionProvider.js +71 -0
  92. package/dist/state/ChatSessionProvider.js.map +1 -0
  93. package/dist/state/MockChatStreamTransport.d.ts +54 -0
  94. package/dist/state/MockChatStreamTransport.d.ts.map +1 -0
  95. package/dist/state/MockChatStreamTransport.js +86 -0
  96. package/dist/state/MockChatStreamTransport.js.map +1 -0
  97. package/dist/state/MockChatStreamTransport.stories.d.ts +13 -0
  98. package/dist/state/MockChatStreamTransport.stories.d.ts.map +1 -0
  99. package/dist/state/MockChatStreamTransport.stories.js +60 -0
  100. package/dist/state/MockChatStreamTransport.stories.js.map +1 -0
  101. package/dist/state/bridgeLegacySSE.d.ts +42 -0
  102. package/dist/state/bridgeLegacySSE.d.ts.map +1 -0
  103. package/dist/state/bridgeLegacySSE.js +102 -0
  104. package/dist/state/bridgeLegacySSE.js.map +1 -0
  105. package/dist/state/index.d.ts +32 -0
  106. package/dist/state/index.d.ts.map +1 -0
  107. package/dist/state/index.js +24 -0
  108. package/dist/state/index.js.map +1 -0
  109. package/dist/state/parseSSE.d.ts +50 -0
  110. package/dist/state/parseSSE.d.ts.map +1 -0
  111. package/dist/state/parseSSE.js +112 -0
  112. package/dist/state/parseSSE.js.map +1 -0
  113. package/dist/state/useChatComposer.d.ts +51 -0
  114. package/dist/state/useChatComposer.d.ts.map +1 -0
  115. package/dist/state/useChatComposer.js +82 -0
  116. package/dist/state/useChatComposer.js.map +1 -0
  117. package/dist/state/useChatList.d.ts +71 -0
  118. package/dist/state/useChatList.d.ts.map +1 -0
  119. package/dist/state/useChatList.js +158 -0
  120. package/dist/state/useChatList.js.map +1 -0
  121. package/dist/state/useChatSession.d.ts +29 -0
  122. package/dist/state/useChatSession.d.ts.map +1 -0
  123. package/dist/state/useChatSession.js +37 -0
  124. package/dist/state/useChatSession.js.map +1 -0
  125. package/dist/state/useScrollLock.d.ts +35 -0
  126. package/dist/state/useScrollLock.d.ts.map +1 -0
  127. package/dist/state/useScrollLock.js +74 -0
  128. package/dist/state/useScrollLock.js.map +1 -0
  129. package/dist/state/useToolCallStream.d.ts +38 -0
  130. package/dist/state/useToolCallStream.d.ts.map +1 -0
  131. package/dist/state/useToolCallStream.js +74 -0
  132. package/dist/state/useToolCallStream.js.map +1 -0
  133. package/package.json +10 -2
  134. package/src/cards/ActionSegmentCard.tsx +60 -0
  135. package/src/cards/AnalysisDocumentCard.tsx +81 -0
  136. package/src/cards/AskUserQuestionsCard.tsx +354 -0
  137. package/src/cards/CoverageReportCard.tsx +215 -0
  138. package/src/cards/OrchestrationPlanCard.tsx +328 -0
  139. package/src/cards/PlanSelectorCard.tsx +312 -0
  140. package/src/cards/PlanStepsCard.tsx +52 -0
  141. package/src/cards/QuizCard.tsx +309 -0
  142. package/src/cards/ResearchPlanCard.tsx +169 -0
  143. package/src/cards/SearchSourcesCard.tsx +244 -0
  144. package/src/cards/SuggestedRepliesCard.tsx +48 -0
  145. package/src/cards/SummaryMessageCard.tsx +145 -0
  146. package/src/cards/ThinkingProcessCard.tsx +155 -0
  147. package/src/cards/ToolExecutionCard.tsx +152 -0
  148. package/src/cards/cards.test.tsx +492 -0
  149. package/src/cards/icons.tsx +249 -0
  150. package/src/cards/index.ts +53 -0
  151. package/src/cards/types.ts +27 -0
  152. package/src/components/AgentSelector.stories.tsx +113 -0
  153. package/src/components/AgentSelector.test.tsx +70 -0
  154. package/src/components/AgentSelector.tsx +147 -0
  155. package/src/components/ChatPanel.test.tsx +130 -0
  156. package/src/components/ChatPanel.tsx +612 -138
  157. package/src/components/index.ts +13 -1
  158. package/src/index.ts +43 -0
  159. package/src/state/ChatSessionProvider.test.tsx +85 -0
  160. package/src/state/ChatSessionProvider.tsx +98 -0
  161. package/src/state/MockChatStreamTransport.stories.tsx +95 -0
  162. package/src/state/MockChatStreamTransport.test.ts +163 -0
  163. package/src/state/MockChatStreamTransport.ts +123 -0
  164. package/src/state/bridgeLegacySSE.test.ts +134 -0
  165. package/src/state/bridgeLegacySSE.ts +134 -0
  166. package/src/state/index.ts +82 -0
  167. package/src/state/parseSSE.test.ts +123 -0
  168. package/src/state/parseSSE.ts +123 -0
  169. package/src/state/useChatComposer.test.ts +145 -0
  170. package/src/state/useChatComposer.ts +117 -0
  171. package/src/state/useChatList.test.ts +135 -0
  172. package/src/state/useChatList.ts +217 -0
  173. package/src/state/useChatSession.test.ts +61 -0
  174. package/src/state/useChatSession.ts +64 -0
  175. package/src/state/useScrollLock.test.ts +99 -0
  176. package/src/state/useScrollLock.ts +105 -0
  177. package/src/state/useToolCallStream.test.ts +86 -0
  178. package/src/state/useToolCallStream.ts +110 -0
@@ -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
+ });
@@ -0,0 +1,217 @@
1
+ /**
2
+ * `useChatList` — paginated chat-summary + agent-catalog state for the chat
3
+ * sidebar. Generic in the chat and agent shapes so cloud and local apps reuse
4
+ * the same hook (the cloud schema has extra fields on `Chat`, the local
5
+ * schema has fewer).
6
+ *
7
+ * Lifted from deeppath-agent's `useChatsAndAgents.ts` and deeppath's
8
+ * `useChatUI` chat-list subset. The hook owns:
9
+ * - paginated chat list with "load more" and refresh
10
+ * - the agent catalog (single page for now)
11
+ * - the agent picked for the next new chat
12
+ * - in-place title patching (used by `chat_title_updated` SSE events to
13
+ * avoid clobbering pagination state with a full refresh)
14
+ * - create / delete chat actions, with optimistic patching of the local
15
+ * list.
16
+ *
17
+ * The transport is provided by the consumer so the same hook serves Electron
18
+ * IPC, the cloud REST client, or any other backend.
19
+ */
20
+
21
+ import {
22
+ useCallback,
23
+ useEffect,
24
+ useMemo,
25
+ useRef,
26
+ useState,
27
+ type Dispatch,
28
+ type SetStateAction,
29
+ } from 'react';
30
+
31
+ export interface ChatListTransport<TChat, TAgent> {
32
+ listChats: (input: { page: number; pageSize: number }) => Promise<{
33
+ chats: TChat[];
34
+ hasMore: boolean;
35
+ }>;
36
+ listAgents: () => Promise<TAgent[]>;
37
+ createChat?: (input: { agentId?: string }) => Promise<TChat | string>;
38
+ deleteChat?: (chatId: string) => Promise<boolean>;
39
+ /** Tell the hook how to read a chat's id and title. */
40
+ getChatId: (chat: TChat) => string;
41
+ setChatTitle?: (chat: TChat, title: string) => TChat;
42
+ /** Tell the hook how to read an agent's id (for `selectedAgentId`). */
43
+ getAgentId: (agent: TAgent) => string;
44
+ }
45
+
46
+ export interface UseChatListOptions<TChat, TAgent> {
47
+ transport: ChatListTransport<TChat, TAgent>;
48
+ /** Defaults to 50. */
49
+ pageSize?: number;
50
+ /** Skip the mount-time fetch (e.g. SSR contexts). */
51
+ skipInitialLoad?: boolean;
52
+ }
53
+
54
+ export interface UseChatListReturn<TChat, TAgent> {
55
+ chats: TChat[];
56
+ agents: TAgent[];
57
+ isLoading: boolean;
58
+ isLoadingMoreChats: boolean;
59
+ hasMoreChats: boolean;
60
+ error: Error | null;
61
+ selectedAgentId: string | null;
62
+ /**
63
+ * Accepts either a plain value or a React-style updater function so
64
+ * adapters can patch the selection without re-reading the current value.
65
+ */
66
+ setSelectedAgentId: Dispatch<SetStateAction<string | null>>;
67
+ refreshChats: () => Promise<void>;
68
+ refreshAgents: () => Promise<void>;
69
+ loadMoreChats: () => Promise<void>;
70
+ createChat: (input?: { agentId?: string }) => Promise<string | null>;
71
+ deleteChat: (chatId: string) => Promise<boolean>;
72
+ /** In-place title patcher — does not hit the server. */
73
+ patchChatTitle: (chatId: string, title: string) => void;
74
+ }
75
+
76
+ export function useChatList<TChat, TAgent>(
77
+ options: UseChatListOptions<TChat, TAgent>,
78
+ ): UseChatListReturn<TChat, TAgent> {
79
+ const pageSize = options.pageSize ?? 50;
80
+ const transportRef = useRef(options.transport);
81
+ transportRef.current = options.transport;
82
+
83
+ const [chats, setChats] = useState<TChat[]>([]);
84
+ const [agents, setAgents] = useState<TAgent[]>([]);
85
+ const [isLoading, setLoading] = useState(!options.skipInitialLoad);
86
+ const [isLoadingMoreChats, setLoadingMore] = useState(false);
87
+ const [hasMoreChats, setHasMore] = useState(false);
88
+ const [error, setError] = useState<Error | null>(null);
89
+ const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
90
+ const pageRef = useRef(1);
91
+ const mountedRef = useRef(true);
92
+
93
+ useEffect(() => {
94
+ mountedRef.current = true;
95
+ return () => {
96
+ mountedRef.current = false;
97
+ };
98
+ }, []);
99
+
100
+ const refreshChats = useCallback(async () => {
101
+ setLoading(true);
102
+ try {
103
+ const res = await transportRef.current.listChats({ page: 1, pageSize });
104
+ if (!mountedRef.current) return;
105
+ setChats(res.chats);
106
+ setHasMore(res.hasMore);
107
+ pageRef.current = 1;
108
+ setError(null);
109
+ } catch (err) {
110
+ if (mountedRef.current) setError(err instanceof Error ? err : new Error(String(err)));
111
+ } finally {
112
+ if (mountedRef.current) setLoading(false);
113
+ }
114
+ }, [pageSize]);
115
+
116
+ const refreshAgents = useCallback(async () => {
117
+ try {
118
+ const list = await transportRef.current.listAgents();
119
+ if (mountedRef.current) setAgents(list);
120
+ } catch (err) {
121
+ if (mountedRef.current) setError(err instanceof Error ? err : new Error(String(err)));
122
+ }
123
+ }, []);
124
+
125
+ const loadMoreChats = useCallback(async () => {
126
+ if (!hasMoreChats || isLoadingMoreChats) return;
127
+ setLoadingMore(true);
128
+ try {
129
+ const next = pageRef.current + 1;
130
+ const res = await transportRef.current.listChats({ page: next, pageSize });
131
+ if (!mountedRef.current) return;
132
+ setChats((prev) => prev.concat(res.chats));
133
+ setHasMore(res.hasMore);
134
+ pageRef.current = next;
135
+ } catch (err) {
136
+ if (mountedRef.current) setError(err instanceof Error ? err : new Error(String(err)));
137
+ } finally {
138
+ if (mountedRef.current) setLoadingMore(false);
139
+ }
140
+ }, [hasMoreChats, isLoadingMoreChats, pageSize]);
141
+
142
+ const createChat = useCallback(
143
+ async (input?: { agentId?: string }) => {
144
+ const t = transportRef.current;
145
+ if (!t.createChat) return null;
146
+ const created = await t.createChat({ agentId: input?.agentId ?? selectedAgentId ?? undefined });
147
+ // Either a chat object or a bare id.
148
+ const id = typeof created === 'string' ? created : t.getChatId(created);
149
+ await refreshChats();
150
+ return id;
151
+ },
152
+ [refreshChats, selectedAgentId],
153
+ );
154
+
155
+ const deleteChat = useCallback(
156
+ async (chatId: string) => {
157
+ const t = transportRef.current;
158
+ if (!t.deleteChat) return false;
159
+ const ok = await t.deleteChat(chatId);
160
+ if (ok && mountedRef.current) {
161
+ setChats((prev) => prev.filter((c) => t.getChatId(c) !== chatId));
162
+ }
163
+ return ok;
164
+ },
165
+ [],
166
+ );
167
+
168
+ const patchChatTitle = useCallback((chatId: string, title: string) => {
169
+ const t = transportRef.current;
170
+ if (!t.setChatTitle) return;
171
+ setChats((prev) =>
172
+ prev.map((c) => (t.getChatId(c) === chatId ? t.setChatTitle!(c, title) : c)),
173
+ );
174
+ }, []);
175
+
176
+ useEffect(() => {
177
+ if (options.skipInitialLoad) return;
178
+ void refreshChats();
179
+ void refreshAgents();
180
+ // Run once on mount; downstream callers use refresh* explicitly.
181
+ // eslint-disable-next-line react-hooks/exhaustive-deps
182
+ }, []);
183
+
184
+ return useMemo<UseChatListReturn<TChat, TAgent>>(
185
+ () => ({
186
+ chats,
187
+ agents,
188
+ isLoading,
189
+ isLoadingMoreChats,
190
+ hasMoreChats,
191
+ error,
192
+ selectedAgentId,
193
+ setSelectedAgentId,
194
+ refreshChats,
195
+ refreshAgents,
196
+ loadMoreChats,
197
+ createChat,
198
+ deleteChat,
199
+ patchChatTitle,
200
+ }),
201
+ [
202
+ chats,
203
+ agents,
204
+ isLoading,
205
+ isLoadingMoreChats,
206
+ hasMoreChats,
207
+ error,
208
+ selectedAgentId,
209
+ refreshChats,
210
+ refreshAgents,
211
+ loadMoreChats,
212
+ createChat,
213
+ deleteChat,
214
+ patchChatTitle,
215
+ ],
216
+ );
217
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Tests for `useChatSession` — the composer-meets-stream convenience hook.
3
+ */
4
+
5
+ import { describe, expect, it, vi } from 'vitest';
6
+ import { act, renderHook, waitFor } from '@testing-library/react';
7
+ import { MockChatStreamTransport } from './MockChatStreamTransport';
8
+ import { useChatSession } from './useChatSession';
9
+
10
+ describe('useChatSession', () => {
11
+ it('routes composer.submit through the stream pipeline', async () => {
12
+ const transport = new MockChatStreamTransport({
13
+ scripts: [[
14
+ { type: 'content', content: 'pong' },
15
+ { type: 'done' },
16
+ ]],
17
+ });
18
+ const { result } = renderHook(() =>
19
+ useChatSession({ transport, initialValue: '' }),
20
+ );
21
+
22
+ await act(async () => {
23
+ result.current.composer.setValue('ping');
24
+ });
25
+ await act(async () => {
26
+ await result.current.composer.submit();
27
+ });
28
+
29
+ await waitFor(() => {
30
+ expect(result.current.messages.length).toBeGreaterThanOrEqual(2);
31
+ });
32
+ expect(result.current.messages[0].content).toBe('ping');
33
+ expect(result.current.composer.value).toBe('');
34
+ });
35
+
36
+ it('forwards build metadata onto the transport', async () => {
37
+ const stream = vi.fn().mockImplementation(async (_input, onEvent) => {
38
+ onEvent({ type: 'done' });
39
+ return () => {};
40
+ });
41
+ const { result } = renderHook(() =>
42
+ useChatSession({
43
+ transport: { stream },
44
+ buildMetadata: (v) => ({ length: v.length }),
45
+ }),
46
+ );
47
+
48
+ await act(async () => {
49
+ result.current.composer.setValue('hello');
50
+ });
51
+ await act(async () => {
52
+ await result.current.composer.submit();
53
+ });
54
+
55
+ expect(stream).toHaveBeenCalledTimes(1);
56
+ expect(stream.mock.calls[0][0]).toEqual({
57
+ content: 'hello',
58
+ metadata: { length: 5 },
59
+ });
60
+ });
61
+ });