@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,19 +1,37 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
3
3
  import type { SessionEventView } from '@/api/types';
4
- import { sendChatTurnStream } from '@/api/config';
4
+ import { sendChatTurnStream, stopChatTurn } from '@/api/config';
5
5
 
6
6
  type PendingChatMessage = {
7
7
  id: number;
8
8
  message: string;
9
9
  sessionKey: string;
10
10
  agentId: string;
11
+ model?: string;
12
+ requestedSkills?: string[];
13
+ stopSupported?: boolean;
14
+ stopReason?: string;
15
+ };
16
+
17
+ type ActiveRunState = {
18
+ localRunId: number;
19
+ sessionKey: string;
20
+ agentId: string;
21
+ requestAbortController: AbortController;
22
+ backendRunId?: string;
23
+ backendStopSupported: boolean;
24
+ backendStopReason?: string;
11
25
  };
12
26
 
13
27
  type SendMessageParams = {
14
28
  message: string;
15
29
  sessionKey: string;
16
30
  agentId: string;
31
+ model?: string;
32
+ requestedSkills?: string[];
33
+ stopSupported?: boolean;
34
+ stopReason?: string;
17
35
  restoreDraftOnError?: boolean;
18
36
  };
19
37
 
@@ -26,6 +44,17 @@ type UseChatStreamControllerParams = {
26
44
  refetchHistory: () => Promise<unknown>;
27
45
  };
28
46
 
47
+ function formatSendError(error: unknown): string {
48
+ if (error instanceof Error) {
49
+ const message = error.message.trim();
50
+ if (message) {
51
+ return message;
52
+ }
53
+ }
54
+ const raw = String(error ?? '').trim();
55
+ return raw || 'Failed to send message';
56
+ }
57
+
29
58
  type StreamSetters = {
30
59
  setOptimisticUserEvent: Dispatch<SetStateAction<SessionEventView | null>>;
31
60
  setStreamingSessionEvents: Dispatch<SetStateAction<SessionEventView[]>>;
@@ -33,6 +62,9 @@ type StreamSetters = {
33
62
  setStreamingAssistantTimestamp: Dispatch<SetStateAction<string | null>>;
34
63
  setIsSending: Dispatch<SetStateAction<boolean>>;
35
64
  setIsAwaitingAssistantOutput: Dispatch<SetStateAction<boolean>>;
65
+ setCanStopCurrentRun: Dispatch<SetStateAction<boolean>>;
66
+ setStopDisabledReason: Dispatch<SetStateAction<string | null>>;
67
+ setLastSendError: Dispatch<SetStateAction<string | null>>;
36
68
  };
37
69
 
38
70
  function clearStreamingState(setters: StreamSetters) {
@@ -42,12 +74,78 @@ function clearStreamingState(setters: StreamSetters) {
42
74
  setters.setStreamingAssistantText('');
43
75
  setters.setStreamingAssistantTimestamp(null);
44
76
  setters.setIsAwaitingAssistantOutput(false);
77
+ setters.setCanStopCurrentRun(false);
78
+ setters.setStopDisabledReason(null);
79
+ setters.setLastSendError(null);
80
+ }
81
+
82
+ function normalizeRequestedSkills(value: string[] | undefined): string[] {
83
+ if (!Array.isArray(value)) {
84
+ return [];
85
+ }
86
+ const deduped = new Set<string>();
87
+ for (const item of value) {
88
+ const trimmed = item.trim();
89
+ if (trimmed) {
90
+ deduped.add(trimmed);
91
+ }
92
+ }
93
+ return [...deduped];
94
+ }
95
+
96
+ function isAbortLikeError(error: unknown): boolean {
97
+ if (error instanceof DOMException && error.name === 'AbortError') {
98
+ return true;
99
+ }
100
+ if (error instanceof Error) {
101
+ if (error.name === 'AbortError') {
102
+ return true;
103
+ }
104
+ const lower = error.message.toLowerCase();
105
+ if (lower.includes('aborted') || lower.includes('abort')) {
106
+ return true;
107
+ }
108
+ }
109
+ return false;
110
+ }
111
+
112
+ function buildLocalAssistantEvent(content: string, eventType = 'message.assistant.local'): SessionEventView {
113
+ const timestamp = new Date().toISOString();
114
+ return {
115
+ seq: Date.now(),
116
+ type: eventType,
117
+ timestamp,
118
+ message: {
119
+ role: 'assistant',
120
+ content,
121
+ timestamp
122
+ }
123
+ };
124
+ }
125
+
126
+ async function refetchIfSessionVisible(params: {
127
+ selectedSessionKeyRef: MutableRefObject<string | null>;
128
+ currentSessionKey: string;
129
+ resultSessionKey?: string;
130
+ refetchSessions: () => Promise<unknown>;
131
+ refetchHistory: () => Promise<unknown>;
132
+ }): Promise<void> {
133
+ await params.refetchSessions();
134
+ const activeSessionKey = params.selectedSessionKeyRef.current;
135
+ if (
136
+ !activeSessionKey ||
137
+ activeSessionKey === params.currentSessionKey ||
138
+ (params.resultSessionKey && activeSessionKey === params.resultSessionKey)
139
+ ) {
140
+ await params.refetchHistory();
141
+ }
45
142
  }
46
143
 
47
144
  async function executeSendRun(params: {
48
145
  item: PendingChatMessage;
49
146
  runId: number;
50
147
  runIdRef: MutableRefObject<number>;
148
+ activeRunRef: MutableRefObject<ActiveRunState | null>;
51
149
  nextOptimisticUserSeq: number;
52
150
  selectedSessionKeyRef: MutableRefObject<string | null>;
53
151
  setSelectedSessionKey: Dispatch<SetStateAction<string | null>>;
@@ -61,6 +159,7 @@ async function executeSendRun(params: {
61
159
  item,
62
160
  runId,
63
161
  runIdRef,
162
+ activeRunRef,
64
163
  nextOptimisticUserSeq,
65
164
  selectedSessionKeyRef,
66
165
  setSelectedSessionKey,
@@ -71,6 +170,16 @@ async function executeSendRun(params: {
71
170
  setters
72
171
  } = params;
73
172
 
173
+ const requestAbortController = new AbortController();
174
+ activeRunRef.current = {
175
+ localRunId: runId,
176
+ sessionKey: item.sessionKey,
177
+ agentId: item.agentId,
178
+ requestAbortController,
179
+ backendStopSupported: Boolean(item.stopSupported),
180
+ ...(item.stopReason ? { backendStopReason: item.stopReason } : {})
181
+ };
182
+
74
183
  setters.setStreamingSessionEvents([]);
75
184
  setters.setStreamingAssistantText('');
76
185
  setters.setStreamingAssistantTimestamp(null);
@@ -86,60 +195,94 @@ async function executeSendRun(params: {
86
195
  });
87
196
  setters.setIsSending(true);
88
197
  setters.setIsAwaitingAssistantOutput(true);
198
+ setters.setCanStopCurrentRun(false);
199
+ setters.setStopDisabledReason(item.stopSupported ? '__preparing__' : item.stopReason ?? null);
200
+ setters.setLastSendError(null);
89
201
 
202
+ let streamText = '';
90
203
  try {
91
- let streamText = '';
204
+ let hasAssistantSessionEvent = false;
92
205
  const streamTimestamp = new Date().toISOString();
93
206
  setters.setStreamingAssistantTimestamp(streamTimestamp);
94
207
 
95
- const result = await sendChatTurnStream({
96
- message: item.message,
97
- sessionKey: item.sessionKey,
98
- agentId: item.agentId,
99
- channel: 'ui',
100
- chatId: 'web-ui'
101
- }, {
102
- onReady: (event) => {
103
- if (runId !== runIdRef.current) {
104
- return;
105
- }
106
- if (event.sessionKey) {
107
- setSelectedSessionKey((prev) => prev === event.sessionKey ? prev : event.sessionKey);
108
- }
208
+ const requestedSkills = normalizeRequestedSkills(item.requestedSkills);
209
+ const result = await sendChatTurnStream(
210
+ {
211
+ message: item.message,
212
+ sessionKey: item.sessionKey,
213
+ agentId: item.agentId,
214
+ ...(item.model ? { model: item.model } : {}),
215
+ ...(requestedSkills.length > 0
216
+ ? {
217
+ metadata: {
218
+ requested_skills: requestedSkills
219
+ }
220
+ }
221
+ : {}),
222
+ channel: 'ui',
223
+ chatId: 'web-ui'
109
224
  },
110
- onDelta: (event) => {
111
- if (runId !== runIdRef.current) {
112
- return;
113
- }
114
- streamText += event.delta;
115
- setters.setStreamingAssistantText(streamText);
116
- setters.setIsAwaitingAssistantOutput(false);
117
- },
118
- onSessionEvent: (event) => {
119
- if (runId !== runIdRef.current) {
120
- return;
121
- }
122
- if (event.data.message?.role === 'user') {
123
- setters.setOptimisticUserEvent(null);
124
- }
125
- setters.setStreamingSessionEvents((prev) => {
126
- const next = [...prev];
127
- const hit = next.findIndex((streamEvent) => streamEvent.seq === event.data.seq);
128
- if (hit >= 0) {
129
- next[hit] = event.data;
130
- } else {
131
- next.push(event.data);
225
+ {
226
+ signal: requestAbortController.signal,
227
+ onReady: (event) => {
228
+ if (runId !== runIdRef.current) {
229
+ return;
132
230
  }
133
- return next;
134
- });
135
- if (event.data.message?.role === 'assistant') {
136
- // Reset delta accumulator once assistant event lands in session timeline.
137
- streamText = '';
138
- setters.setStreamingAssistantText('');
231
+ const activeRun = activeRunRef.current;
232
+ if (activeRun && activeRun.localRunId === runId) {
233
+ activeRun.backendRunId = event.runId?.trim() || undefined;
234
+ if (typeof event.stopSupported === 'boolean') {
235
+ activeRun.backendStopSupported = event.stopSupported;
236
+ }
237
+ if (typeof event.stopReason === 'string' && event.stopReason.trim().length > 0) {
238
+ activeRun.backendStopReason = event.stopReason.trim();
239
+ }
240
+ const canStopNow = Boolean(activeRun.backendStopSupported && activeRun.backendRunId);
241
+ setters.setCanStopCurrentRun(canStopNow);
242
+ setters.setStopDisabledReason(
243
+ canStopNow
244
+ ? null
245
+ : activeRun.backendStopReason ?? (activeRun.backendStopSupported ? '__preparing__' : null)
246
+ );
247
+ }
248
+ if (event.sessionKey) {
249
+ setSelectedSessionKey((prev) => (prev === event.sessionKey ? prev : event.sessionKey));
250
+ }
251
+ },
252
+ onDelta: (event) => {
253
+ if (runId !== runIdRef.current) {
254
+ return;
255
+ }
256
+ streamText += event.delta;
257
+ setters.setStreamingAssistantText(streamText);
139
258
  setters.setIsAwaitingAssistantOutput(false);
259
+ },
260
+ onSessionEvent: (event) => {
261
+ if (runId !== runIdRef.current) {
262
+ return;
263
+ }
264
+ if (event.data.message?.role === 'user') {
265
+ setters.setOptimisticUserEvent(null);
266
+ }
267
+ setters.setStreamingSessionEvents((prev) => {
268
+ const next = [...prev];
269
+ const hit = next.findIndex((streamEvent) => streamEvent.seq === event.data.seq);
270
+ if (hit >= 0) {
271
+ next[hit] = event.data;
272
+ } else {
273
+ next.push(event.data);
274
+ }
275
+ return next;
276
+ });
277
+ if (event.data.message?.role === 'assistant') {
278
+ hasAssistantSessionEvent = true;
279
+ streamText = '';
280
+ setters.setStreamingAssistantText('');
281
+ setters.setIsAwaitingAssistantOutput(false);
282
+ }
140
283
  }
141
284
  }
142
- });
285
+ );
143
286
  if (runId !== runIdRef.current) {
144
287
  return;
145
288
  }
@@ -147,24 +290,60 @@ async function executeSendRun(params: {
147
290
  if (result.sessionKey !== item.sessionKey) {
148
291
  setSelectedSessionKey(result.sessionKey);
149
292
  }
150
- await refetchSessions();
151
- const activeSessionKey = selectedSessionKeyRef.current;
152
- if (!activeSessionKey || activeSessionKey === item.sessionKey || activeSessionKey === result.sessionKey) {
153
- await refetchHistory();
154
- }
155
- setters.setStreamingSessionEvents([]);
293
+
294
+ const localAssistantText = !hasAssistantSessionEvent ? streamText.trim() : '';
295
+ await refetchIfSessionVisible({
296
+ selectedSessionKeyRef,
297
+ currentSessionKey: item.sessionKey,
298
+ resultSessionKey: result.sessionKey,
299
+ refetchSessions,
300
+ refetchHistory
301
+ });
302
+
303
+ setters.setStreamingSessionEvents(localAssistantText ? [buildLocalAssistantEvent(localAssistantText)] : []);
304
+
156
305
  setters.setStreamingAssistantText('');
157
306
  setters.setStreamingAssistantTimestamp(null);
158
307
  setters.setIsAwaitingAssistantOutput(false);
159
308
  setters.setIsSending(false);
160
- } catch {
309
+ setters.setCanStopCurrentRun(false);
310
+ setters.setStopDisabledReason(null);
311
+ setters.setLastSendError(null);
312
+ activeRunRef.current = null;
313
+ } catch (error) {
161
314
  if (runId !== runIdRef.current) {
162
315
  return;
163
316
  }
317
+ const wasAborted = requestAbortController.signal.aborted || isAbortLikeError(error);
164
318
  runIdRef.current += 1;
319
+ if (wasAborted) {
320
+ const localAssistantText = streamText.trim();
321
+ setters.setOptimisticUserEvent(null);
322
+ setters.setStreamingAssistantText('');
323
+ setters.setStreamingAssistantTimestamp(null);
324
+ setters.setIsSending(false);
325
+ setters.setIsAwaitingAssistantOutput(false);
326
+ setters.setCanStopCurrentRun(false);
327
+ setters.setStopDisabledReason(null);
328
+ setters.setLastSendError(null);
329
+ activeRunRef.current = null;
330
+ await refetchIfSessionVisible({
331
+ selectedSessionKeyRef,
332
+ currentSessionKey: item.sessionKey,
333
+ refetchSessions,
334
+ refetchHistory
335
+ });
336
+ setters.setStreamingSessionEvents(localAssistantText ? [buildLocalAssistantEvent(localAssistantText)] : []);
337
+ return;
338
+ }
339
+
165
340
  clearStreamingState(setters);
341
+ const sendError = formatSendError(error);
342
+ setters.setLastSendError(sendError);
343
+ setters.setStreamingSessionEvents([buildLocalAssistantEvent(sendError, 'message.assistant.error.local')]);
344
+ activeRunRef.current = null;
166
345
  if (restoreDraftOnError) {
167
- setDraft((prev) => prev.trim().length === 0 ? item.message : prev);
346
+ setDraft((prev) => (prev.trim().length === 0 ? item.message : prev));
168
347
  }
169
348
  }
170
349
  }
@@ -177,36 +356,49 @@ export function useChatStreamController(params: UseChatStreamControllerParams) {
177
356
  const [isSending, setIsSending] = useState(false);
178
357
  const [isAwaitingAssistantOutput, setIsAwaitingAssistantOutput] = useState(false);
179
358
  const [queuedMessages, setQueuedMessages] = useState<PendingChatMessage[]>([]);
359
+ const [canStopCurrentRun, setCanStopCurrentRun] = useState(false);
360
+ const [stopDisabledReason, setStopDisabledReason] = useState<string | null>(null);
361
+ const [lastSendError, setLastSendError] = useState<string | null>(null);
180
362
 
181
363
  const streamRunIdRef = useRef(0);
182
364
  const queueIdRef = useRef(0);
365
+ const activeRunRef = useRef<ActiveRunState | null>(null);
183
366
 
184
367
  const resetStreamState = useCallback(() => {
185
368
  streamRunIdRef.current += 1;
186
369
  setQueuedMessages([]);
370
+ activeRunRef.current?.requestAbortController.abort();
371
+ activeRunRef.current = null;
187
372
  clearStreamingState({
188
373
  setOptimisticUserEvent,
189
374
  setStreamingSessionEvents,
190
375
  setStreamingAssistantText,
191
376
  setStreamingAssistantTimestamp,
192
377
  setIsSending,
193
- setIsAwaitingAssistantOutput
378
+ setIsAwaitingAssistantOutput,
379
+ setCanStopCurrentRun,
380
+ setStopDisabledReason,
381
+ setLastSendError
194
382
  });
195
383
  }, []);
196
384
 
197
385
  useEffect(() => {
198
386
  return () => {
199
387
  streamRunIdRef.current += 1;
388
+ activeRunRef.current?.requestAbortController.abort();
389
+ activeRunRef.current = null;
200
390
  };
201
391
  }, []);
202
392
 
203
393
  const runSend = useCallback(
204
394
  async (item: PendingChatMessage, options?: { restoreDraftOnError?: boolean }) => {
395
+ setLastSendError(null);
205
396
  streamRunIdRef.current += 1;
206
397
  await executeSendRun({
207
398
  item,
208
399
  runId: streamRunIdRef.current,
209
400
  runIdRef: streamRunIdRef,
401
+ activeRunRef,
210
402
  nextOptimisticUserSeq: params.nextOptimisticUserSeq,
211
403
  selectedSessionKeyRef: params.selectedSessionKeyRef,
212
404
  setSelectedSessionKey: params.setSelectedSessionKey,
@@ -220,7 +412,10 @@ export function useChatStreamController(params: UseChatStreamControllerParams) {
220
412
  setStreamingAssistantText,
221
413
  setStreamingAssistantTimestamp,
222
414
  setIsSending,
223
- setIsAwaitingAssistantOutput
415
+ setIsAwaitingAssistantOutput,
416
+ setCanStopCurrentRun,
417
+ setStopDisabledReason,
418
+ setLastSendError
224
419
  }
225
420
  });
226
421
  },
@@ -238,12 +433,19 @@ export function useChatStreamController(params: UseChatStreamControllerParams) {
238
433
 
239
434
  const sendMessage = useCallback(
240
435
  async (payload: SendMessageParams) => {
436
+ setLastSendError(null);
241
437
  queueIdRef.current += 1;
242
438
  const item: PendingChatMessage = {
243
439
  id: queueIdRef.current,
244
440
  message: payload.message,
245
441
  sessionKey: payload.sessionKey,
246
- agentId: payload.agentId
442
+ agentId: payload.agentId,
443
+ ...(payload.model ? { model: payload.model } : {}),
444
+ ...(payload.requestedSkills && payload.requestedSkills.length > 0
445
+ ? { requestedSkills: payload.requestedSkills }
446
+ : {}),
447
+ ...(typeof payload.stopSupported === 'boolean' ? { stopSupported: payload.stopSupported } : {}),
448
+ ...(payload.stopReason ? { stopReason: payload.stopReason } : {})
247
449
  };
248
450
  if (isSending) {
249
451
  setQueuedMessages((prev) => [...prev, item]);
@@ -254,6 +456,31 @@ export function useChatStreamController(params: UseChatStreamControllerParams) {
254
456
  [isSending, runSend]
255
457
  );
256
458
 
459
+ const stopCurrentRun = useCallback(async () => {
460
+ const activeRun = activeRunRef.current;
461
+ if (!activeRun) {
462
+ return;
463
+ }
464
+ if (!activeRun.backendStopSupported) {
465
+ return;
466
+ }
467
+
468
+ setCanStopCurrentRun(false);
469
+ setQueuedMessages([]);
470
+ if (activeRun.backendRunId) {
471
+ try {
472
+ await stopChatTurn({
473
+ runId: activeRun.backendRunId,
474
+ sessionKey: activeRun.sessionKey,
475
+ agentId: activeRun.agentId
476
+ });
477
+ } catch {
478
+ // Keep local abort as fallback even if stop API fails.
479
+ }
480
+ }
481
+ activeRun.requestAbortController.abort();
482
+ }, []);
483
+
257
484
  return {
258
485
  optimisticUserEvent,
259
486
  streamingSessionEvents,
@@ -262,7 +489,11 @@ export function useChatStreamController(params: UseChatStreamControllerParams) {
262
489
  isSending,
263
490
  isAwaitingAssistantOutput,
264
491
  queuedCount: queuedMessages.length,
492
+ canStopCurrentRun,
493
+ stopDisabledReason,
494
+ lastSendError,
265
495
  sendMessage,
496
+ stopCurrentRun,
266
497
  resetStreamState
267
498
  };
268
499
  }
@@ -371,7 +371,7 @@ export function ChannelForm({ channelName }: ChannelFormProps) {
371
371
  </div>
372
372
 
373
373
  <form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
374
- <div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-6 py-5">
374
+ <div className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain px-6 py-5">
375
375
  {fields.map((field) => {
376
376
  const hint = channelName
377
377
  ? hintForPath(`channels.${channelName}.${field.name}`, uiHints)
@@ -74,10 +74,10 @@ export function ChannelsList() {
74
74
  }
75
75
 
76
76
  return (
77
- <PageLayout>
77
+ <PageLayout className="xl:flex xl:h-full xl:min-h-0 xl:flex-col xl:pb-0">
78
78
  <PageHeader title={t('channelsPageTitle')} description={t('channelsPageDescription')} />
79
79
 
80
- <div className={CONFIG_SPLIT_GRID_CLASS}>
80
+ <div className={cn(CONFIG_SPLIT_GRID_CLASS, 'xl:min-h-0 xl:flex-1')}>
81
81
  <section className={CONFIG_SIDEBAR_CARD_CLASS}>
82
82
  <div className="border-b border-gray-100 px-4 pt-4">
83
83
  <Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="mb-0" />
@@ -95,7 +95,7 @@ export function ChannelsList() {
95
95
  </div>
96
96
  </div>
97
97
 
98
- <div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
98
+ <div className="min-h-0 flex-1 space-y-2 overflow-y-auto overscroll-contain p-3">
99
99
  {filteredChannels.map((channel) => {
100
100
  const channelConfig = config.channels[channel.name];
101
101
  const enabled = channelConfig?.enabled || false;
@@ -8,74 +8,17 @@ import { SearchableModelInput } from '@/components/common/SearchableModelInput';
8
8
  import { useConfig, useConfigMeta, useConfigSchema, useUpdateModel } from '@/hooks/useConfig';
9
9
  import { hintForPath } from '@/lib/config-hints';
10
10
  import { t } from '@/lib/i18n';
11
+ import {
12
+ buildProviderModelCatalog,
13
+ composeProviderModel,
14
+ findProviderByModel,
15
+ toProviderLocalModel
16
+ } from '@/lib/provider-models';
11
17
  import { PageLayout, PageHeader } from '@/components/layout/page-layout';
12
18
  import { DOCS_DEFAULT_BASE_URL } from '@/components/doc-browser/DocBrowserContext';
13
19
  import { BookOpen, Folder, Loader2, Sparkles } from 'lucide-react';
14
20
  import { useEffect, useMemo, useState } from 'react';
15
21
 
16
- function normalizeStringList(input: string[] | null | undefined): string[] {
17
- if (!input || input.length === 0) {
18
- return [];
19
- }
20
- const deduped = new Set<string>();
21
- for (const item of input) {
22
- const trimmed = item.trim();
23
- if (trimmed) {
24
- deduped.add(trimmed);
25
- }
26
- }
27
- return [...deduped];
28
- }
29
-
30
- function stripProviderPrefix(model: string, prefix: string): string {
31
- const trimmed = model.trim();
32
- const cleanPrefix = prefix.trim();
33
- if (!trimmed || !cleanPrefix) {
34
- return trimmed;
35
- }
36
- const withSlash = `${cleanPrefix}/`;
37
- if (trimmed.startsWith(withSlash)) {
38
- return trimmed.slice(withSlash.length);
39
- }
40
- return trimmed;
41
- }
42
-
43
- function toProviderLocalModel(model: string, aliases: string[]): string {
44
- let normalized = model.trim();
45
- if (!normalized) {
46
- return '';
47
- }
48
- for (const alias of aliases) {
49
- normalized = stripProviderPrefix(normalized, alias);
50
- }
51
- return normalized.trim();
52
- }
53
-
54
- function findProviderByModel(
55
- model: string,
56
- providerCatalog: Array<{ name: string; aliases: string[] }>
57
- ): string | null {
58
- const trimmed = model.trim();
59
- if (!trimmed) {
60
- return null;
61
- }
62
- let bestMatch: { name: string; score: number } | null = null;
63
- for (const provider of providerCatalog) {
64
- for (const alias of provider.aliases) {
65
- const cleanAlias = alias.trim();
66
- if (!cleanAlias) {
67
- continue;
68
- }
69
- if (trimmed === cleanAlias || trimmed.startsWith(`${cleanAlias}/`)) {
70
- if (!bestMatch || cleanAlias.length > bestMatch.score) {
71
- bestMatch = { name: provider.name, score: cleanAlias.length };
72
- }
73
- }
74
- }
75
- }
76
- return bestMatch?.name ?? null;
77
- }
78
-
79
22
  export function ModelConfig() {
80
23
  const { data: config, isLoading } = useConfig();
81
24
  const { data: meta } = useConfigMeta();
@@ -89,25 +32,10 @@ export function ModelConfig() {
89
32
  const modelHint = hintForPath('agents.defaults.model', uiHints);
90
33
  const workspaceHint = hintForPath('agents.defaults.workspace', uiHints);
91
34
 
92
- const providerCatalog = useMemo(() => {
93
- return (meta?.providers ?? []).map((provider) => {
94
- const prefix = (provider.modelPrefix || provider.name || '').trim();
95
- const aliases = normalizeStringList([provider.modelPrefix || '', provider.name || '']);
96
- const defaultModels = normalizeStringList((provider.defaultModels ?? []).map((model) => toProviderLocalModel(model, aliases)));
97
- const customModels = normalizeStringList(
98
- (config?.providers?.[provider.name]?.models ?? []).map((model) => toProviderLocalModel(model, aliases))
99
- );
100
- const allModels = normalizeStringList([...defaultModels, ...customModels]);
101
- const configDisplayName = config?.providers?.[provider.name]?.displayName?.trim();
102
- return {
103
- name: provider.name,
104
- displayName: configDisplayName || provider.displayName || provider.name,
105
- prefix,
106
- aliases,
107
- models: allModels
108
- };
109
- });
110
- }, [meta, config]);
35
+ const providerCatalog = useMemo(
36
+ () => buildProviderModelCatalog({ meta, config }),
37
+ [config, meta]
38
+ );
111
39
 
112
40
  const providerMap = useMemo(() => new Map(providerCatalog.map((provider) => [provider.name, provider])), [providerCatalog]);
113
41
  const selectedProvider = providerMap.get(providerName) ?? providerCatalog[0];
@@ -151,13 +79,7 @@ export function ModelConfig() {
151
79
  if (!normalizedModelId) {
152
80
  return '';
153
81
  }
154
- if (!selectedProvider) {
155
- return normalizedModelId;
156
- }
157
- if (!selectedProvider.prefix) {
158
- return normalizedModelId;
159
- }
160
- return `${selectedProvider.prefix}/${normalizedModelId}`;
82
+ return composeProviderModel(selectedProvider?.prefix ?? '', normalizedModelId);
161
83
  }, [modelId, selectedProvider, selectedProviderAliases]);
162
84
 
163
85
  const modelHelpText = t('modelIdentifierHelp') || modelHint?.help || '';