@nextclaw/ui 0.5.48 → 0.6.1

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 +18 -0
  2. package/dist/assets/ChannelsList-CkCpHSto.js +1 -0
  3. package/dist/assets/ChatPage-DM4XNsrW.js +32 -0
  4. package/dist/assets/DocBrowser-B5Aqiz6W.js +1 -0
  5. package/dist/assets/MarketplacePage-BIi0bBdW.js +49 -0
  6. package/dist/assets/ModelConfig-BTFiEAxQ.js +1 -0
  7. package/dist/assets/{ProvidersList-BXHpjVtO.js → ProvidersList-cdk1d-G_.js} +1 -1
  8. package/dist/assets/RuntimeConfig-CFqFsXmR.js +1 -0
  9. package/dist/assets/{SecretsConfig-KkgMzdt1.js → SecretsConfig-CIKasCek.js} +2 -2
  10. package/dist/assets/SessionsConfig-mnCLFtbo.js +2 -0
  11. package/dist/assets/{card-D7NY0Szf.js → card-C1BUfR85.js} +1 -1
  12. package/dist/assets/index-Dxas8MJ9.js +2 -0
  13. package/dist/assets/index-P4YzN9iS.css +1 -0
  14. package/dist/assets/{label-Ojs7Al6B.js → label-CwWfYbuj.js} +1 -1
  15. package/dist/assets/{logos-B1qBsCSi.js → logos-DDyjHSEU.js} +1 -1
  16. package/dist/assets/{page-layout-CUMMO0nN.js → page-layout-DKTRKcHL.js} +1 -1
  17. package/dist/assets/provider-models-y4mUDcGF.js +1 -0
  18. package/dist/assets/{switch-BdhS_16-.js → switch-Bi3yeYiC.js} +1 -1
  19. package/dist/assets/{tabs-custom-D261E5EA.js → tabs-custom-HZFNZrc0.js} +1 -1
  20. package/dist/assets/useConfig-CgzVQTZl.js +6 -0
  21. package/dist/assets/{useConfirmDialog-BUKGHDL6.js → useConfirmDialog-DwD21HlD.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 +109 -85
  29. package/src/components/chat/ChatInputBar.tsx +245 -0
  30. package/src/components/chat/ChatPage.tsx +365 -187
  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 +53 -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,38 @@ 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]);
373
+ useChatSessionSync({
374
+ view,
375
+ routeSessionKey,
376
+ selectedSessionKey,
377
+ selectedAgentId,
378
+ setSelectedSessionKey,
379
+ setSelectedAgentId,
380
+ selectedSessionKeyRef,
381
+ isUserScrollingRef,
382
+ resetStreamState
383
+ });
213
384
 
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
- }, []);
385
+ const { handleScroll } = useChatThreadScroll({
386
+ threadRef,
387
+ isUserScrollingRef,
388
+ mergedEvents,
389
+ isSending
390
+ });
220
391
 
221
- const handleScroll = useCallback(() => {
222
- if (isNearBottom()) {
223
- isUserScrollingRef.current = false;
224
- } else {
225
- isUserScrollingRef.current = true;
392
+ const createNewSession = useCallback(() => {
393
+ resetStreamState();
394
+ setSelectedSessionKey(null);
395
+ if (location.pathname !== '/chat') {
396
+ navigate('/chat');
226
397
  }
227
- }, [isNearBottom]);
398
+ }, [location.pathname, navigate, resetStreamState]);
228
399
 
229
- useEffect(() => {
230
- const element = threadRef.current;
231
- if (!element || isUserScrollingRef.current) {
232
- return;
400
+ const goToProviders = useCallback(() => {
401
+ if (location.pathname !== '/providers') {
402
+ navigate('/providers');
233
403
  }
234
- element.scrollTop = element.scrollHeight;
235
- }, [mergedEvents, isSending]);
236
-
237
- const createNewSession = useCallback(() => {
238
- resetStreamState();
239
- const next = buildNewSessionKey(selectedAgentId);
240
- setSelectedSessionKey(next);
241
- }, [resetStreamState, selectedAgentId]);
404
+ }, [location.pathname, navigate]);
242
405
 
243
406
  const handleDeleteSession = useCallback(async () => {
244
407
  if (!selectedSessionKey) {
@@ -258,94 +421,109 @@ export function ChatPage() {
258
421
  onSuccess: async () => {
259
422
  resetStreamState();
260
423
  setSelectedSessionKey(null);
424
+ navigate('/chat', { replace: true });
261
425
  await sessionsQuery.refetch();
262
426
  }
263
427
  }
264
428
  );
265
- }, [confirm, deleteSession, resetStreamState, selectedSessionKey, sessionsQuery]);
429
+ }, [confirm, deleteSession, navigate, resetStreamState, selectedSessionKey, sessionsQuery]);
266
430
 
267
431
  const handleSend = useCallback(async () => {
268
432
  const message = draft.trim();
269
433
  if (!message) {
270
434
  return;
271
435
  }
436
+ const requestedSkills = selectedSkills;
272
437
 
273
438
  const sessionKey = selectedSessionKey ?? buildNewSessionKey(selectedAgentId);
274
439
  if (!selectedSessionKey) {
275
- setSelectedSessionKey(sessionKey);
440
+ navigate(buildSessionPath(sessionKey), { replace: true });
276
441
  }
277
442
  setDraft('');
443
+ setSelectedSkills([]);
278
444
  await sendMessage({
279
445
  message,
280
446
  sessionKey,
281
447
  agentId: selectedAgentId,
448
+ model: selectedModel || undefined,
449
+ stopSupported: chatCapabilitiesQuery.data?.stopSupported ?? false,
450
+ stopReason: chatCapabilitiesQuery.data?.stopReason,
451
+ requestedSkills,
282
452
  restoreDraftOnError: true
283
453
  });
284
- }, [draft, selectedSessionKey, selectedAgentId, sendMessage]);
454
+ }, [
455
+ chatCapabilitiesQuery.data?.stopReason,
456
+ chatCapabilitiesQuery.data?.stopSupported,
457
+ draft,
458
+ selectedAgentId,
459
+ selectedModel,
460
+ navigate,
461
+ selectedSessionKey,
462
+ selectedSkills,
463
+ sendMessage
464
+ ]);
465
+
466
+ const currentSessionDisplayName = selectedSession ? sessionDisplayName(selectedSession) : undefined;
467
+ const handleSelectSession = useCallback((nextSessionKey: string) => {
468
+ const target = buildSessionPath(nextSessionKey);
469
+ if (location.pathname !== target) {
470
+ navigate(target);
471
+ }
472
+ }, [location.pathname, navigate]);
473
+
474
+ const sidebarProps: ComponentProps<typeof ChatSidebar> = {
475
+ sessions,
476
+ selectedSessionKey,
477
+ onSelectSession: handleSelectSession,
478
+ onCreateSession: createNewSession,
479
+ sessionTitle: sessionDisplayName,
480
+ isLoading: sessionsQuery.isLoading,
481
+ query,
482
+ onQueryChange: setQuery
483
+ };
484
+
485
+ const conversationProps: ComponentProps<typeof ChatConversationPanel> = {
486
+ modelOptions,
487
+ selectedModel,
488
+ onSelectedModelChange: setSelectedModel,
489
+ onGoToProviders: goToProviders,
490
+ skillRecords,
491
+ isSkillsLoading: installedSkillsQuery.isLoading,
492
+ selectedSkills,
493
+ onSelectedSkillsChange: setSelectedSkills,
494
+ selectedSessionKey,
495
+ sessionDisplayName: currentSessionDisplayName,
496
+ canDeleteSession: Boolean(selectedSession),
497
+ isDeletePending: deleteSession.isPending,
498
+ onDeleteSession: () => {
499
+ void handleDeleteSession();
500
+ },
501
+ onCreateSession: createNewSession,
502
+ threadRef,
503
+ onThreadScroll: handleScroll,
504
+ isHistoryLoading: historyQuery.isLoading,
505
+ mergedEvents,
506
+ isSending,
507
+ isAwaitingAssistantOutput,
508
+ streamingAssistantText,
509
+ draft,
510
+ onDraftChange: setDraft,
511
+ onSend: handleSend,
512
+ onStop: () => {
513
+ void stopCurrentRun();
514
+ },
515
+ canStopGeneration: canStopCurrentRun,
516
+ stopDisabledReason,
517
+ sendError: lastSendError,
518
+ queuedCount
519
+ };
285
520
 
286
521
  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>
522
+ <ChatPageLayout
523
+ view={view}
524
+ sidebarProps={sidebarProps}
525
+ conversationProps={conversationProps}
526
+ confirmDialog={<ConfirmDialog />}
527
+ />
350
528
  );
351
529
  }