@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.
- package/dist/cards/ActionSegmentCard.d.ts +18 -0
- package/dist/cards/ActionSegmentCard.d.ts.map +1 -0
- package/dist/cards/ActionSegmentCard.js +26 -0
- package/dist/cards/ActionSegmentCard.js.map +1 -0
- package/dist/cards/AnalysisDocumentCard.d.ts +33 -0
- package/dist/cards/AnalysisDocumentCard.d.ts.map +1 -0
- package/dist/cards/AnalysisDocumentCard.js +11 -0
- package/dist/cards/AnalysisDocumentCard.js.map +1 -0
- package/dist/cards/AskUserQuestionsCard.d.ts +43 -0
- package/dist/cards/AskUserQuestionsCard.d.ts.map +1 -0
- package/dist/cards/AskUserQuestionsCard.js +171 -0
- package/dist/cards/AskUserQuestionsCard.js.map +1 -0
- package/dist/cards/CoverageReportCard.d.ts +41 -0
- package/dist/cards/CoverageReportCard.d.ts.map +1 -0
- package/dist/cards/CoverageReportCard.js +55 -0
- package/dist/cards/CoverageReportCard.js.map +1 -0
- package/dist/cards/OrchestrationPlanCard.d.ts +60 -0
- package/dist/cards/OrchestrationPlanCard.d.ts.map +1 -0
- package/dist/cards/OrchestrationPlanCard.js +103 -0
- package/dist/cards/OrchestrationPlanCard.js.map +1 -0
- package/dist/cards/PlanSelectorCard.d.ts +40 -0
- package/dist/cards/PlanSelectorCard.d.ts.map +1 -0
- package/dist/cards/PlanSelectorCard.js +106 -0
- package/dist/cards/PlanSelectorCard.js.map +1 -0
- package/dist/cards/PlanStepsCard.d.ts +18 -0
- package/dist/cards/PlanStepsCard.d.ts.map +1 -0
- package/dist/cards/PlanStepsCard.js +25 -0
- package/dist/cards/PlanStepsCard.js.map +1 -0
- package/dist/cards/QuizCard.d.ts +44 -0
- package/dist/cards/QuizCard.d.ts.map +1 -0
- package/dist/cards/QuizCard.js +113 -0
- package/dist/cards/QuizCard.js.map +1 -0
- package/dist/cards/ResearchPlanCard.d.ts +23 -0
- package/dist/cards/ResearchPlanCard.d.ts.map +1 -0
- package/dist/cards/ResearchPlanCard.js +50 -0
- package/dist/cards/ResearchPlanCard.js.map +1 -0
- package/dist/cards/SearchSourcesCard.d.ts +29 -0
- package/dist/cards/SearchSourcesCard.d.ts.map +1 -0
- package/dist/cards/SearchSourcesCard.js +83 -0
- package/dist/cards/SearchSourcesCard.js.map +1 -0
- package/dist/cards/SuggestedRepliesCard.d.ts +18 -0
- package/dist/cards/SuggestedRepliesCard.d.ts.map +1 -0
- package/dist/cards/SuggestedRepliesCard.js +14 -0
- package/dist/cards/SuggestedRepliesCard.js.map +1 -0
- package/dist/cards/SummaryMessageCard.d.ts +40 -0
- package/dist/cards/SummaryMessageCard.d.ts.map +1 -0
- package/dist/cards/SummaryMessageCard.js +56 -0
- package/dist/cards/SummaryMessageCard.js.map +1 -0
- package/dist/cards/ThinkingProcessCard.d.ts +46 -0
- package/dist/cards/ThinkingProcessCard.d.ts.map +1 -0
- package/dist/cards/ThinkingProcessCard.js +79 -0
- package/dist/cards/ThinkingProcessCard.js.map +1 -0
- package/dist/cards/ToolExecutionCard.d.ts +21 -0
- package/dist/cards/ToolExecutionCard.d.ts.map +1 -0
- package/dist/cards/ToolExecutionCard.js +65 -0
- package/dist/cards/ToolExecutionCard.js.map +1 -0
- package/dist/cards/icons.d.ts +38 -0
- package/dist/cards/icons.d.ts.map +1 -0
- package/dist/cards/icons.js +87 -0
- package/dist/cards/icons.js.map +1 -0
- package/dist/cards/index.d.ts +38 -0
- package/dist/cards/index.d.ts.map +1 -0
- package/dist/cards/index.js +24 -0
- package/dist/cards/index.js.map +1 -0
- package/dist/cards/types.d.ts +24 -0
- package/dist/cards/types.d.ts.map +1 -0
- package/dist/cards/types.js +7 -0
- package/dist/cards/types.js.map +1 -0
- package/dist/components/AgentSelector.d.ts +23 -0
- package/dist/components/AgentSelector.d.ts.map +1 -0
- package/dist/components/AgentSelector.js +63 -0
- package/dist/components/AgentSelector.js.map +1 -0
- package/dist/components/AgentSelector.stories.d.ts +12 -0
- package/dist/components/AgentSelector.stories.d.ts.map +1 -0
- package/dist/components/AgentSelector.stories.js +95 -0
- package/dist/components/AgentSelector.stories.js.map +1 -0
- package/dist/components/ChatPanel.d.ts +138 -21
- package/dist/components/ChatPanel.d.ts.map +1 -1
- package/dist/components/ChatPanel.js +181 -26
- package/dist/components/ChatPanel.js.map +1 -1
- package/dist/components/index.d.ts +3 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -0
- package/dist/components/index.js.map +1 -1
- package/dist/hooks/useChatStream.d.ts +15 -0
- package/dist/hooks/useChatStream.d.ts.map +1 -1
- package/dist/hooks/useChatStream.js +13 -1
- package/dist/hooks/useChatStream.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/state/ChatSessionProvider.d.ts +59 -0
- package/dist/state/ChatSessionProvider.d.ts.map +1 -0
- package/dist/state/ChatSessionProvider.js +71 -0
- package/dist/state/ChatSessionProvider.js.map +1 -0
- package/dist/state/MockChatStreamTransport.d.ts +54 -0
- package/dist/state/MockChatStreamTransport.d.ts.map +1 -0
- package/dist/state/MockChatStreamTransport.js +86 -0
- package/dist/state/MockChatStreamTransport.js.map +1 -0
- package/dist/state/MockChatStreamTransport.stories.d.ts +13 -0
- package/dist/state/MockChatStreamTransport.stories.d.ts.map +1 -0
- package/dist/state/MockChatStreamTransport.stories.js +60 -0
- package/dist/state/MockChatStreamTransport.stories.js.map +1 -0
- package/dist/state/bridgeLegacySSE.d.ts +42 -0
- package/dist/state/bridgeLegacySSE.d.ts.map +1 -0
- package/dist/state/bridgeLegacySSE.js +102 -0
- package/dist/state/bridgeLegacySSE.js.map +1 -0
- package/dist/state/index.d.ts +32 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +24 -0
- package/dist/state/index.js.map +1 -0
- package/dist/state/parseSSE.d.ts +50 -0
- package/dist/state/parseSSE.d.ts.map +1 -0
- package/dist/state/parseSSE.js +112 -0
- package/dist/state/parseSSE.js.map +1 -0
- package/dist/state/useChatComposer.d.ts +51 -0
- package/dist/state/useChatComposer.d.ts.map +1 -0
- package/dist/state/useChatComposer.js +82 -0
- package/dist/state/useChatComposer.js.map +1 -0
- package/dist/state/useChatList.d.ts +73 -0
- package/dist/state/useChatList.d.ts.map +1 -0
- package/dist/state/useChatList.js +161 -0
- package/dist/state/useChatList.js.map +1 -0
- package/dist/state/useChatSession.d.ts +29 -0
- package/dist/state/useChatSession.d.ts.map +1 -0
- package/dist/state/useChatSession.js +37 -0
- package/dist/state/useChatSession.js.map +1 -0
- package/dist/state/useScrollLock.d.ts +35 -0
- package/dist/state/useScrollLock.d.ts.map +1 -0
- package/dist/state/useScrollLock.js +74 -0
- package/dist/state/useScrollLock.js.map +1 -0
- package/dist/state/useToolCallStream.d.ts +38 -0
- package/dist/state/useToolCallStream.d.ts.map +1 -0
- package/dist/state/useToolCallStream.js +74 -0
- package/dist/state/useToolCallStream.js.map +1 -0
- package/package.json +11 -3
- package/src/cards/ActionSegmentCard.tsx +60 -0
- package/src/cards/AnalysisDocumentCard.tsx +81 -0
- package/src/cards/AskUserQuestionsCard.tsx +354 -0
- package/src/cards/CoverageReportCard.tsx +215 -0
- package/src/cards/OrchestrationPlanCard.tsx +328 -0
- package/src/cards/PlanSelectorCard.tsx +312 -0
- package/src/cards/PlanStepsCard.tsx +52 -0
- package/src/cards/QuizCard.tsx +309 -0
- package/src/cards/ResearchPlanCard.tsx +169 -0
- package/src/cards/SearchSourcesCard.tsx +244 -0
- package/src/cards/SuggestedRepliesCard.tsx +48 -0
- package/src/cards/SummaryMessageCard.tsx +145 -0
- package/src/cards/ThinkingProcessCard.tsx +155 -0
- package/src/cards/ToolExecutionCard.tsx +152 -0
- package/src/cards/cards.test.tsx +492 -0
- package/src/cards/icons.tsx +249 -0
- package/src/cards/index.ts +53 -0
- package/src/cards/types.ts +27 -0
- package/src/components/AgentSelector.stories.tsx +113 -0
- package/src/components/AgentSelector.test.tsx +70 -0
- package/src/components/AgentSelector.tsx +147 -0
- package/src/components/ChatPanel.test.tsx +130 -0
- package/src/components/ChatPanel.tsx +612 -138
- package/src/components/index.ts +13 -1
- package/src/hooks/useChatStream.test.ts +78 -0
- package/src/hooks/useChatStream.ts +32 -1
- package/src/index.ts +43 -0
- package/src/state/ChatSessionProvider.test.tsx +85 -0
- package/src/state/ChatSessionProvider.tsx +98 -0
- package/src/state/MockChatStreamTransport.stories.tsx +95 -0
- package/src/state/MockChatStreamTransport.test.ts +163 -0
- package/src/state/MockChatStreamTransport.ts +123 -0
- package/src/state/bridgeLegacySSE.test.ts +134 -0
- package/src/state/bridgeLegacySSE.ts +134 -0
- package/src/state/index.ts +82 -0
- package/src/state/parseSSE.test.ts +123 -0
- package/src/state/parseSSE.ts +123 -0
- package/src/state/useChatComposer.test.ts +145 -0
- package/src/state/useChatComposer.ts +117 -0
- package/src/state/useChatList.test.ts +135 -0
- package/src/state/useChatList.ts +220 -0
- package/src/state/useChatSession.test.ts +61 -0
- package/src/state/useChatSession.ts +64 -0
- package/src/state/useScrollLock.test.ts +99 -0
- package/src/state/useScrollLock.ts +105 -0
- package/src/state/useToolCallStream.test.ts +86 -0
- package/src/state/useToolCallStream.ts +110 -0
|
@@ -0,0 +1,220 @@
|
|
|
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; projectId?: 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; projectId?: 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; projectId?: string }) => {
|
|
144
|
+
const t = transportRef.current;
|
|
145
|
+
if (!t.createChat) return null;
|
|
146
|
+
const created = await t.createChat({
|
|
147
|
+
agentId: input?.agentId ?? selectedAgentId ?? undefined,
|
|
148
|
+
projectId: input?.projectId,
|
|
149
|
+
});
|
|
150
|
+
// Either a chat object or a bare id.
|
|
151
|
+
const id = typeof created === 'string' ? created : t.getChatId(created);
|
|
152
|
+
await refreshChats();
|
|
153
|
+
return id;
|
|
154
|
+
},
|
|
155
|
+
[refreshChats, selectedAgentId],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const deleteChat = useCallback(
|
|
159
|
+
async (chatId: string) => {
|
|
160
|
+
const t = transportRef.current;
|
|
161
|
+
if (!t.deleteChat) return false;
|
|
162
|
+
const ok = await t.deleteChat(chatId);
|
|
163
|
+
if (ok && mountedRef.current) {
|
|
164
|
+
setChats((prev) => prev.filter((c) => t.getChatId(c) !== chatId));
|
|
165
|
+
}
|
|
166
|
+
return ok;
|
|
167
|
+
},
|
|
168
|
+
[],
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const patchChatTitle = useCallback((chatId: string, title: string) => {
|
|
172
|
+
const t = transportRef.current;
|
|
173
|
+
if (!t.setChatTitle) return;
|
|
174
|
+
setChats((prev) =>
|
|
175
|
+
prev.map((c) => (t.getChatId(c) === chatId ? t.setChatTitle!(c, title) : c)),
|
|
176
|
+
);
|
|
177
|
+
}, []);
|
|
178
|
+
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (options.skipInitialLoad) return;
|
|
181
|
+
void refreshChats();
|
|
182
|
+
void refreshAgents();
|
|
183
|
+
// Run once on mount; downstream callers use refresh* explicitly.
|
|
184
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
185
|
+
}, []);
|
|
186
|
+
|
|
187
|
+
return useMemo<UseChatListReturn<TChat, TAgent>>(
|
|
188
|
+
() => ({
|
|
189
|
+
chats,
|
|
190
|
+
agents,
|
|
191
|
+
isLoading,
|
|
192
|
+
isLoadingMoreChats,
|
|
193
|
+
hasMoreChats,
|
|
194
|
+
error,
|
|
195
|
+
selectedAgentId,
|
|
196
|
+
setSelectedAgentId,
|
|
197
|
+
refreshChats,
|
|
198
|
+
refreshAgents,
|
|
199
|
+
loadMoreChats,
|
|
200
|
+
createChat,
|
|
201
|
+
deleteChat,
|
|
202
|
+
patchChatTitle,
|
|
203
|
+
}),
|
|
204
|
+
[
|
|
205
|
+
chats,
|
|
206
|
+
agents,
|
|
207
|
+
isLoading,
|
|
208
|
+
isLoadingMoreChats,
|
|
209
|
+
hasMoreChats,
|
|
210
|
+
error,
|
|
211
|
+
selectedAgentId,
|
|
212
|
+
refreshChats,
|
|
213
|
+
refreshAgents,
|
|
214
|
+
loadMoreChats,
|
|
215
|
+
createChat,
|
|
216
|
+
deleteChat,
|
|
217
|
+
patchChatTitle,
|
|
218
|
+
],
|
|
219
|
+
);
|
|
220
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useChatSession` — convenience wrapper that combines `useChatStream` (turn
|
|
3
|
+
* lifecycle) with `useChatComposer` (draft + keys + send). Most consumers want
|
|
4
|
+
* this single hook so the wiring is identical across web-shell, deeppath, and
|
|
5
|
+
* deeppath-agent. Power users still reach for the underlying primitives.
|
|
6
|
+
*
|
|
7
|
+
* const { messages, isStreaming, composer, cancel } = useChatSession({
|
|
8
|
+
* transport,
|
|
9
|
+
* initialMessages,
|
|
10
|
+
* });
|
|
11
|
+
*
|
|
12
|
+
* <ChatPanel>
|
|
13
|
+
* <MessageList messages={messages} isStreaming={isStreaming} />
|
|
14
|
+
* <ChatInput composer={composer} onCancel={cancel} />
|
|
15
|
+
* </ChatPanel>
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useMemo } from 'react';
|
|
19
|
+
import {
|
|
20
|
+
useChatStream,
|
|
21
|
+
type UseChatStreamOptions,
|
|
22
|
+
type UseChatStreamReturn,
|
|
23
|
+
} from '../hooks/useChatStream.js';
|
|
24
|
+
import {
|
|
25
|
+
useChatComposer,
|
|
26
|
+
type UseChatComposerOptions,
|
|
27
|
+
type UseChatComposerReturn,
|
|
28
|
+
} from './useChatComposer.js';
|
|
29
|
+
|
|
30
|
+
export interface UseChatSessionOptions
|
|
31
|
+
extends UseChatStreamOptions,
|
|
32
|
+
Pick<UseChatComposerOptions, 'enterToSubmit' | 'initialValue'> {
|
|
33
|
+
/** Optional metadata callback when submitting a message (e.g. @mentions). */
|
|
34
|
+
buildMetadata?: (value: string) => Record<string, unknown> | undefined;
|
|
35
|
+
/** Force-disable the composer regardless of streaming state. */
|
|
36
|
+
composerDisabled?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface UseChatSessionReturn extends UseChatStreamReturn {
|
|
40
|
+
composer: UseChatComposerReturn;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function useChatSession(options: UseChatSessionOptions): UseChatSessionReturn {
|
|
44
|
+
const stream = useChatStream(options);
|
|
45
|
+
|
|
46
|
+
const composer = useChatComposer({
|
|
47
|
+
initialValue: options.initialValue,
|
|
48
|
+
enterToSubmit: options.enterToSubmit,
|
|
49
|
+
isStreaming: stream.isStreaming,
|
|
50
|
+
disabled: options.composerDisabled,
|
|
51
|
+
onSend: async (value) => {
|
|
52
|
+
await stream.sendUserMessage({
|
|
53
|
+
content: value,
|
|
54
|
+
metadata: options.buildMetadata?.(value),
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
onCancel: stream.cancel,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return useMemo(
|
|
61
|
+
() => ({ ...stream, composer }),
|
|
62
|
+
[stream, composer],
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `useScrollLock`. happy-dom doesn't compute scroll distance
|
|
3
|
+
* automatically, so we manually mutate `scrollTop` and dispatch scroll events.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import { act, renderHook } from '@testing-library/react';
|
|
8
|
+
import { useScrollLock } from './useScrollLock';
|
|
9
|
+
|
|
10
|
+
function makeScrollContainer(
|
|
11
|
+
options: { startAtBottom?: boolean } = {},
|
|
12
|
+
): HTMLDivElement {
|
|
13
|
+
const el = document.createElement('div');
|
|
14
|
+
Object.defineProperty(el, 'scrollHeight', { value: 1000, writable: true });
|
|
15
|
+
Object.defineProperty(el, 'clientHeight', { value: 400, writable: true });
|
|
16
|
+
// happy-dom doesn't implement scrollTo; emulate the clamping browsers do.
|
|
17
|
+
el.scrollTo = ((opts: ScrollToOptions) => {
|
|
18
|
+
if (typeof opts === 'object' && typeof opts.top === 'number') {
|
|
19
|
+
const max = el.scrollHeight - el.clientHeight;
|
|
20
|
+
el.scrollTop = Math.min(Math.max(0, opts.top), max);
|
|
21
|
+
}
|
|
22
|
+
}) as typeof el.scrollTo;
|
|
23
|
+
if (options.startAtBottom !== false) {
|
|
24
|
+
el.scrollTop = el.scrollHeight - el.clientHeight;
|
|
25
|
+
}
|
|
26
|
+
return el;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('useScrollLock', () => {
|
|
30
|
+
it('auto-scrolls to bottom when revalidate keys change and user is anchored', () => {
|
|
31
|
+
let keys: ReadonlyArray<unknown> = [0];
|
|
32
|
+
const { result, rerender } = renderHook(() =>
|
|
33
|
+
useScrollLock({ revalidateKeys: keys }),
|
|
34
|
+
);
|
|
35
|
+
const el = makeScrollContainer();
|
|
36
|
+
act(() => result.current.setRef(el));
|
|
37
|
+
expect(result.current.isAnchored).toBe(true);
|
|
38
|
+
|
|
39
|
+
keys = [1];
|
|
40
|
+
rerender();
|
|
41
|
+
expect(el.scrollTop).toBe(el.scrollHeight - el.clientHeight);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('releases the lock when the user scrolls up past the threshold', () => {
|
|
45
|
+
const { result } = renderHook(() => useScrollLock({ threshold: 24 }));
|
|
46
|
+
const el = makeScrollContainer();
|
|
47
|
+
act(() => result.current.setRef(el));
|
|
48
|
+
expect(result.current.isAnchored).toBe(true);
|
|
49
|
+
|
|
50
|
+
el.scrollTop = 0;
|
|
51
|
+
act(() => {
|
|
52
|
+
el.dispatchEvent(new Event('scroll'));
|
|
53
|
+
});
|
|
54
|
+
expect(result.current.isAnchored).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('re-anchors once the user scrolls back to the bottom', () => {
|
|
58
|
+
const { result } = renderHook(() => useScrollLock({ threshold: 24 }));
|
|
59
|
+
const el = makeScrollContainer();
|
|
60
|
+
act(() => result.current.setRef(el));
|
|
61
|
+
|
|
62
|
+
el.scrollTop = 0;
|
|
63
|
+
act(() => el.dispatchEvent(new Event('scroll')));
|
|
64
|
+
expect(result.current.isAnchored).toBe(false);
|
|
65
|
+
|
|
66
|
+
el.scrollTop = el.scrollHeight - el.clientHeight;
|
|
67
|
+
act(() => el.dispatchEvent(new Event('scroll')));
|
|
68
|
+
expect(result.current.isAnchored).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('scrollToBottom is callable imperatively', () => {
|
|
72
|
+
const { result } = renderHook(() => useScrollLock());
|
|
73
|
+
const el = makeScrollContainer({ startAtBottom: false });
|
|
74
|
+
act(() => result.current.setRef(el));
|
|
75
|
+
expect(el.scrollTop).toBe(0);
|
|
76
|
+
|
|
77
|
+
act(() => result.current.scrollToBottom());
|
|
78
|
+
|
|
79
|
+
expect(el.scrollTop).toBe(el.scrollHeight - el.clientHeight);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('does not auto-scroll when the user is unanchored', () => {
|
|
83
|
+
let keys: ReadonlyArray<unknown> = [0];
|
|
84
|
+
const { result, rerender } = renderHook(() =>
|
|
85
|
+
useScrollLock({ revalidateKeys: keys, threshold: 24 }),
|
|
86
|
+
);
|
|
87
|
+
const el = makeScrollContainer();
|
|
88
|
+
act(() => result.current.setRef(el));
|
|
89
|
+
|
|
90
|
+
el.scrollTop = 0;
|
|
91
|
+
act(() => el.dispatchEvent(new Event('scroll')));
|
|
92
|
+
expect(result.current.isAnchored).toBe(false);
|
|
93
|
+
|
|
94
|
+
keys = [1];
|
|
95
|
+
rerender();
|
|
96
|
+
// Lock released → no auto-scroll, scrollTop stays at user's position.
|
|
97
|
+
expect(el.scrollTop).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useScrollLock` — sticky-bottom scrolling for streaming message lists.
|
|
3
|
+
*
|
|
4
|
+
* The hook returns a callback ref to attach to the scroll container and a
|
|
5
|
+
* `scrollToBottom` imperative API. Behaviour:
|
|
6
|
+
* - When the container's scroll position is within `threshold` px of the
|
|
7
|
+
* bottom, the hook treats the user as "anchored" and re-runs
|
|
8
|
+
* `scrollToBottom` whenever the dependency array `revalidateKeys` changes.
|
|
9
|
+
* - As soon as the user scrolls up past `threshold`, the lock releases —
|
|
10
|
+
* the list will no longer auto-scroll until the user scrolls back to the
|
|
11
|
+
* bottom (or `scrollToBottom` is called explicitly, e.g. on send).
|
|
12
|
+
*
|
|
13
|
+
* Uses a callback ref so the listener attaches the moment the element mounts,
|
|
14
|
+
* not after a second effect pass. Matches deeppath's MessageList scroll
|
|
15
|
+
* handling minus the inline MutationObserver+IntersectionObserver soup.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
19
|
+
|
|
20
|
+
export interface UseScrollLockOptions {
|
|
21
|
+
/** Px from the bottom that still counts as "anchored". Default 24. */
|
|
22
|
+
threshold?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Dependencies that, when changed, will trigger an auto-scroll if the user
|
|
25
|
+
* is currently anchored. Pass e.g. `[messages.length, lastMessageContent]`.
|
|
26
|
+
*/
|
|
27
|
+
revalidateKeys?: ReadonlyArray<unknown>;
|
|
28
|
+
/** Scroll smooth vs jump. Defaults to 'auto' (instant). */
|
|
29
|
+
behavior?: ScrollBehavior;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface UseScrollLockReturn<E extends HTMLElement = HTMLDivElement> {
|
|
33
|
+
/** Callback ref — pass directly to `ref={...}` on the scroll container. */
|
|
34
|
+
setRef: (el: E | null) => void;
|
|
35
|
+
isAnchored: boolean;
|
|
36
|
+
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function maxScrollTop(el: HTMLElement): number {
|
|
40
|
+
return Math.max(0, el.scrollHeight - el.clientHeight);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function useScrollLock<E extends HTMLElement = HTMLDivElement>(
|
|
44
|
+
options: UseScrollLockOptions = {},
|
|
45
|
+
): UseScrollLockReturn<E> {
|
|
46
|
+
const threshold = options.threshold ?? 24;
|
|
47
|
+
const elRef = useRef<E | null>(null);
|
|
48
|
+
const cleanupRef = useRef<(() => void) | null>(null);
|
|
49
|
+
const [isAnchored, setAnchored] = useState(true);
|
|
50
|
+
const anchoredRef = useRef(true);
|
|
51
|
+
anchoredRef.current = isAnchored;
|
|
52
|
+
|
|
53
|
+
const scrollToBottom = useCallback(
|
|
54
|
+
(behavior?: ScrollBehavior) => {
|
|
55
|
+
const el = elRef.current;
|
|
56
|
+
if (!el) return;
|
|
57
|
+
el.scrollTo({
|
|
58
|
+
top: maxScrollTop(el),
|
|
59
|
+
behavior: behavior ?? options.behavior ?? 'auto',
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
[options.behavior],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const setRef = useCallback(
|
|
66
|
+
(el: E | null) => {
|
|
67
|
+
if (cleanupRef.current) {
|
|
68
|
+
cleanupRef.current();
|
|
69
|
+
cleanupRef.current = null;
|
|
70
|
+
}
|
|
71
|
+
elRef.current = el;
|
|
72
|
+
if (!el) return;
|
|
73
|
+
const handler = () => {
|
|
74
|
+
const distance = maxScrollTop(el) - el.scrollTop;
|
|
75
|
+
const anchored = distance <= threshold;
|
|
76
|
+
if (anchored !== anchoredRef.current) {
|
|
77
|
+
anchoredRef.current = anchored;
|
|
78
|
+
setAnchored(anchored);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
el.addEventListener('scroll', handler, { passive: true });
|
|
82
|
+
cleanupRef.current = () => el.removeEventListener('scroll', handler);
|
|
83
|
+
// Run once to seed `isAnchored` from the current position.
|
|
84
|
+
handler();
|
|
85
|
+
},
|
|
86
|
+
[threshold],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
return () => {
|
|
91
|
+
if (cleanupRef.current) {
|
|
92
|
+
cleanupRef.current();
|
|
93
|
+
cleanupRef.current = null;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (anchoredRef.current) scrollToBottom();
|
|
100
|
+
// Consumer-provided dependency array + the scroll callback.
|
|
101
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
102
|
+
}, options.revalidateKeys ?? []);
|
|
103
|
+
|
|
104
|
+
return { setRef, isAnchored, scrollToBottom };
|
|
105
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `useToolCallStream` — pure derivation of in-flight tool-call
|
|
3
|
+
* status from a `ChatMessage`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import { renderHook } from '@testing-library/react';
|
|
8
|
+
import type { ChatMessage } from '@steerable/agent-protocol';
|
|
9
|
+
import { useToolCallStream } from './useToolCallStream';
|
|
10
|
+
|
|
11
|
+
const baseMsg: ChatMessage = {
|
|
12
|
+
id: 'm1',
|
|
13
|
+
role: 'assistant',
|
|
14
|
+
content: '',
|
|
15
|
+
createdAt: new Date().toISOString(),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe('useToolCallStream', () => {
|
|
19
|
+
it('returns an empty entry list when the message has no tool calls', () => {
|
|
20
|
+
const { result } = renderHook(() => useToolCallStream({ message: baseMsg }));
|
|
21
|
+
expect(result.current.entries).toEqual([]);
|
|
22
|
+
expect(result.current.pendingCount).toBe(0);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('classifies pending calls (no result yet) as pending', () => {
|
|
26
|
+
const msg: ChatMessage = {
|
|
27
|
+
...baseMsg,
|
|
28
|
+
toolCalls: [{ id: 'c1', name: 'get_weather', arguments: {} }],
|
|
29
|
+
};
|
|
30
|
+
const { result } = renderHook(() => useToolCallStream({ message: msg }));
|
|
31
|
+
expect(result.current.entries[0].status).toBe('pending');
|
|
32
|
+
expect(result.current.pendingCount).toBe(1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('infers mode from the tool name', () => {
|
|
36
|
+
const msg: ChatMessage = {
|
|
37
|
+
...baseMsg,
|
|
38
|
+
toolCalls: [
|
|
39
|
+
{ id: 'r', name: 'get_user', arguments: {} },
|
|
40
|
+
{ id: 'd', name: 'delete_user', arguments: {} },
|
|
41
|
+
{ id: 'l', name: 'local_exec', arguments: {} },
|
|
42
|
+
{ id: 'w', name: 'create_thing', arguments: {} },
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
const { result } = renderHook(() => useToolCallStream({ message: msg }));
|
|
46
|
+
expect(result.current.entries.map((e) => e.mode)).toEqual([
|
|
47
|
+
'read',
|
|
48
|
+
'destructive',
|
|
49
|
+
'local',
|
|
50
|
+
'safe_write',
|
|
51
|
+
]);
|
|
52
|
+
expect(result.current.entries[1].isDestructive).toBe(true);
|
|
53
|
+
expect(result.current.entries[2].requiresApproval).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('overrides mode via modeByName', () => {
|
|
57
|
+
const msg: ChatMessage = {
|
|
58
|
+
...baseMsg,
|
|
59
|
+
toolCalls: [{ id: 'c1', name: 'mystery', arguments: {} }],
|
|
60
|
+
};
|
|
61
|
+
const { result } = renderHook(() =>
|
|
62
|
+
useToolCallStream({ message: msg, modeByName: { mystery: 'destructive' } }),
|
|
63
|
+
);
|
|
64
|
+
expect(result.current.entries[0].mode).toBe('destructive');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('pairs results via resultByCallId, falls back to message.toolResult', () => {
|
|
68
|
+
const msg: ChatMessage = {
|
|
69
|
+
...baseMsg,
|
|
70
|
+
toolCalls: [
|
|
71
|
+
{ id: 'a', name: 'get_a', arguments: {} },
|
|
72
|
+
{ id: 'b', name: 'get_b', arguments: {} },
|
|
73
|
+
],
|
|
74
|
+
toolResult: { success: true, data: { fallback: true } },
|
|
75
|
+
};
|
|
76
|
+
const { result } = renderHook(() =>
|
|
77
|
+
useToolCallStream({
|
|
78
|
+
message: msg,
|
|
79
|
+
resultByCallId: { a: { success: false, data: { err: 'x' } } },
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
expect(result.current.entries[0].status).toBe('error');
|
|
83
|
+
expect(result.current.entries[1].status).toBe('done');
|
|
84
|
+
expect(result.current.errorCount).toBe(1);
|
|
85
|
+
});
|
|
86
|
+
});
|