@nextclaw/ui 0.5.48 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/assets/ChannelsList-BWQYaOuz.js +1 -0
  3. package/dist/assets/ChatPage-DsIuF-TC.js +32 -0
  4. package/dist/assets/DocBrowser-D4pXQDKt.js +1 -0
  5. package/dist/assets/MarketplacePage-Cj1HGbGe.js +49 -0
  6. package/dist/assets/ModelConfig-C2f3h7yq.js +1 -0
  7. package/dist/assets/{ProvidersList-BXHpjVtO.js → ProvidersList-DUdQEMNV.js} +1 -1
  8. package/dist/assets/RuntimeConfig-BnR60m9J.js +1 -0
  9. package/dist/assets/{SecretsConfig-KkgMzdt1.js → SecretsConfig-CXV017VN.js} +2 -2
  10. package/dist/assets/SessionsConfig-DsgHhuYe.js +2 -0
  11. package/dist/assets/{card-D7NY0Szf.js → card-B7d3Z9Y7.js} +1 -1
  12. package/dist/assets/index-Dp6x_DHf.js +2 -0
  13. package/dist/assets/index-DsQL2mtx.css +1 -0
  14. package/dist/assets/{label-Ojs7Al6B.js → label-Dlq0AZXx.js} +1 -1
  15. package/dist/assets/{logos-B1qBsCSi.js → logos-CSTJsbua.js} +1 -1
  16. package/dist/assets/{page-layout-CUMMO0nN.js → page-layout-DeBYaT_B.js} +1 -1
  17. package/dist/assets/provider-models-y4mUDcGF.js +1 -0
  18. package/dist/assets/{switch-BdhS_16-.js → switch-DwDE9PLr.js} +1 -1
  19. package/dist/assets/{tabs-custom-D261E5EA.js → tabs-custom-DqY_ht59.js} +1 -1
  20. package/dist/assets/useConfig-BiM-oO9i.js +6 -0
  21. package/dist/assets/{useConfirmDialog-BUKGHDL6.js → useConfirmDialog-BEFIWczY.js} +2 -2
  22. package/dist/assets/{vendor-Dh04PGww.js → vendor-Ylg6Wdt_.js} +84 -69
  23. package/dist/index.html +3 -3
  24. package/package.json +2 -1
  25. package/src/App.tsx +10 -6
  26. package/src/api/config.ts +42 -1
  27. package/src/api/types.ts +29 -0
  28. package/src/components/chat/ChatConversationPanel.tsx +75 -86
  29. package/src/components/chat/ChatInputBar.tsx +226 -0
  30. package/src/components/chat/ChatPage.tsx +359 -188
  31. package/src/components/chat/ChatSidebar.tsx +242 -0
  32. package/src/components/chat/ChatThread.tsx +92 -25
  33. package/src/components/chat/ChatWelcome.tsx +61 -0
  34. package/src/components/chat/SkillsPicker.tsx +137 -0
  35. package/src/components/chat/useChatStreamController.ts +287 -56
  36. package/src/components/config/ChannelForm.tsx +1 -1
  37. package/src/components/config/ChannelsList.tsx +3 -3
  38. package/src/components/config/ModelConfig.tsx +11 -89
  39. package/src/components/config/RuntimeConfig.tsx +29 -1
  40. package/src/components/layout/AppLayout.tsx +42 -6
  41. package/src/components/layout/Sidebar.tsx +68 -62
  42. package/src/components/marketplace/MarketplacePage.tsx +13 -3
  43. package/src/components/ui/popover.tsx +31 -0
  44. package/src/hooks/useConfig.ts +18 -0
  45. package/src/lib/i18n.ts +47 -0
  46. package/src/lib/provider-models.ts +129 -0
  47. package/dist/assets/ChannelsList-C8cguFLc.js +0 -1
  48. package/dist/assets/ChatPage-BkHWNUNR.js +0 -32
  49. package/dist/assets/CronConfig-D-ESQlvk.js +0 -1
  50. package/dist/assets/DocBrowser-B9ZD6pAk.js +0 -1
  51. package/dist/assets/MarketplacePage-Ds_l9KTF.js +0 -49
  52. package/dist/assets/ModelConfig-N1tbLv9b.js +0 -1
  53. package/dist/assets/RuntimeConfig-KsKfkjgv.js +0 -1
  54. package/dist/assets/SessionsConfig-CWBp8IPf.js +0 -2
  55. package/dist/assets/index-BRBYYgR_.js +0 -2
  56. package/dist/assets/index-C5cdRzpO.css +0 -1
  57. package/dist/assets/useConfig-txxbxXnT.js +0 -6
@@ -1,59 +1,85 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
- import type { SessionEntryView } from '@/api/types';
3
- import { useConfig, useDeleteSession, useSessionHistory, useSessions } from '@/hooks/useConfig';
2
+ import type { ComponentProps, Dispatch, MutableRefObject, SetStateAction } from 'react';
3
+ import type { SessionEntryView, SessionEventView } from '@/api/types';
4
+ import {
5
+ useChatCapabilities,
6
+ useConfig,
7
+ useConfigMeta,
8
+ useDeleteSession,
9
+ useSessionHistory,
10
+ useSessions
11
+ } from '@/hooks/useConfig';
12
+ import { useMarketplaceInstalled } from '@/hooks/useMarketplace';
4
13
  import { useConfirmDialog } from '@/hooks/useConfirmDialog';
5
- import { Button } from '@/components/ui/button';
6
- import { PageHeader, PageLayout } from '@/components/layout/page-layout';
7
- import { ChatSessionsSidebar } from '@/components/chat/ChatSessionsSidebar';
14
+ import type { ChatModelOption } from '@/components/chat/ChatInputBar';
15
+ import { ChatSidebar } from '@/components/chat/ChatSidebar';
8
16
  import { ChatConversationPanel } from '@/components/chat/ChatConversationPanel';
17
+ import { CronConfig } from '@/components/config/CronConfig';
18
+ import { MarketplacePage } from '@/components/marketplace/MarketplacePage';
9
19
  import { useChatStreamController } from '@/components/chat/useChatStreamController';
10
- import { cn } from '@/lib/utils';
11
20
  import { buildFallbackEventsFromMessages } from '@/lib/chat-message';
21
+ import { buildProviderModelCatalog, composeProviderModel } from '@/lib/provider-models';
12
22
  import { t } from '@/lib/i18n';
13
- import { Plus, RefreshCw } from 'lucide-react';
23
+ import { useLocation, useNavigate, useParams } from 'react-router-dom';
14
24
 
15
- const CHAT_SESSION_STORAGE_KEY = 'nextclaw.ui.chat.activeSession';
16
- const UNKNOWN_CHAT_CHANNEL_KEY = '__unknown_channel__';
25
+ const SESSION_ROUTE_PREFIX = 'sid_';
17
26
 
18
- function readStoredSessionKey(): string | null {
19
- if (typeof window === 'undefined') {
27
+ function resolveAgentIdFromSessionKey(sessionKey: string): string | null {
28
+ const match = /^agent:([^:]+):/i.exec(sessionKey.trim());
29
+ if (!match) {
20
30
  return null;
21
31
  }
22
- try {
23
- const value = window.localStorage.getItem(CHAT_SESSION_STORAGE_KEY);
24
- return value && value.trim().length > 0 ? value : null;
25
- } catch {
26
- return null;
32
+ const value = match[1]?.trim();
33
+ return value ? value : null;
34
+ }
35
+
36
+ function buildNewSessionKey(agentId: string): string {
37
+ const slug = Math.random().toString(36).slice(2, 8);
38
+ return `agent:${agentId}:ui:direct:web-${Date.now().toString(36)}${slug}`;
39
+ }
40
+
41
+ function encodeSessionRouteId(sessionKey: string): string {
42
+ const bytes = new TextEncoder().encode(sessionKey);
43
+ let binary = '';
44
+ for (const byte of bytes) {
45
+ binary += String.fromCharCode(byte);
27
46
  }
47
+ const base64 = btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
48
+ return `${SESSION_ROUTE_PREFIX}${base64}`;
28
49
  }
29
50
 
30
- function writeStoredSessionKey(value: string | null): void {
31
- if (typeof window === 'undefined') {
32
- return;
51
+ function decodeSessionRouteId(routeValue: string): string | null {
52
+ if (!routeValue.startsWith(SESSION_ROUTE_PREFIX)) {
53
+ return null;
33
54
  }
55
+ const encoded = routeValue.slice(SESSION_ROUTE_PREFIX.length).replace(/-/g, '+').replace(/_/g, '/');
56
+ const padding = encoded.length % 4 === 0 ? '' : '='.repeat(4 - (encoded.length % 4));
34
57
  try {
35
- if (!value) {
36
- window.localStorage.removeItem(CHAT_SESSION_STORAGE_KEY);
37
- return;
38
- }
39
- window.localStorage.setItem(CHAT_SESSION_STORAGE_KEY, value);
58
+ const binary = atob(encoded + padding);
59
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
60
+ return new TextDecoder().decode(bytes);
40
61
  } catch {
41
- // ignore storage errors
62
+ return null;
42
63
  }
43
64
  }
44
65
 
45
- function resolveAgentIdFromSessionKey(sessionKey: string): string | null {
46
- const match = /^agent:([^:]+):/i.exec(sessionKey.trim());
47
- if (!match) {
66
+ function parseSessionKeyFromRoute(routeValue?: string): string | null {
67
+ if (!routeValue) {
48
68
  return null;
49
69
  }
50
- const value = match[1]?.trim();
51
- return value ? value : null;
70
+ const decodedToken = decodeSessionRouteId(routeValue);
71
+ if (decodedToken) {
72
+ return decodedToken;
73
+ }
74
+ try {
75
+ return decodeURIComponent(routeValue);
76
+ } catch {
77
+ return routeValue;
78
+ }
52
79
  }
53
80
 
54
- function buildNewSessionKey(agentId: string): string {
55
- const slug = Math.random().toString(36).slice(2, 8);
56
- return `agent:${agentId}:ui:direct:web-${Date.now().toString(36)}${slug}`;
81
+ function buildSessionPath(sessionKey: string): string {
82
+ return `/chat/${encodeSessionRouteId(sessionKey)}`;
57
83
  }
58
84
 
59
85
  function sessionDisplayName(session: SessionEntryView): string {
@@ -64,73 +90,227 @@ function sessionDisplayName(session: SessionEntryView): string {
64
90
  return chunks[chunks.length - 1] || session.key;
65
91
  }
66
92
 
67
- function resolveChannelFromSessionKey(key: string): string {
68
- const separator = key.indexOf(':');
69
- if (separator <= 0) {
70
- return UNKNOWN_CHAT_CHANNEL_KEY;
71
- }
72
- const channel = key.slice(0, separator).trim();
73
- return channel || UNKNOWN_CHAT_CHANNEL_KEY;
93
+ type MainPanelView = 'chat' | 'cron' | 'skills';
94
+
95
+ type ChatPageProps = {
96
+ view: MainPanelView;
97
+ };
98
+
99
+ type UseSessionSyncParams = {
100
+ view: MainPanelView;
101
+ routeSessionKey: string | null;
102
+ selectedSessionKey: string | null;
103
+ selectedAgentId: string;
104
+ setSelectedSessionKey: Dispatch<SetStateAction<string | null>>;
105
+ setSelectedAgentId: Dispatch<SetStateAction<string>>;
106
+ selectedSessionKeyRef: MutableRefObject<string | null>;
107
+ isUserScrollingRef: MutableRefObject<boolean>;
108
+ resetStreamState: () => void;
109
+ };
110
+
111
+ function useChatSessionSync(params: UseSessionSyncParams): void {
112
+ const {
113
+ view,
114
+ routeSessionKey,
115
+ selectedSessionKey,
116
+ selectedAgentId,
117
+ setSelectedSessionKey,
118
+ setSelectedAgentId,
119
+ selectedSessionKeyRef,
120
+ isUserScrollingRef,
121
+ resetStreamState
122
+ } = params;
123
+
124
+ useEffect(() => {
125
+ if (view !== 'chat') {
126
+ return;
127
+ }
128
+ if (routeSessionKey) {
129
+ if (selectedSessionKey !== routeSessionKey) {
130
+ setSelectedSessionKey(routeSessionKey);
131
+ }
132
+ return;
133
+ }
134
+ if (selectedSessionKey !== null) {
135
+ setSelectedSessionKey(null);
136
+ resetStreamState();
137
+ }
138
+ }, [resetStreamState, routeSessionKey, selectedSessionKey, setSelectedSessionKey, view]);
139
+
140
+ useEffect(() => {
141
+ const inferred = selectedSessionKey ? resolveAgentIdFromSessionKey(selectedSessionKey) : null;
142
+ if (!inferred) {
143
+ return;
144
+ }
145
+ if (selectedAgentId !== inferred) {
146
+ setSelectedAgentId(inferred);
147
+ }
148
+ }, [selectedAgentId, selectedSessionKey, setSelectedAgentId]);
149
+
150
+ useEffect(() => {
151
+ selectedSessionKeyRef.current = selectedSessionKey;
152
+ isUserScrollingRef.current = false;
153
+ }, [isUserScrollingRef, selectedSessionKey, selectedSessionKeyRef]);
74
154
  }
75
155
 
76
- function displayChannelName(channel: string): string {
77
- if (channel === UNKNOWN_CHAT_CHANNEL_KEY) {
78
- return t('sessionsUnknownChannel');
79
- }
80
- return channel;
156
+ type UseThreadScrollParams = {
157
+ threadRef: MutableRefObject<HTMLDivElement | null>;
158
+ isUserScrollingRef: MutableRefObject<boolean>;
159
+ mergedEvents: SessionEventView[];
160
+ isSending: boolean;
161
+ };
162
+
163
+ function useChatThreadScroll(params: UseThreadScrollParams): { handleScroll: () => void } {
164
+ const { threadRef, isUserScrollingRef, mergedEvents, isSending } = params;
165
+
166
+ const isNearBottom = useCallback(() => {
167
+ const element = threadRef.current;
168
+ if (!element) {
169
+ return true;
170
+ }
171
+ const threshold = 50;
172
+ return element.scrollHeight - element.scrollTop - element.clientHeight < threshold;
173
+ }, [threadRef]);
174
+
175
+ const handleScroll = useCallback(() => {
176
+ if (isNearBottom()) {
177
+ isUserScrollingRef.current = false;
178
+ } else {
179
+ isUserScrollingRef.current = true;
180
+ }
181
+ }, [isNearBottom, isUserScrollingRef]);
182
+
183
+ useEffect(() => {
184
+ const element = threadRef.current;
185
+ if (!element || isUserScrollingRef.current) {
186
+ return;
187
+ }
188
+ element.scrollTop = element.scrollHeight;
189
+ }, [isSending, isUserScrollingRef, mergedEvents, threadRef]);
190
+
191
+ return { handleScroll };
192
+ }
193
+
194
+ type ChatPageLayoutProps = {
195
+ view: MainPanelView;
196
+ sidebarProps: ComponentProps<typeof ChatSidebar>;
197
+ conversationProps: ComponentProps<typeof ChatConversationPanel>;
198
+ confirmDialog: JSX.Element;
199
+ };
200
+
201
+ function ChatPageLayout({ view, sidebarProps, conversationProps, confirmDialog }: ChatPageLayoutProps) {
202
+ return (
203
+ <div className="h-full flex">
204
+ <ChatSidebar {...sidebarProps} />
205
+
206
+ {view === 'chat' ? (
207
+ <ChatConversationPanel {...conversationProps} />
208
+ ) : (
209
+ <section className="flex-1 min-h-0 overflow-hidden bg-gradient-to-b from-gray-50/60 to-white">
210
+ <div className="h-full overflow-auto custom-scrollbar">
211
+ <div className="mx-auto w-full max-w-[min(1120px,100%)] px-6 py-5">
212
+ {view === 'cron' ? <CronConfig /> : <MarketplacePage forcedType="skills" />}
213
+ </div>
214
+ </div>
215
+ </section>
216
+ )}
217
+
218
+ {confirmDialog}
219
+ </div>
220
+ );
81
221
  }
82
222
 
83
- export function ChatPage() {
223
+ export function ChatPage({ view }: ChatPageProps) {
84
224
  const [query, setQuery] = useState('');
85
- const [selectedChannel, setSelectedChannel] = useState('all');
86
225
  const [draft, setDraft] = useState('');
87
- const [selectedSessionKey, setSelectedSessionKey] = useState<string | null>(() => readStoredSessionKey());
226
+ const [selectedSessionKey, setSelectedSessionKey] = useState<string | null>(null);
88
227
  const [selectedAgentId, setSelectedAgentId] = useState('main');
228
+ const [selectedModel, setSelectedModel] = useState('');
229
+ const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
89
230
 
90
231
  const { confirm, ConfirmDialog } = useConfirmDialog();
232
+ const location = useLocation();
233
+ const navigate = useNavigate();
234
+ const { sessionId: routeSessionIdParam } = useParams<{ sessionId?: string }>();
91
235
  const threadRef = useRef<HTMLDivElement | null>(null);
92
236
  const isUserScrollingRef = useRef(false);
93
237
  const selectedSessionKeyRef = useRef<string | null>(selectedSessionKey);
238
+ const routeSessionKey = useMemo(
239
+ () => parseSessionKeyFromRoute(routeSessionIdParam),
240
+ [routeSessionIdParam]
241
+ );
94
242
 
95
243
  const configQuery = useConfig();
244
+ const configMetaQuery = useConfigMeta();
96
245
  const sessionsQuery = useSessions({ q: query.trim() || undefined, limit: 120, activeMinutes: 0 });
246
+ const installedSkillsQuery = useMarketplaceInstalled('skill');
247
+ const chatCapabilitiesQuery = useChatCapabilities({
248
+ sessionKey: selectedSessionKey,
249
+ agentId: selectedAgentId
250
+ });
97
251
  const historyQuery = useSessionHistory(selectedSessionKey, 300);
98
252
  const deleteSession = useDeleteSession();
99
253
 
100
- const agentOptions = useMemo(() => {
101
- const list = configQuery.data?.agents.list ?? [];
102
- const unique = new Set<string>(['main']);
103
- for (const item of list) {
104
- if (typeof item.id === 'string' && item.id.trim().length > 0) {
105
- unique.add(item.id.trim().toLowerCase());
254
+ const modelOptions = useMemo<ChatModelOption[]>(() => {
255
+ const providers = buildProviderModelCatalog({
256
+ meta: configMetaQuery.data,
257
+ config: configQuery.data,
258
+ onlyConfigured: true
259
+ });
260
+ const seen = new Set<string>();
261
+ const options: ChatModelOption[] = [];
262
+ for (const provider of providers) {
263
+ for (const localModel of provider.models) {
264
+ const value = composeProviderModel(provider.prefix, localModel);
265
+ if (!value || seen.has(value)) {
266
+ continue;
267
+ }
268
+ seen.add(value);
269
+ options.push({
270
+ value,
271
+ modelLabel: localModel,
272
+ providerLabel: provider.displayName
273
+ });
106
274
  }
107
275
  }
108
- return Array.from(unique);
109
- }, [configQuery.data?.agents.list]);
276
+ return options.sort((left, right) => {
277
+ const providerCompare = left.providerLabel.localeCompare(right.providerLabel);
278
+ if (providerCompare !== 0) {
279
+ return providerCompare;
280
+ }
281
+ return left.modelLabel.localeCompare(right.modelLabel);
282
+ });
283
+ }, [configMetaQuery.data, configQuery.data]);
110
284
 
111
285
  const sessions = useMemo(() => sessionsQuery.data?.sessions ?? [], [sessionsQuery.data?.sessions]);
112
- const channelOptions = useMemo(() => {
113
- const unique = new Set<string>();
114
- for (const session of sessions) {
115
- unique.add(resolveChannelFromSessionKey(session.key));
116
- }
117
- return Array.from(unique).sort((a, b) => {
118
- if (a === UNKNOWN_CHAT_CHANNEL_KEY) return 1;
119
- if (b === UNKNOWN_CHAT_CHANNEL_KEY) return -1;
120
- return a.localeCompare(b);
121
- });
122
- }, [sessions]);
123
- const filteredSessions = useMemo(() => {
124
- if (selectedChannel === 'all') {
125
- return sessions;
126
- }
127
- return sessions.filter((session) => resolveChannelFromSessionKey(session.key) === selectedChannel);
128
- }, [selectedChannel, sessions]);
286
+ const skillRecords = useMemo(() => installedSkillsQuery.data?.records ?? [], [installedSkillsQuery.data?.records]);
287
+
129
288
  const selectedSession = useMemo(
130
289
  () => sessions.find((session) => session.key === selectedSessionKey) ?? null,
131
290
  [selectedSessionKey, sessions]
132
291
  );
133
292
 
293
+ useEffect(() => {
294
+ if (modelOptions.length === 0) {
295
+ setSelectedModel('');
296
+ return;
297
+ }
298
+ setSelectedModel((prev) => {
299
+ if (modelOptions.some((option) => option.value === prev)) {
300
+ return prev;
301
+ }
302
+ const sessionPreferred = selectedSession?.preferredModel?.trim();
303
+ if (sessionPreferred && modelOptions.some((option) => option.value === sessionPreferred)) {
304
+ return sessionPreferred;
305
+ }
306
+ const fallback = configQuery.data?.agents.defaults.model?.trim();
307
+ if (fallback && modelOptions.some((option) => option.value === fallback)) {
308
+ return fallback;
309
+ }
310
+ return modelOptions[0]?.value ?? '';
311
+ });
312
+ }, [configQuery.data?.agents.defaults.model, modelOptions, selectedSession?.preferredModel]);
313
+
134
314
  const historyData = historyQuery.data;
135
315
  const historyMessages = historyData?.messages ?? [];
136
316
  const historyEvents =
@@ -150,7 +330,11 @@ export function ChatPage() {
150
330
  isSending,
151
331
  isAwaitingAssistantOutput,
152
332
  queuedCount,
333
+ canStopCurrentRun,
334
+ stopDisabledReason,
335
+ lastSendError,
153
336
  sendMessage,
337
+ stopCurrentRun,
154
338
  resetStreamState
155
339
  } = useChatStreamController({
156
340
  nextOptimisticUserSeq,
@@ -186,59 +370,32 @@ export function ChatPage() {
186
370
  return next;
187
371
  }, [historyEvents, optimisticUserEvent, streamingAssistantText, streamingAssistantTimestamp, streamingSessionEvents]);
188
372
 
189
- useEffect(() => {
190
- if (!selectedSessionKey && filteredSessions.length > 0) {
191
- setSelectedSessionKey(filteredSessions[0].key);
192
- }
193
- }, [filteredSessions, selectedSessionKey]);
194
-
195
- useEffect(() => {
196
- writeStoredSessionKey(selectedSessionKey);
197
- }, [selectedSessionKey]);
198
-
199
- useEffect(() => {
200
- const inferred = selectedSessionKey ? resolveAgentIdFromSessionKey(selectedSessionKey) : null;
201
- if (!inferred) {
202
- return;
203
- }
204
- if (selectedAgentId !== inferred) {
205
- setSelectedAgentId(inferred);
206
- }
207
- }, [selectedAgentId, selectedSessionKey]);
208
-
209
- useEffect(() => {
210
- selectedSessionKeyRef.current = selectedSessionKey;
211
- isUserScrollingRef.current = false;
212
- }, [selectedSessionKey]);
213
-
214
- const isNearBottom = useCallback(() => {
215
- const element = threadRef.current;
216
- if (!element) return true;
217
- const threshold = 50;
218
- return element.scrollHeight - element.scrollTop - element.clientHeight < threshold;
219
- }, []);
220
-
221
- const handleScroll = useCallback(() => {
222
- if (isNearBottom()) {
223
- isUserScrollingRef.current = false;
224
- } else {
225
- isUserScrollingRef.current = true;
226
- }
227
- }, [isNearBottom]);
373
+ useChatSessionSync({
374
+ view,
375
+ routeSessionKey,
376
+ selectedSessionKey,
377
+ selectedAgentId,
378
+ setSelectedSessionKey,
379
+ setSelectedAgentId,
380
+ selectedSessionKeyRef,
381
+ isUserScrollingRef,
382
+ resetStreamState
383
+ });
228
384
 
229
- useEffect(() => {
230
- const element = threadRef.current;
231
- if (!element || isUserScrollingRef.current) {
232
- return;
233
- }
234
- element.scrollTop = element.scrollHeight;
235
- }, [mergedEvents, isSending]);
385
+ const { handleScroll } = useChatThreadScroll({
386
+ threadRef,
387
+ isUserScrollingRef,
388
+ mergedEvents,
389
+ isSending
390
+ });
236
391
 
237
392
  const createNewSession = useCallback(() => {
238
393
  resetStreamState();
239
- const next = buildNewSessionKey(selectedAgentId);
240
- setSelectedSessionKey(next);
241
- }, [resetStreamState, selectedAgentId]);
394
+ setSelectedSessionKey(null);
395
+ if (location.pathname !== '/chat') {
396
+ navigate('/chat');
397
+ }
398
+ }, [location.pathname, navigate, resetStreamState]);
242
399
 
243
400
  const handleDeleteSession = useCallback(async () => {
244
401
  if (!selectedSessionKey) {
@@ -258,94 +415,108 @@ export function ChatPage() {
258
415
  onSuccess: async () => {
259
416
  resetStreamState();
260
417
  setSelectedSessionKey(null);
418
+ navigate('/chat', { replace: true });
261
419
  await sessionsQuery.refetch();
262
420
  }
263
421
  }
264
422
  );
265
- }, [confirm, deleteSession, resetStreamState, selectedSessionKey, sessionsQuery]);
423
+ }, [confirm, deleteSession, navigate, resetStreamState, selectedSessionKey, sessionsQuery]);
266
424
 
267
425
  const handleSend = useCallback(async () => {
268
426
  const message = draft.trim();
269
427
  if (!message) {
270
428
  return;
271
429
  }
430
+ const requestedSkills = selectedSkills;
272
431
 
273
432
  const sessionKey = selectedSessionKey ?? buildNewSessionKey(selectedAgentId);
274
433
  if (!selectedSessionKey) {
275
- setSelectedSessionKey(sessionKey);
434
+ navigate(buildSessionPath(sessionKey), { replace: true });
276
435
  }
277
436
  setDraft('');
437
+ setSelectedSkills([]);
278
438
  await sendMessage({
279
439
  message,
280
440
  sessionKey,
281
441
  agentId: selectedAgentId,
442
+ model: selectedModel || undefined,
443
+ stopSupported: chatCapabilitiesQuery.data?.stopSupported ?? false,
444
+ stopReason: chatCapabilitiesQuery.data?.stopReason,
445
+ requestedSkills,
282
446
  restoreDraftOnError: true
283
447
  });
284
- }, [draft, selectedSessionKey, selectedAgentId, sendMessage]);
448
+ }, [
449
+ chatCapabilitiesQuery.data?.stopReason,
450
+ chatCapabilitiesQuery.data?.stopSupported,
451
+ draft,
452
+ selectedAgentId,
453
+ selectedModel,
454
+ navigate,
455
+ selectedSessionKey,
456
+ selectedSkills,
457
+ sendMessage
458
+ ]);
459
+
460
+ const currentSessionDisplayName = selectedSession ? sessionDisplayName(selectedSession) : undefined;
461
+ const handleSelectSession = useCallback((nextSessionKey: string) => {
462
+ const target = buildSessionPath(nextSessionKey);
463
+ if (location.pathname !== target) {
464
+ navigate(target);
465
+ }
466
+ }, [location.pathname, navigate]);
467
+
468
+ const sidebarProps: ComponentProps<typeof ChatSidebar> = {
469
+ sessions,
470
+ selectedSessionKey,
471
+ onSelectSession: handleSelectSession,
472
+ onCreateSession: createNewSession,
473
+ sessionTitle: sessionDisplayName,
474
+ isLoading: sessionsQuery.isLoading,
475
+ query,
476
+ onQueryChange: setQuery
477
+ };
478
+
479
+ const conversationProps: ComponentProps<typeof ChatConversationPanel> = {
480
+ modelOptions,
481
+ selectedModel,
482
+ onSelectedModelChange: setSelectedModel,
483
+ skillRecords,
484
+ isSkillsLoading: installedSkillsQuery.isLoading,
485
+ selectedSkills,
486
+ onSelectedSkillsChange: setSelectedSkills,
487
+ selectedSessionKey,
488
+ sessionDisplayName: currentSessionDisplayName,
489
+ canDeleteSession: Boolean(selectedSession),
490
+ isDeletePending: deleteSession.isPending,
491
+ onDeleteSession: () => {
492
+ void handleDeleteSession();
493
+ },
494
+ onCreateSession: createNewSession,
495
+ threadRef,
496
+ onThreadScroll: handleScroll,
497
+ isHistoryLoading: historyQuery.isLoading,
498
+ mergedEvents,
499
+ isSending,
500
+ isAwaitingAssistantOutput,
501
+ streamingAssistantText,
502
+ draft,
503
+ onDraftChange: setDraft,
504
+ onSend: handleSend,
505
+ onStop: () => {
506
+ void stopCurrentRun();
507
+ },
508
+ canStopGeneration: canStopCurrentRun,
509
+ stopDisabledReason,
510
+ sendError: lastSendError,
511
+ queuedCount
512
+ };
285
513
 
286
514
  return (
287
- <PageLayout fullHeight>
288
- <PageHeader
289
- title={t('chatPageTitle')}
290
- description={t('chatPageDescription')}
291
- actions={
292
- <div className="flex items-center gap-2">
293
- <Button variant="outline" size="sm" onClick={() => historyQuery.refetch()} className="rounded-lg">
294
- <RefreshCw className={cn('h-3.5 w-3.5 mr-1.5', historyQuery.isFetching && 'animate-spin')} />
295
- {t('chatRefresh')}
296
- </Button>
297
- <Button variant="primary" size="sm" onClick={createNewSession} className="rounded-lg">
298
- <Plus className="h-3.5 w-3.5 mr-1.5" />
299
- {t('chatNewSession')}
300
- </Button>
301
- </div>
302
- }
303
- />
304
-
305
- <div className="flex-1 min-h-0 flex gap-4 max-lg:flex-col">
306
- <ChatSessionsSidebar
307
- query={query}
308
- onQueryChange={setQuery}
309
- selectedChannel={selectedChannel}
310
- onSelectedChannelChange={setSelectedChannel}
311
- channelOptions={channelOptions}
312
- channelLabel={displayChannelName}
313
- isLoading={sessionsQuery.isLoading}
314
- isRefreshing={sessionsQuery.isFetching}
315
- sessions={filteredSessions}
316
- selectedSessionKey={selectedSessionKey}
317
- onSelectSession={setSelectedSessionKey}
318
- sessionTitle={sessionDisplayName}
319
- onRefresh={() => {
320
- void sessionsQuery.refetch();
321
- }}
322
- onCreateSession={createNewSession}
323
- />
324
-
325
- <ChatConversationPanel
326
- agentOptions={agentOptions}
327
- selectedAgentId={selectedAgentId}
328
- onSelectedAgentIdChange={setSelectedAgentId}
329
- selectedSessionKey={selectedSessionKey}
330
- canDeleteSession={Boolean(selectedSession)}
331
- isDeletePending={deleteSession.isPending}
332
- onDeleteSession={() => {
333
- void handleDeleteSession();
334
- }}
335
- threadRef={threadRef}
336
- onThreadScroll={handleScroll}
337
- isHistoryLoading={historyQuery.isLoading}
338
- mergedEvents={mergedEvents}
339
- isSending={isSending}
340
- isAwaitingAssistantOutput={isAwaitingAssistantOutput}
341
- streamingAssistantText={streamingAssistantText}
342
- draft={draft}
343
- onDraftChange={setDraft}
344
- onSend={handleSend}
345
- queuedCount={queuedCount}
346
- />
347
- </div>
348
- <ConfirmDialog />
349
- </PageLayout>
515
+ <ChatPageLayout
516
+ view={view}
517
+ sidebarProps={sidebarProps}
518
+ conversationProps={conversationProps}
519
+ confirmDialog={<ConfirmDialog />}
520
+ />
350
521
  );
351
522
  }