@nextclaw/ui 0.6.10 → 0.6.12

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 (90) hide show
  1. package/.eslintrc.cjs +10 -0
  2. package/CHANGELOG.md +16 -0
  3. package/dist/assets/ChannelsList-DBDjwf-X.js +1 -0
  4. package/dist/assets/ChatPage-C18sGGk1.js +36 -0
  5. package/dist/assets/DocBrowser-ZOplDEMS.js +1 -0
  6. package/dist/assets/LogoBadge-2LMzEMwe.js +1 -0
  7. package/dist/assets/MarketplacePage-D4JHYcB5.js +49 -0
  8. package/dist/assets/ModelConfig-DZVvdLFq.js +1 -0
  9. package/dist/assets/ProvidersList-Dum31480.js +1 -0
  10. package/dist/assets/{RuntimeConfig-BO6s-ls-.js → RuntimeConfig-4sb3mpkd.js} +1 -1
  11. package/dist/assets/SearchConfig-B4u_MxRG.js +1 -0
  12. package/dist/assets/{SecretsConfig-mayFdxpM.js → SecretsConfig-BQXblZvb.js} +2 -2
  13. package/dist/assets/SessionsConfig-Jk29xjQU.js +2 -0
  14. package/dist/assets/{card-BP5YnL-G.js → card-BekAnCgX.js} +1 -1
  15. package/dist/assets/config-layout-BHnOoweL.js +1 -0
  16. package/dist/assets/index-BXwjfCEO.css +1 -0
  17. package/dist/assets/index-Dl6t70wA.js +8 -0
  18. package/dist/assets/{input-B1D2QX0O.js → input-MMn_Na9q.js} +1 -1
  19. package/dist/assets/{label-DW0j-fXA.js → label-Dg2ydpN0.js} +1 -1
  20. package/dist/assets/{page-layout-Ch-H9gD-.js → page-layout-7K0rcz0I.js} +1 -1
  21. package/dist/assets/session-run-status-CAdjSqeb.js +3 -0
  22. package/dist/assets/{switch-_cZHlGKB.js → switch-DnDMlDVu.js} +1 -1
  23. package/dist/assets/{tabs-custom-ARxqYYjG.js → tabs-custom-khLM8lWj.js} +1 -1
  24. package/dist/assets/{useConfirmDialog-BaU7nIat.js → useConfirmDialog-BYA1XnVU.js} +2 -2
  25. package/dist/assets/{vendor-C--HHaLf.js → vendor-d7E8OgNx.js} +84 -84
  26. package/dist/index.html +3 -3
  27. package/package.json +4 -2
  28. package/src/App.tsx +3 -2
  29. package/src/api/config.ts +212 -200
  30. package/src/api/types.ts +93 -24
  31. package/src/components/chat/ChatConversationPanel.tsx +102 -121
  32. package/src/components/chat/ChatPage.tsx +165 -437
  33. package/src/components/chat/ChatSidebar.tsx +30 -36
  34. package/src/components/chat/ChatThread.tsx +73 -131
  35. package/src/components/chat/chat-input/ChatInputBarView.tsx +82 -0
  36. package/src/components/chat/chat-input/ChatInputBottomToolbar.tsx +71 -0
  37. package/src/components/chat/chat-input/components/ChatInputModelStateHint.tsx +39 -0
  38. package/src/components/chat/chat-input/components/ChatInputSelectedSkillsSection.tsx +31 -0
  39. package/src/components/chat/chat-input/components/ChatInputSlashPanelSection.tsx +112 -0
  40. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputAttachButton.tsx +24 -0
  41. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputModelSelector.tsx +58 -0
  42. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputSendControls.tsx +56 -0
  43. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputSessionTypeSelector.tsx +40 -0
  44. package/src/components/chat/chat-input/useChatInputBarController.ts +313 -0
  45. package/src/components/chat/chat-input.types.ts +15 -0
  46. package/src/components/chat/chat-page-data.ts +121 -0
  47. package/src/components/chat/chat-page-runtime.ts +221 -0
  48. package/src/components/chat/chat-session-route.ts +59 -0
  49. package/src/components/chat/chat-stream/nextbot-parsers.ts +52 -0
  50. package/src/components/chat/chat-stream/nextbot-runtime-agent.ts +413 -0
  51. package/src/components/chat/chat-stream/stream-event-adapter.ts +98 -0
  52. package/src/components/chat/chat-stream/transport.ts +159 -0
  53. package/src/components/chat/chat-stream/types.ts +76 -0
  54. package/src/components/chat/managers/chat-input.manager.ts +142 -0
  55. package/src/components/chat/managers/chat-run-status.manager.ts +32 -0
  56. package/src/components/chat/managers/chat-session-list.manager.ts +77 -0
  57. package/src/components/chat/managers/chat-stream-actions.manager.ts +34 -0
  58. package/src/components/chat/managers/chat-thread.manager.ts +86 -0
  59. package/src/components/chat/managers/chat-ui.manager.ts +65 -0
  60. package/src/components/chat/presenter/chat-presenter-context.tsx +25 -0
  61. package/src/components/chat/presenter/chat.presenter.ts +32 -0
  62. package/src/components/chat/stores/chat-input.store.ts +62 -0
  63. package/src/components/chat/stores/chat-run-status.store.ts +30 -0
  64. package/src/components/chat/stores/chat-session-list.store.ts +34 -0
  65. package/src/components/chat/stores/chat-thread.store.ts +52 -0
  66. package/src/components/chat/useChatRuntimeController.ts +134 -0
  67. package/src/components/chat/useChatSessionTypeState.ts +148 -0
  68. package/src/components/common/MaskedInput.tsx +1 -1
  69. package/src/components/config/SearchConfig.tsx +297 -0
  70. package/src/components/layout/Sidebar.tsx +6 -1
  71. package/src/hooks/useConfig.ts +48 -1
  72. package/src/hooks/useObservable.ts +20 -0
  73. package/src/lib/chat-message.ts +2 -202
  74. package/src/lib/chat-runtime-utils.ts +250 -0
  75. package/src/lib/i18n.ts +31 -0
  76. package/tsconfig.json +2 -1
  77. package/vite.config.ts +2 -1
  78. package/dist/assets/ChannelsList-TyMb5Mgz.js +0 -1
  79. package/dist/assets/ChatPage-CQerYqvy.js +0 -34
  80. package/dist/assets/DocBrowser-CNtrA0ps.js +0 -1
  81. package/dist/assets/LogoBadge-BLqiOM5D.js +0 -1
  82. package/dist/assets/MarketplacePage-CotZxxNe.js +0 -49
  83. package/dist/assets/ModelConfig-CCsQ8KFq.js +0 -1
  84. package/dist/assets/ProvidersList-BYYX5K_g.js +0 -1
  85. package/dist/assets/SessionsConfig-DAIczdBj.js +0 -2
  86. package/dist/assets/index-BUiahmWm.css +0 -1
  87. package/dist/assets/index-D6_5HaDl.js +0 -7
  88. package/dist/assets/session-run-status-BUYsQeWs.js +0 -5
  89. package/src/components/chat/ChatInputBar.tsx +0 -590
  90. package/src/components/chat/useChatStreamController.ts +0 -591
@@ -1,97 +1,20 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
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
- useChatRuns,
12
- } from '@/hooks/useConfig';
13
- import { useMarketplaceInstalled } from '@/hooks/useMarketplace';
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
14
3
  import { useConfirmDialog } from '@/hooks/useConfirmDialog';
15
- import type { ChatModelOption } from '@/components/chat/ChatInputBar';
16
4
  import { ChatSidebar } from '@/components/chat/ChatSidebar';
17
5
  import { ChatConversationPanel } from '@/components/chat/ChatConversationPanel';
18
6
  import { CronConfig } from '@/components/config/CronConfig';
19
7
  import { MarketplacePage } from '@/components/marketplace/MarketplacePage';
20
- import { useChatStreamController } from '@/components/chat/useChatStreamController';
21
- import { buildFallbackEventsFromMessages } from '@/lib/chat-message';
22
- import { buildProviderModelCatalog, composeProviderModel } from '@/lib/provider-models';
23
- import { buildActiveRunBySessionKey, buildSessionRunStatusByKey } from '@/lib/session-run-status';
24
- import { t } from '@/lib/i18n';
8
+ import { useSessionRunStatus } from '@/components/chat/chat-page-runtime';
9
+ import { useChatRuntimeController } from '@/components/chat/useChatRuntimeController';
10
+ import { parseSessionKeyFromRoute, resolveAgentIdFromSessionKey } from '@/components/chat/chat-session-route';
11
+ import { useChatPageData, sessionDisplayName } from '@/components/chat/chat-page-data';
12
+ import { ChatPresenterProvider } from '@/components/chat/presenter/chat-presenter-context';
13
+ import { ChatPresenter } from '@/components/chat/presenter/chat.presenter';
14
+ import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
15
+ import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
25
16
  import { useLocation, useNavigate, useParams } from 'react-router-dom';
26
17
 
27
- const SESSION_ROUTE_PREFIX = 'sid_';
28
-
29
- function resolveAgentIdFromSessionKey(sessionKey: string): string | null {
30
- const match = /^agent:([^:]+):/i.exec(sessionKey.trim());
31
- if (!match) {
32
- return null;
33
- }
34
- const value = match[1]?.trim();
35
- return value ? value : null;
36
- }
37
-
38
- function buildNewSessionKey(agentId: string): string {
39
- const slug = Math.random().toString(36).slice(2, 8);
40
- return `agent:${agentId}:ui:direct:web-${Date.now().toString(36)}${slug}`;
41
- }
42
-
43
- function encodeSessionRouteId(sessionKey: string): string {
44
- const bytes = new TextEncoder().encode(sessionKey);
45
- let binary = '';
46
- for (const byte of bytes) {
47
- binary += String.fromCharCode(byte);
48
- }
49
- const base64 = btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
50
- return `${SESSION_ROUTE_PREFIX}${base64}`;
51
- }
52
-
53
- function decodeSessionRouteId(routeValue: string): string | null {
54
- if (!routeValue.startsWith(SESSION_ROUTE_PREFIX)) {
55
- return null;
56
- }
57
- const encoded = routeValue.slice(SESSION_ROUTE_PREFIX.length).replace(/-/g, '+').replace(/_/g, '/');
58
- const padding = encoded.length % 4 === 0 ? '' : '='.repeat(4 - (encoded.length % 4));
59
- try {
60
- const binary = atob(encoded + padding);
61
- const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
62
- return new TextDecoder().decode(bytes);
63
- } catch {
64
- return null;
65
- }
66
- }
67
-
68
- function parseSessionKeyFromRoute(routeValue?: string): string | null {
69
- if (!routeValue) {
70
- return null;
71
- }
72
- const decodedToken = decodeSessionRouteId(routeValue);
73
- if (decodedToken) {
74
- return decodedToken;
75
- }
76
- try {
77
- return decodeURIComponent(routeValue);
78
- } catch {
79
- return routeValue;
80
- }
81
- }
82
-
83
- function buildSessionPath(sessionKey: string): string {
84
- return `/chat/${encodeSessionRouteId(sessionKey)}`;
85
- }
86
-
87
- function sessionDisplayName(session: SessionEntryView): string {
88
- if (session.label && session.label.trim()) {
89
- return session.label.trim();
90
- }
91
- const chunks = session.key.split(':');
92
- return chunks[chunks.length - 1] || session.key;
93
- }
94
-
95
18
  type MainPanelView = 'chat' | 'cron' | 'skills';
96
19
 
97
20
  type ChatPageProps = {
@@ -106,7 +29,6 @@ type UseSessionSyncParams = {
106
29
  setSelectedSessionKey: Dispatch<SetStateAction<string | null>>;
107
30
  setSelectedAgentId: Dispatch<SetStateAction<string>>;
108
31
  selectedSessionKeyRef: MutableRefObject<string | null>;
109
- isUserScrollingRef: MutableRefObject<boolean>;
110
32
  resetStreamState: () => void;
111
33
  };
112
34
 
@@ -119,7 +41,6 @@ function useChatSessionSync(params: UseSessionSyncParams): void {
119
41
  setSelectedSessionKey,
120
42
  setSelectedAgentId,
121
43
  selectedSessionKeyRef,
122
- isUserScrollingRef,
123
44
  resetStreamState
124
45
  } = params;
125
46
 
@@ -151,62 +72,21 @@ function useChatSessionSync(params: UseSessionSyncParams): void {
151
72
 
152
73
  useEffect(() => {
153
74
  selectedSessionKeyRef.current = selectedSessionKey;
154
- isUserScrollingRef.current = false;
155
- }, [isUserScrollingRef, selectedSessionKey, selectedSessionKeyRef]);
156
- }
157
-
158
- type UseThreadScrollParams = {
159
- threadRef: MutableRefObject<HTMLDivElement | null>;
160
- isUserScrollingRef: MutableRefObject<boolean>;
161
- mergedEvents: SessionEventView[];
162
- isSending: boolean;
163
- };
164
-
165
- function useChatThreadScroll(params: UseThreadScrollParams): { handleScroll: () => void } {
166
- const { threadRef, isUserScrollingRef, mergedEvents, isSending } = params;
167
-
168
- const isNearBottom = useCallback(() => {
169
- const element = threadRef.current;
170
- if (!element) {
171
- return true;
172
- }
173
- const threshold = 50;
174
- return element.scrollHeight - element.scrollTop - element.clientHeight < threshold;
175
- }, [threadRef]);
176
-
177
- const handleScroll = useCallback(() => {
178
- if (isNearBottom()) {
179
- isUserScrollingRef.current = false;
180
- } else {
181
- isUserScrollingRef.current = true;
182
- }
183
- }, [isNearBottom, isUserScrollingRef]);
184
-
185
- useEffect(() => {
186
- const element = threadRef.current;
187
- if (!element || isUserScrollingRef.current) {
188
- return;
189
- }
190
- element.scrollTop = element.scrollHeight;
191
- }, [isSending, isUserScrollingRef, mergedEvents, threadRef]);
192
-
193
- return { handleScroll };
75
+ }, [selectedSessionKey, selectedSessionKeyRef]);
194
76
  }
195
77
 
196
78
  type ChatPageLayoutProps = {
197
79
  view: MainPanelView;
198
- sidebarProps: ComponentProps<typeof ChatSidebar>;
199
- conversationProps: ComponentProps<typeof ChatConversationPanel>;
200
80
  confirmDialog: JSX.Element;
201
81
  };
202
82
 
203
- function ChatPageLayout({ view, sidebarProps, conversationProps, confirmDialog }: ChatPageLayoutProps) {
83
+ function ChatPageLayout({ view, confirmDialog }: ChatPageLayoutProps) {
204
84
  return (
205
85
  <div className="h-full flex">
206
- <ChatSidebar {...sidebarProps} />
86
+ <ChatSidebar />
207
87
 
208
88
  {view === 'chat' ? (
209
- <ChatConversationPanel {...conversationProps} />
89
+ <ChatConversationPanel />
210
90
  ) : (
211
91
  <section className="flex-1 min-h-0 overflow-hidden bg-gradient-to-b from-gray-50/60 to-white">
212
92
  {view === 'cron' ? (
@@ -231,356 +111,204 @@ function ChatPageLayout({ view, sidebarProps, conversationProps, confirmDialog }
231
111
  }
232
112
 
233
113
  export function ChatPage({ view }: ChatPageProps) {
234
- const [query, setQuery] = useState('');
235
- const [draft, setDraft] = useState('');
236
- const [selectedSessionKey, setSelectedSessionKey] = useState<string | null>(null);
237
- const [selectedAgentId, setSelectedAgentId] = useState('main');
238
- const [selectedModel, setSelectedModel] = useState('');
239
- const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
240
-
114
+ const [presenter] = useState(() => new ChatPresenter());
115
+ const query = useChatSessionListStore((state) => state.snapshot.query);
116
+ const selectedSessionKey = useChatSessionListStore((state) => state.snapshot.selectedSessionKey);
117
+ const selectedAgentId = useChatSessionListStore((state) => state.snapshot.selectedAgentId);
118
+ const pendingSessionType = useChatInputStore((state) => state.snapshot.pendingSessionType);
241
119
  const { confirm, ConfirmDialog } = useConfirmDialog();
242
120
  const location = useLocation();
243
121
  const navigate = useNavigate();
244
122
  const { sessionId: routeSessionIdParam } = useParams<{ sessionId?: string }>();
245
123
  const threadRef = useRef<HTMLDivElement | null>(null);
246
- const isUserScrollingRef = useRef(false);
247
124
  const selectedSessionKeyRef = useRef<string | null>(selectedSessionKey);
248
125
  const routeSessionKey = useMemo(
249
126
  () => parseSessionKeyFromRoute(routeSessionIdParam),
250
127
  [routeSessionIdParam]
251
128
  );
252
-
253
- const configQuery = useConfig();
254
- const configMetaQuery = useConfigMeta();
255
- const isProviderStateResolved =
256
- (configQuery.isFetched || configQuery.isSuccess) &&
257
- (configMetaQuery.isFetched || configMetaQuery.isSuccess);
258
- const sessionsQuery = useSessions({ q: query.trim() || undefined, limit: 120, activeMinutes: 0 });
259
- const installedSkillsQuery = useMarketplaceInstalled('skill');
260
- const chatCapabilitiesQuery = useChatCapabilities({
261
- sessionKey: selectedSessionKey,
262
- agentId: selectedAgentId
129
+ const {
130
+ sessionsQuery,
131
+ installedSkillsQuery,
132
+ chatCapabilitiesQuery,
133
+ historyQuery,
134
+ isProviderStateResolved,
135
+ modelOptions,
136
+ sessions,
137
+ skillRecords,
138
+ selectedSession,
139
+ historyMessages,
140
+ sessionTypeOptions,
141
+ defaultSessionType,
142
+ selectedSessionType,
143
+ canEditSessionType,
144
+ sessionTypeUnavailable,
145
+ sessionTypeUnavailableMessage
146
+ } = useChatPageData({
147
+ query,
148
+ selectedSessionKey,
149
+ selectedAgentId,
150
+ pendingSessionType,
151
+ setPendingSessionType: presenter.chatInputManager.setPendingSessionType,
152
+ setSelectedModel: presenter.chatInputManager.setSelectedModel
263
153
  });
264
- const historyQuery = useSessionHistory(selectedSessionKey, 300);
265
- const deleteSession = useDeleteSession();
266
-
267
- const modelOptions = useMemo<ChatModelOption[]>(() => {
268
- const providers = buildProviderModelCatalog({
269
- meta: configMetaQuery.data,
270
- config: configQuery.data,
271
- onlyConfigured: true
272
- });
273
- const seen = new Set<string>();
274
- const options: ChatModelOption[] = [];
275
- for (const provider of providers) {
276
- for (const localModel of provider.models) {
277
- const value = composeProviderModel(provider.prefix, localModel);
278
- if (!value || seen.has(value)) {
279
- continue;
280
- }
281
- seen.add(value);
282
- options.push({
283
- value,
284
- modelLabel: localModel,
285
- providerLabel: provider.displayName
286
- });
287
- }
288
- }
289
- return options.sort((left, right) => {
290
- const providerCompare = left.providerLabel.localeCompare(right.providerLabel);
291
- if (providerCompare !== 0) {
292
- return providerCompare;
293
- }
294
- return left.modelLabel.localeCompare(right.modelLabel);
295
- });
296
- }, [configMetaQuery.data, configQuery.data]);
297
-
298
- const sessions = useMemo(() => sessionsQuery.data?.sessions ?? [], [sessionsQuery.data?.sessions]);
299
- const skillRecords = useMemo(() => installedSkillsQuery.data?.records ?? [], [installedSkillsQuery.data?.records]);
300
-
301
- const selectedSession = useMemo(
302
- () => sessions.find((session) => session.key === selectedSessionKey) ?? null,
303
- [selectedSessionKey, sessions]
304
- );
305
-
306
- useEffect(() => {
307
- if (modelOptions.length === 0) {
308
- setSelectedModel('');
309
- return;
310
- }
311
- setSelectedModel((prev) => {
312
- if (modelOptions.some((option) => option.value === prev)) {
313
- return prev;
314
- }
315
- const sessionPreferred = selectedSession?.preferredModel?.trim();
316
- if (sessionPreferred && modelOptions.some((option) => option.value === sessionPreferred)) {
317
- return sessionPreferred;
318
- }
319
- const fallback = configQuery.data?.agents.defaults.model?.trim();
320
- if (fallback && modelOptions.some((option) => option.value === fallback)) {
321
- return fallback;
322
- }
323
- return modelOptions[0]?.value ?? '';
324
- });
325
- }, [configQuery.data?.agents.defaults.model, modelOptions, selectedSession?.preferredModel]);
326
-
327
- const historyData = historyQuery.data;
328
- const historyMessages = historyData?.messages ?? [];
329
- const historyEvents =
330
- historyData?.events && historyData.events.length > 0
331
- ? historyData.events
332
- : buildFallbackEventsFromMessages(historyMessages);
333
- const nextOptimisticUserSeq = useMemo(
334
- () => historyEvents.reduce((max, event) => (Number.isFinite(event.seq) ? Math.max(max, event.seq) : max), 0) + 1,
335
- [historyEvents]
336
- );
337
-
338
154
  const {
339
- optimisticUserEvent,
340
- streamingSessionEvents,
341
- streamingAssistantText,
342
- streamingAssistantTimestamp,
155
+ uiMessages,
343
156
  isSending,
344
157
  isAwaitingAssistantOutput,
345
- queuedCount,
346
158
  canStopCurrentRun,
347
159
  stopDisabledReason,
348
160
  lastSendError,
349
- sendMessage,
350
- resumeRun,
351
161
  activeBackendRunId,
162
+ sendMessage,
352
163
  stopCurrentRun,
353
- resetStreamState
354
- } = useChatStreamController({
355
- nextOptimisticUserSeq,
356
- selectedSessionKeyRef,
357
- setSelectedSessionKey,
358
- setDraft,
359
- refetchSessions: sessionsQuery.refetch,
360
- refetchHistory: historyQuery.refetch
361
- });
362
-
363
- const sessionStatusRunsQuery = useChatRuns(
364
- view === 'chat'
365
- ? {
366
- states: ['queued', 'running'],
367
- limit: 200
368
- }
369
- : undefined
370
- );
371
- const activeRunBySessionKey = useMemo(
372
- () => buildActiveRunBySessionKey(sessionStatusRunsQuery.data?.runs ?? []),
373
- [sessionStatusRunsQuery.data?.runs]
374
- );
375
- const sessionRunStatusByKey = useMemo(
376
- () => buildSessionRunStatusByKey(activeRunBySessionKey),
377
- [activeRunBySessionKey]
164
+ resumeRun,
165
+ resetStreamState,
166
+ applyHistoryMessages
167
+ } = useChatRuntimeController(
168
+ {
169
+ selectedSessionKeyRef,
170
+ setSelectedSessionKey: presenter.chatSessionListManager.setSelectedSessionKey,
171
+ setDraft: presenter.chatInputManager.setDraft,
172
+ refetchSessions: sessionsQuery.refetch,
173
+ refetchHistory: historyQuery.refetch
174
+ },
175
+ presenter.chatController
378
176
  );
379
- const activeRun = useMemo(() => {
380
- if (!selectedSessionKey) {
381
- return null;
382
- }
383
- return activeRunBySessionKey.get(selectedSessionKey) ?? null;
384
- }, [activeRunBySessionKey, selectedSessionKey]);
385
177
 
178
+ console.log('[ChatPage] uiMessages', { uiMessages, historyMessages });
386
179
  useEffect(() => {
387
- if (view !== 'chat' || !selectedSessionKey || !activeRun) {
388
- return;
389
- }
390
- if (activeBackendRunId === activeRun.runId) {
391
- return;
392
- }
393
- void resumeRun(activeRun);
394
- }, [activeBackendRunId, activeRun, resumeRun, selectedSessionKey, view]);
395
-
396
- const mergedEvents = useMemo(() => {
397
- const bySeq = new Map<number, SessionEventView>();
398
- const append = (event: SessionEventView) => {
399
- if (!Number.isFinite(event.seq)) {
400
- return;
401
- }
402
- bySeq.set(event.seq, event);
403
- };
404
-
405
- historyEvents.forEach(append);
406
- if (optimisticUserEvent) {
407
- append(optimisticUserEvent);
408
- }
409
- streamingSessionEvents.forEach(append);
180
+ presenter.chatStreamActionsManager.bind({
181
+ sendMessage,
182
+ stopCurrentRun,
183
+ resumeRun,
184
+ resetStreamState,
185
+ applyHistoryMessages
186
+ });
187
+ }, [applyHistoryMessages, presenter, resetStreamState, resumeRun, sendMessage, stopCurrentRun]);
410
188
 
411
- const next = [...bySeq.values()].sort((left, right) => left.seq - right.seq);
412
- if (streamingAssistantText.trim()) {
413
- const maxSeq = next.reduce((max, event) => (event.seq > max ? event.seq : max), 0);
414
- next.push({
415
- seq: maxSeq + 1,
416
- type: 'stream.assistant_delta',
417
- timestamp: streamingAssistantTimestamp ?? new Date().toISOString(),
418
- message: {
419
- role: 'assistant',
420
- content: streamingAssistantText,
421
- timestamp: streamingAssistantTimestamp ?? new Date().toISOString()
422
- }
423
- });
424
- }
425
- return next;
426
- }, [historyEvents, optimisticUserEvent, streamingAssistantText, streamingAssistantTimestamp, streamingSessionEvents]);
189
+ const { sessionRunStatusByKey } = useSessionRunStatus({
190
+ view,
191
+ selectedSessionKey,
192
+ activeBackendRunId,
193
+ isLocallyRunning: isSending || Boolean(activeBackendRunId),
194
+ resumeRun: presenter.chatStreamActionsManager.resumeRun
195
+ });
427
196
 
428
197
  useChatSessionSync({
429
198
  view,
430
199
  routeSessionKey,
431
200
  selectedSessionKey,
432
201
  selectedAgentId,
433
- setSelectedSessionKey,
434
- setSelectedAgentId,
202
+ setSelectedSessionKey: presenter.chatSessionListManager.setSelectedSessionKey,
203
+ setSelectedAgentId: presenter.chatSessionListManager.setSelectedAgentId,
435
204
  selectedSessionKeyRef,
436
- isUserScrollingRef,
437
- resetStreamState
438
- });
439
-
440
- const { handleScroll } = useChatThreadScroll({
441
- threadRef,
442
- isUserScrollingRef,
443
- mergedEvents,
444
- isSending
205
+ resetStreamState: presenter.chatStreamActionsManager.resetStreamState
445
206
  });
446
207
 
447
- const createNewSession = useCallback(() => {
448
- resetStreamState();
449
- setSelectedSessionKey(null);
450
- if (location.pathname !== '/chat') {
451
- navigate('/chat');
452
- }
453
- }, [location.pathname, navigate, resetStreamState]);
454
-
455
- const goToProviders = useCallback(() => {
456
- if (location.pathname !== '/providers') {
457
- navigate('/providers');
458
- }
459
- }, [location.pathname, navigate]);
208
+ useEffect(() => {
209
+ presenter.chatStreamActionsManager.applyHistoryMessages(historyMessages, {
210
+ isLoading: historyQuery.isLoading
211
+ });
212
+ }, [historyMessages, historyQuery.isLoading, presenter]);
460
213
 
461
- const handleDeleteSession = useCallback(async () => {
462
- if (!selectedSessionKey) {
463
- return;
464
- }
465
- const confirmed = await confirm({
466
- title: t('chatDeleteSessionConfirm'),
467
- variant: 'destructive',
468
- confirmLabel: t('delete')
214
+ useEffect(() => {
215
+ presenter.chatUiManager.syncState({
216
+ pathname: location.pathname
469
217
  });
470
- if (!confirmed) {
471
- return;
472
- }
473
- deleteSession.mutate(
474
- { key: selectedSessionKey },
475
- {
476
- onSuccess: async () => {
477
- resetStreamState();
478
- setSelectedSessionKey(null);
479
- navigate('/chat', { replace: true });
480
- await sessionsQuery.refetch();
481
- }
482
- }
483
- );
484
- }, [confirm, deleteSession, navigate, resetStreamState, selectedSessionKey, sessionsQuery]);
218
+ presenter.chatUiManager.bindActions({
219
+ navigate,
220
+ confirm
221
+ });
222
+ }, [confirm, location.pathname, navigate, presenter]);
223
+ const currentSessionDisplayName = selectedSession ? sessionDisplayName(selectedSession) : undefined;
485
224
 
486
- const handleSend = useCallback(async () => {
487
- const message = draft.trim();
488
- if (!message) {
489
- return;
490
- }
491
- const requestedSkills = selectedSkills;
225
+ useEffect(() => {
226
+ presenter.chatThreadManager.bindActions({
227
+ refetchSessions: sessionsQuery.refetch
228
+ });
229
+ }, [
230
+ presenter,
231
+ sessionsQuery.refetch,
232
+ ]);
492
233
 
493
- const sessionKey = selectedSessionKey ?? buildNewSessionKey(selectedAgentId);
494
- if (!selectedSessionKey) {
495
- navigate(buildSessionPath(sessionKey), { replace: true });
496
- }
497
- setDraft('');
498
- setSelectedSkills([]);
499
- await sendMessage({
500
- message,
501
- sessionKey,
502
- agentId: selectedAgentId,
503
- model: selectedModel || undefined,
234
+ useEffect(() => {
235
+ presenter.chatInputManager.syncSnapshot({
236
+ isProviderStateResolved,
237
+ defaultSessionType,
238
+ canStopGeneration: canStopCurrentRun,
239
+ stopDisabledReason,
504
240
  stopSupported: chatCapabilitiesQuery.data?.stopSupported ?? false,
505
241
  stopReason: chatCapabilitiesQuery.data?.stopReason,
506
- requestedSkills,
507
- restoreDraftOnError: true
242
+ sendError: lastSendError,
243
+ isSending,
244
+ modelOptions,
245
+ sessionTypeOptions,
246
+ selectedSessionType,
247
+ canEditSessionType,
248
+ sessionTypeUnavailable,
249
+ skillRecords,
250
+ isSkillsLoading: installedSkillsQuery.isLoading
251
+ });
252
+ presenter.chatSessionListManager.syncSnapshot({
253
+ sessions,
254
+ query,
255
+ isLoading: sessionsQuery.isLoading
256
+ });
257
+ presenter.chatRunStatusManager.syncSnapshot({
258
+ sessionRunStatusByKey,
259
+ isLocallyRunning: isSending || Boolean(activeBackendRunId),
260
+ activeBackendRunId
261
+ });
262
+ presenter.chatThreadManager.syncSnapshot({
263
+ isProviderStateResolved,
264
+ modelOptions,
265
+ sessionTypeUnavailable,
266
+ sessionTypeUnavailableMessage,
267
+ selectedSessionKey,
268
+ sessionDisplayName: currentSessionDisplayName,
269
+ canDeleteSession: Boolean(selectedSession),
270
+ threadRef,
271
+ isHistoryLoading: historyQuery.isLoading,
272
+ uiMessages,
273
+ isSending,
274
+ isAwaitingAssistantOutput
508
275
  });
509
276
  }, [
277
+ activeBackendRunId,
278
+ canEditSessionType,
279
+ canStopCurrentRun,
280
+ currentSessionDisplayName,
510
281
  chatCapabilitiesQuery.data?.stopReason,
511
282
  chatCapabilitiesQuery.data?.stopSupported,
512
- draft,
513
- selectedAgentId,
514
- selectedModel,
515
- navigate,
516
- selectedSessionKey,
517
- selectedSkills,
518
- sendMessage
519
- ]);
520
-
521
- const currentSessionDisplayName = selectedSession ? sessionDisplayName(selectedSession) : undefined;
522
- const handleSelectSession = useCallback((nextSessionKey: string) => {
523
- const target = buildSessionPath(nextSessionKey);
524
- if (location.pathname !== target) {
525
- navigate(target);
526
- }
527
- }, [location.pathname, navigate]);
528
-
529
- const sidebarProps: ComponentProps<typeof ChatSidebar> = {
530
- sessions,
531
- sessionRunStatusByKey,
532
- selectedSessionKey,
533
- onSelectSession: handleSelectSession,
534
- onCreateSession: createNewSession,
535
- sessionTitle: sessionDisplayName,
536
- isLoading: sessionsQuery.isLoading,
537
- query,
538
- onQueryChange: setQuery
539
- };
540
-
541
- const conversationProps: ComponentProps<typeof ChatConversationPanel> = {
283
+ defaultSessionType,
284
+ historyQuery.isLoading,
285
+ installedSkillsQuery.isLoading,
286
+ isAwaitingAssistantOutput,
542
287
  isProviderStateResolved,
288
+ isSending,
289
+ lastSendError,
290
+ uiMessages,
543
291
  modelOptions,
544
- selectedModel,
545
- onSelectedModelChange: setSelectedModel,
546
- onGoToProviders: goToProviders,
547
- skillRecords,
548
- isSkillsLoading: installedSkillsQuery.isLoading,
549
- selectedSkills,
550
- onSelectedSkillsChange: setSelectedSkills,
292
+ presenter,
293
+ query,
294
+ selectedSession,
551
295
  selectedSessionKey,
552
- sessionDisplayName: currentSessionDisplayName,
553
- canDeleteSession: Boolean(selectedSession),
554
- isDeletePending: deleteSession.isPending,
555
- onDeleteSession: () => {
556
- void handleDeleteSession();
557
- },
558
- onCreateSession: createNewSession,
559
- threadRef,
560
- onThreadScroll: handleScroll,
561
- isHistoryLoading: historyQuery.isLoading,
562
- mergedEvents,
563
- isSending,
564
- isAwaitingAssistantOutput,
565
- streamingAssistantText,
566
- draft,
567
- onDraftChange: setDraft,
568
- onSend: handleSend,
569
- onStop: () => {
570
- void stopCurrentRun();
571
- },
572
- canStopGeneration: canStopCurrentRun,
296
+ selectedAgentId,
297
+ selectedSessionType,
298
+ sessionRunStatusByKey,
299
+ sessionTypeOptions,
300
+ sessionTypeUnavailable,
301
+ sessionTypeUnavailableMessage,
302
+ sessions,
303
+ sessionsQuery.isLoading,
573
304
  stopDisabledReason,
574
- sendError: lastSendError,
575
- queuedCount
576
- };
305
+ threadRef,
306
+ skillRecords
307
+ ]);
577
308
 
578
309
  return (
579
- <ChatPageLayout
580
- view={view}
581
- sidebarProps={sidebarProps}
582
- conversationProps={conversationProps}
583
- confirmDialog={<ConfirmDialog />}
584
- />
310
+ <ChatPresenterProvider presenter={presenter}>
311
+ <ChatPageLayout view={view} confirmDialog={<ConfirmDialog />} />
312
+ </ChatPresenterProvider>
585
313
  );
586
314
  }