@nextclaw/ui 0.11.0 → 0.11.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 (82) hide show
  1. package/CHANGELOG.md +8 -2
  2. package/dist/assets/{ChannelsList-BqsOYnXz.js → ChannelsList-CVPqrxns.js} +4 -4
  3. package/dist/assets/ChatPage-BO1VUrAY.js +37 -0
  4. package/dist/assets/{DocBrowser-BmL0QXBZ.js → DocBrowser-FBwg8iji.js} +1 -1
  5. package/dist/assets/{LogoBadge-C1HiPZPf.js → LogoBadge-BCmJfRT8.js} +1 -1
  6. package/dist/assets/MarketplacePage-DWxXUOCx.js +49 -0
  7. package/dist/assets/{McpMarketplacePage-CLHFnNBd.js → McpMarketplacePage-Bth9X_hu.js} +2 -2
  8. package/dist/assets/{ModelConfig-LQSR58tc.js → ModelConfig-PkSp_ioc.js} +1 -1
  9. package/dist/assets/ProvidersList-DVDge8wa.js +1 -0
  10. package/dist/assets/RemoteAccessPage-BVkzfEaL.js +1 -0
  11. package/dist/assets/RuntimeConfig-ByJs3khh.js +1 -0
  12. package/dist/assets/{SearchConfig-Chzo_JGs.js → SearchConfig-KZUAqYJN.js} +1 -1
  13. package/dist/assets/{SecretsConfig-CEIbjZYA.js → SecretsConfig-qwB_Y_Ka.js} +2 -2
  14. package/dist/assets/SessionsConfig-CGCl4UTr.js +2 -0
  15. package/dist/assets/index-CrilScMo.css +1 -0
  16. package/dist/assets/{index-j6A_-1b6.js → index-D41ntvb7.js} +6 -6
  17. package/dist/assets/{label-GACO2RzW.js → label-7JEFhkur.js} +1 -1
  18. package/dist/assets/ncp-session-adapter-BOqhkrc-.js +1 -0
  19. package/dist/assets/{page-layout-DjXaK3A3.js → page-layout-B7q511TE.js} +1 -1
  20. package/dist/assets/popover-CywJGmPr.js +1 -0
  21. package/dist/assets/security-config-zi2UxN5r.js +1 -0
  22. package/dist/assets/skeleton-qUJZQ03S.js +1 -0
  23. package/dist/assets/{status-dot-IWEBezqb.js → status-dot-BilwNdTT.js} +1 -1
  24. package/dist/assets/{switch-DCHAJSrA.js → switch-BLp2Pno1.js} +1 -1
  25. package/dist/assets/tabs-custom-CgIdQMGC.js +1 -0
  26. package/dist/assets/useConfirmDialog-BitswAkv.js +1 -0
  27. package/dist/assets/{vendor-CNhxtHCf.js → vendor-D_JxmsLV.js} +87 -87
  28. package/dist/index.html +3 -3
  29. package/package.json +4 -4
  30. package/src/App.test.tsx +42 -10
  31. package/src/App.tsx +5 -40
  32. package/src/api/api-base.test.ts +37 -0
  33. package/src/api/api-base.ts +0 -4
  34. package/src/api/config.ts +2 -270
  35. package/src/api/types.ts +0 -117
  36. package/src/components/chat/ChatPage.tsx +1 -11
  37. package/src/components/chat/ChatSidebar.test.tsx +1 -50
  38. package/src/components/chat/ChatSidebar.tsx +0 -5
  39. package/src/components/chat/README.md +2 -0
  40. package/src/components/chat/chat-attachment-upload-limit.test.ts +41 -0
  41. package/src/components/chat/chat-session-display.ts +9 -0
  42. package/src/components/chat/chat-session-label.service.ts +3 -12
  43. package/src/components/chat/chat-session-preference-sync.test.ts +10 -13
  44. package/src/components/chat/chat-stream/types.ts +4 -57
  45. package/src/components/chat/ncp/NcpChatPage.tsx +3 -3
  46. package/src/components/chat/useHydratedNcpAgent.test.tsx +77 -0
  47. package/src/components/config/README.md +2 -0
  48. package/src/components/config/SessionsConfig.tsx +152 -132
  49. package/src/hooks/use-auth.test.ts +3 -3
  50. package/src/hooks/use-auth.ts +16 -4
  51. package/src/hooks/use-realtime-query-bridge.ts +0 -24
  52. package/src/hooks/useConfig.ts +10 -137
  53. package/src/lib/session-run-status.ts +1 -63
  54. package/src/vite-env.d.ts +1 -0
  55. package/vite.config.ts +4 -4
  56. package/dist/assets/ChatPage-CJBYKR-Y.js +0 -38
  57. package/dist/assets/MarketplacePage-BIRP0NRS.js +0 -49
  58. package/dist/assets/ProvidersList-CwI-mxah.js +0 -1
  59. package/dist/assets/RemoteAccessPage-Cw5BqZb6.js +0 -1
  60. package/dist/assets/RuntimeConfig-DbowSRAb.js +0 -1
  61. package/dist/assets/SessionsConfig-BR8GfGWL.js +0 -2
  62. package/dist/assets/chat-message-CPG7zxRR.js +0 -3
  63. package/dist/assets/index-kaPUhd-8.css +0 -1
  64. package/dist/assets/popover-DTaFiTmU.js +0 -1
  65. package/dist/assets/security-config-Dk-yoKvK.js +0 -1
  66. package/dist/assets/skeleton-Dm2xOBSA.js +0 -1
  67. package/dist/assets/tabs-custom-DKSbDSB9.js +0 -1
  68. package/dist/assets/useConfirmDialog-ByJ8A8n7.js +0 -1
  69. package/src/api/config.stream.test.ts +0 -115
  70. package/src/components/chat/chat-chain.test.ts +0 -22
  71. package/src/components/chat/chat-chain.ts +0 -23
  72. package/src/components/chat/chat-page-data.ts +0 -171
  73. package/src/components/chat/chat-page-runtime.ts +0 -190
  74. package/src/components/chat/chat-stream/nextbot-parsers.ts +0 -52
  75. package/src/components/chat/chat-stream/nextbot-runtime-agent.ts +0 -413
  76. package/src/components/chat/chat-stream/stream-event-adapter.ts +0 -98
  77. package/src/components/chat/chat-stream/transport.ts +0 -253
  78. package/src/components/chat/legacy/LegacyChatPage.tsx +0 -223
  79. package/src/components/chat/managers/chat-input.manager.ts +0 -228
  80. package/src/components/chat/managers/chat-thread.manager.ts +0 -87
  81. package/src/components/chat/presenter/chat.presenter.ts +0 -32
  82. package/src/components/chat/useChatRuntimeController.ts +0 -134
@@ -1,19 +1,15 @@
1
1
  import { useEffect, useMemo, useState } from 'react';
2
- import type { SessionEntryView, SessionMessageView } from '@/api/types';
2
+ import type { NcpMessageView, NcpSessionSummaryView, SessionEntryView } from '@/api/types';
3
3
  import { useConfirmDialog } from '@/hooks/useConfirmDialog';
4
- import { useChatRuns, useDeleteSession, useSessionHistory, useSessions, useUpdateSession } from '@/hooks/useConfig';
4
+ import { useDeleteNcpSession, useNcpSessionMessages, useNcpSessions, useUpdateNcpSession } from '@/hooks/useConfig';
5
5
  import { Button } from '@/components/ui/button';
6
6
  import { Input } from '@/components/ui/input';
7
7
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
8
8
  import { SessionRunBadge } from '@/components/common/SessionRunBadge';
9
+ import { adaptNcpSessionSummaries } from '@/components/chat/ncp/ncp-session-adapter';
10
+ import { sessionDisplayName } from '@/components/chat/chat-session-display';
9
11
  import { cn } from '@/lib/utils';
10
12
  import { formatDateShort, formatDateTime, t } from '@/lib/i18n';
11
- import { extractMessageText } from '@/lib/chat-message';
12
- import {
13
- buildActiveRunBySessionKey,
14
- buildSessionRunStatusByKey,
15
- type SessionRunStatus
16
- } from '@/lib/session-run-status';
17
13
  import { PageLayout, PageHeader } from '@/components/layout/page-layout';
18
14
  import { RefreshCw, Search, Clock, Inbox, Hash, Bot, User, MessageCircle, Settings as SettingsIcon } from 'lucide-react';
19
15
 
@@ -39,83 +35,107 @@ function displayChannelName(channel: string): string {
39
35
  return channel;
40
36
  }
41
37
 
42
- // ============================================================================
43
- // COMPONENT: Left Sidebar Session Item
44
- // ============================================================================
38
+ function normalizeNcpRole(role: NcpMessageView['role']): 'user' | 'assistant' | 'system' | 'tool' {
39
+ if (role === 'service') {
40
+ return 'system';
41
+ }
42
+ if (role === 'tool') {
43
+ return 'tool';
44
+ }
45
+ return role;
46
+ }
47
+
48
+ function extractNcpMessageText(message: NcpMessageView): string {
49
+ const parts: string[] = [];
50
+ for (const part of message.parts) {
51
+ if (part.type === 'text' || part.type === 'rich-text' || part.type === 'reasoning') {
52
+ parts.push(part.text);
53
+ continue;
54
+ }
55
+ if (part.type === 'tool-invocation') {
56
+ const prefix = part.toolName?.trim() ? `[${part.toolName.trim()}]` : '[tool]';
57
+ if (part.state === 'result' && typeof part.result === 'string' && part.result.trim()) {
58
+ parts.push(`${prefix} ${part.result.trim()}`);
59
+ continue;
60
+ }
61
+ parts.push(prefix);
62
+ }
63
+ }
64
+ return parts.join('\n').trim();
65
+ }
45
66
 
46
67
  type SessionListItemProps = {
47
68
  session: SessionEntryView;
69
+ summary: NcpSessionSummaryView;
48
70
  channel: string;
49
- runStatus?: SessionRunStatus;
50
71
  isSelected: boolean;
51
72
  onSelect: () => void;
52
73
  };
53
74
 
54
- function SessionListItem({ session, channel, runStatus, isSelected, onSelect }: SessionListItemProps) {
75
+ function SessionListItem({ session, summary, channel, isSelected, onSelect }: SessionListItemProps) {
55
76
  const channelDisplay = displayChannelName(channel);
56
- const displayName = session.label || session.key.split(':').pop() || session.key;
77
+ const displayName = sessionDisplayName(session);
57
78
 
58
79
  return (
59
80
  <button
60
81
  onClick={onSelect}
61
82
  className={cn(
62
- "w-full text-left p-3.5 rounded-xl transition-all duration-200 outline-none focus:outline-none focus:ring-0 group",
83
+ 'w-full text-left p-3.5 rounded-xl transition-all duration-200 outline-none focus:outline-none focus:ring-0 group',
63
84
  isSelected
64
- ? "bg-brand-50 border border-brand-100/50"
65
- : "bg-transparent border border-transparent hover:bg-gray-50/80"
85
+ ? 'bg-brand-50 border border-brand-100/50'
86
+ : 'bg-transparent border border-transparent hover:bg-gray-50/80'
66
87
  )}
67
88
  >
68
89
  <div className="flex items-start justify-between mb-1.5">
69
- <div className={cn("font-semibold truncate pr-2 flex-1 text-sm", isSelected ? "text-brand-800" : "text-gray-900")}>
90
+ <div className={cn('font-semibold truncate pr-2 flex-1 text-sm', isSelected ? 'text-brand-800' : 'text-gray-900')}>
70
91
  {displayName}
71
92
  </div>
72
- <div className={cn("text-[10px] font-bold px-2 py-0.5 rounded-full shrink-0 capitalize", isSelected ? "bg-white text-brand-600 shadow-[0_1px_2px_rgba(0,0,0,0.02)]" : "bg-gray-100 text-gray-500")}>
93
+ <div className={cn('text-[10px] font-bold px-2 py-0.5 rounded-full shrink-0 capitalize', isSelected ? 'bg-white text-brand-600 shadow-[0_1px_2px_rgba(0,0,0,0.02)]' : 'bg-gray-100 text-gray-500')}>
73
94
  {channelDisplay}
74
95
  </div>
75
96
  </div>
76
97
 
77
- <div className={cn("flex items-center text-xs justify-between mt-2 font-medium", isSelected ? "text-brand-600/80" : "text-gray-400")}>
98
+ <div className={cn('flex items-center text-xs justify-between mt-2 font-medium', isSelected ? 'text-brand-600/80' : 'text-gray-400')}>
78
99
  <div className="flex items-center gap-1.5">
79
100
  <span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
80
- {runStatus ? <SessionRunBadge status={runStatus} /> : null}
101
+ {summary.status === 'running' ? <SessionRunBadge status="running" /> : null}
81
102
  </span>
82
103
  <Clock className="w-3.5 h-3.5 opacity-70" />
83
- <span className="truncate max-w-[100px]">{formatDateShort(session.updatedAt)}</span>
104
+ <span className="truncate max-w-[100px]">{formatDateShort(summary.updatedAt)}</span>
84
105
  </div>
85
106
  <div className="flex items-center gap-1">
86
107
  <MessageCircle className="w-3.5 h-3.5 opacity-70" />
87
- <span>{session.messageCount}</span>
108
+ <span>{summary.messageCount}</span>
88
109
  </div>
89
110
  </div>
90
111
  </button>
91
112
  );
92
113
  }
93
114
 
94
- // ============================================================================
95
- // COMPONENT: Right Side Chat Bubble Message Item
96
- // ============================================================================
97
-
98
- function SessionMessageBubble({ message }: { message: SessionMessageView }) {
99
- const isUser = message.role.toLowerCase() === 'user';
100
- const content = extractMessageText(message.content).trim();
115
+ function SessionMessageBubble({ message }: { message: NcpMessageView }) {
116
+ const role = normalizeNcpRole(message.role);
117
+ const isUser = role === 'user';
118
+ const content = extractNcpMessageText(message);
101
119
 
102
120
  return (
103
- <div className={cn("flex w-full mb-6", isUser ? "justify-end" : "justify-start")}>
104
- <div className={cn(
105
- "max-w-[85%] rounded-[1.25rem] p-5 flex gap-3 text-sm",
106
- isUser
107
- ? "bg-primary text-white rounded-tr-sm"
108
- : "bg-gray-50 text-gray-800 rounded-tl-sm border border-gray-100/50"
109
- )}>
121
+ <div className={cn('flex w-full mb-6', isUser ? 'justify-end' : 'justify-start')}>
122
+ <div
123
+ className={cn(
124
+ 'max-w-[85%] rounded-[1.25rem] p-5 flex gap-3 text-sm',
125
+ isUser
126
+ ? 'bg-primary text-white rounded-tr-sm'
127
+ : 'bg-gray-50 text-gray-800 rounded-tl-sm border border-gray-100/50'
128
+ )}
129
+ >
110
130
  <div className="shrink-0 pt-0.5">
111
131
  {isUser ? <User className="w-4 h-4 text-primary-100" /> : <Bot className="w-4 h-4 text-gray-400" />}
112
132
  </div>
113
133
  <div className="flex-1 space-y-1 overflow-x-hidden">
114
134
  <div className="flex items-baseline justify-between gap-4 mb-2">
115
- <span className={cn("font-semibold text-xs", isUser ? "text-primary-50" : "text-gray-900 capitalize")}>
116
- {message.role}
135
+ <span className={cn('font-semibold text-xs capitalize', isUser ? 'text-primary-50' : 'text-gray-900')}>
136
+ {role}
117
137
  </span>
118
- <span className={cn("text-[10px]", isUser ? "text-primary-200" : "text-gray-400")}>
138
+ <span className={cn('text-[10px]', isUser ? 'text-primary-200' : 'text-gray-400')}>
119
139
  {formatDate(message.timestamp)}
120
140
  </span>
121
141
  </div>
@@ -128,60 +148,63 @@ function SessionMessageBubble({ message }: { message: SessionMessageView }) {
128
148
  );
129
149
  }
130
150
 
131
- // ============================================================================
132
- // MAIN PAGE COMPONENT
133
- // ============================================================================
134
-
135
151
  export function SessionsConfig() {
136
152
  const [query, setQuery] = useState('');
137
153
  const [limit] = useState(100);
138
- const [activeMinutes] = useState(0);
139
- const [selectedKey, setSelectedKey] = useState<string | null>(null);
154
+ const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
140
155
  const [selectedChannel, setSelectedChannel] = useState<string>('all');
141
-
142
- // Local state drafts for editing the currently selected session
143
156
  const [draftLabel, setDraftLabel] = useState('');
144
157
  const [draftModel, setDraftModel] = useState('');
145
158
  const [isEditingMeta, setIsEditingMeta] = useState(false);
146
159
 
147
- const sessionsParams = useMemo(() => ({ q: query.trim() || undefined, limit, activeMinutes }), [query, limit, activeMinutes]);
148
- const sessionsQuery = useSessions(sessionsParams);
149
- const activeRunsQuery = useChatRuns({ states: ['queued', 'running'], limit: 200 });
150
- const historyQuery = useSessionHistory(selectedKey, 200);
151
-
152
- const updateSession = useUpdateSession();
153
- const deleteSession = useDeleteSession();
160
+ const sessionsQuery = useNcpSessions({ limit });
161
+ const historyQuery = useNcpSessionMessages(selectedSessionId, 300);
162
+ const updateSession = useUpdateNcpSession();
163
+ const deleteSession = useDeleteNcpSession();
154
164
  const { confirm, ConfirmDialog } = useConfirmDialog();
155
165
 
156
- const sessions = useMemo(() => sessionsQuery.data?.sessions ?? [], [sessionsQuery.data?.sessions]);
157
- const activeRunBySessionKey = useMemo(
158
- () => buildActiveRunBySessionKey(activeRunsQuery.data?.runs ?? []),
159
- [activeRunsQuery.data?.runs]
166
+ const sessionSummaries = useMemo(() => sessionsQuery.data?.sessions ?? [], [sessionsQuery.data?.sessions]);
167
+ const sessionEntries = useMemo(() => adaptNcpSessionSummaries(sessionSummaries), [sessionSummaries]);
168
+ const sessionSummaryById = useMemo(() => new Map(sessionSummaries.map((session) => [session.sessionId, session])), [sessionSummaries]);
169
+ const filteredSessions = useMemo(() => {
170
+ const normalizedQuery = query.trim().toLowerCase();
171
+ return sessionEntries.filter((session) => {
172
+ if (selectedChannel !== 'all' && resolveChannelFromSessionKey(session.key) !== selectedChannel) {
173
+ return false;
174
+ }
175
+ if (!normalizedQuery) {
176
+ return true;
177
+ }
178
+ return session.key.toLowerCase().includes(normalizedQuery) || sessionDisplayName(session).toLowerCase().includes(normalizedQuery);
179
+ });
180
+ }, [query, selectedChannel, sessionEntries]);
181
+ const selectedSession = useMemo(
182
+ () => sessionEntries.find((session) => session.key === selectedSessionId) ?? null,
183
+ [selectedSessionId, sessionEntries]
160
184
  );
161
- const sessionRunStatusByKey = useMemo(
162
- () => buildSessionRunStatusByKey(activeRunBySessionKey),
163
- [activeRunBySessionKey]
185
+ const selectedSummary = useMemo(
186
+ () => (selectedSessionId ? sessionSummaryById.get(selectedSessionId) ?? null : null),
187
+ [selectedSessionId, sessionSummaryById]
164
188
  );
165
- const selectedSession = useMemo(() => sessions.find(s => s.key === selectedKey), [sessions, selectedKey]);
166
189
 
167
190
  const channels = useMemo(() => {
168
191
  const set = new Set<string>();
169
- for (const s of sessions) {
170
- set.add(resolveChannelFromSessionKey(s.key));
192
+ for (const session of sessionEntries) {
193
+ set.add(resolveChannelFromSessionKey(session.key));
171
194
  }
172
195
  return Array.from(set).sort((a, b) => {
173
196
  if (a === UNKNOWN_CHANNEL_KEY) return 1;
174
197
  if (b === UNKNOWN_CHANNEL_KEY) return -1;
175
198
  return a.localeCompare(b);
176
199
  });
177
- }, [sessions]);
200
+ }, [sessionEntries]);
178
201
 
179
- const filteredSessions = useMemo(() => {
180
- if (selectedChannel === 'all') return sessions;
181
- return sessions.filter(s => resolveChannelFromSessionKey(s.key) === selectedChannel);
182
- }, [sessions, selectedChannel]);
202
+ useEffect(() => {
203
+ if (selectedSessionId && !sessionSummaryById.has(selectedSessionId)) {
204
+ setSelectedSessionId(null);
205
+ }
206
+ }, [selectedSessionId, sessionSummaryById]);
183
207
 
184
- // Sync draft states when selecting a new session
185
208
  useEffect(() => {
186
209
  if (selectedSession) {
187
210
  setDraftLabel(selectedSession.label || '');
@@ -190,35 +213,23 @@ export function SessionsConfig() {
190
213
  setDraftLabel('');
191
214
  setDraftModel('');
192
215
  }
193
- setIsEditingMeta(false); // Reset editing state when switching sessions
216
+ setIsEditingMeta(false);
194
217
  }, [selectedSession]);
195
218
 
196
219
  const handleSaveMeta = () => {
197
- if (!selectedKey) return;
220
+ if (!selectedSessionId) return;
198
221
  updateSession.mutate({
199
- key: selectedKey,
222
+ sessionId: selectedSessionId,
200
223
  data: {
201
224
  label: draftLabel.trim() || null,
202
225
  preferredModel: draftModel.trim() || null
203
226
  }
204
227
  });
205
- setIsEditingMeta(false); // Close editor on save
206
- };
207
-
208
- const handleClearHistory = async () => {
209
- if (!selectedKey) return;
210
- const confirmed = await confirm({
211
- title: t('sessionsClearHistory') + '?',
212
- variant: 'destructive',
213
- confirmLabel: t('sessionsClearHistory')
214
- });
215
- if (confirmed) {
216
- updateSession.mutate({ key: selectedKey, data: { clearHistory: true } });
217
- }
228
+ setIsEditingMeta(false);
218
229
  };
219
230
 
220
231
  const handleDeleteSession = async () => {
221
- if (!selectedKey) return;
232
+ if (!selectedSessionId) return;
222
233
  const confirmed = await confirm({
223
234
  title: t('sessionsDeleteConfirm') + '?',
224
235
  variant: 'destructive',
@@ -226,9 +237,9 @@ export function SessionsConfig() {
226
237
  });
227
238
  if (confirmed) {
228
239
  deleteSession.mutate(
229
- { key: selectedKey },
240
+ { sessionId: selectedSessionId },
230
241
  {
231
- onSuccess: () => setSelectedKey(null)
242
+ onSuccess: () => setSelectedSessionId(null)
232
243
  }
233
244
  );
234
245
  }
@@ -238,20 +249,20 @@ export function SessionsConfig() {
238
249
  <PageLayout fullHeight>
239
250
  <PageHeader title={t('sessionsPageTitle')} description={t('sessionsPageDescription')} />
240
251
 
241
- {/* Main Mailbox Layout */}
242
252
  <div className="flex-1 flex gap-6 min-h-0 relative">
243
-
244
- {/* LEFT COLUMN: List Card */}
245
253
  <div className="w-[320px] flex flex-col shrink-0 bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
246
-
247
- {/* List Card Header & Toolbar */}
248
254
  <div className="px-4 py-4 border-b border-gray-100 bg-white z-10 shrink-0 space-y-3">
249
255
  <div className="flex items-center justify-between">
250
256
  <span className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider">
251
- {sessions.length} {t('sessionsListTitle')}
257
+ {sessionEntries.length} {t('sessionsListTitle')}
252
258
  </span>
253
- <Button variant="ghost" size="icon" className="h-7 w-7 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100" onClick={() => sessionsQuery.refetch()}>
254
- <RefreshCw className={cn("h-3.5 w-3.5", sessionsQuery.isFetching && "animate-spin")} />
259
+ <Button
260
+ variant="ghost"
261
+ size="icon"
262
+ className="h-7 w-7 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100"
263
+ onClick={() => sessionsQuery.refetch()}
264
+ >
265
+ <RefreshCw className={cn('h-3.5 w-3.5', sessionsQuery.isFetching && 'animate-spin')} />
255
266
  </Button>
256
267
  </div>
257
268
 
@@ -261,8 +272,10 @@ export function SessionsConfig() {
261
272
  </SelectTrigger>
262
273
  <SelectContent className="rounded-xl shadow-lg border-gray-100 max-w-[280px]">
263
274
  <SelectItem value="all" className="rounded-lg text-xs">{t('sessionsAllChannels')}</SelectItem>
264
- {channels.map(c => (
265
- <SelectItem key={c} value={c} className="rounded-lg text-xs truncate pr-6">{displayChannelName(c)}</SelectItem>
275
+ {channels.map((channel) => (
276
+ <SelectItem key={channel} value={channel} className="rounded-lg text-xs truncate pr-6">
277
+ {displayChannelName(channel)}
278
+ </SelectItem>
266
279
  ))}
267
280
  </SelectContent>
268
281
  </Select>
@@ -271,7 +284,7 @@ export function SessionsConfig() {
271
284
  <Search className="h-3.5 w-3.5 absolute left-3 top-2.5 text-gray-400" />
272
285
  <Input
273
286
  value={query}
274
- onChange={(e) => setQuery(e.target.value)}
287
+ onChange={(event) => setQuery(event.target.value)}
275
288
  placeholder={t('sessionsSearchPlaceholder')}
276
289
  className="pl-8 h-8.5 rounded-lg bg-gray-50/50 border-gray-200 focus-visible:bg-white text-xs"
277
290
  />
@@ -287,32 +300,35 @@ export function SessionsConfig() {
287
300
  {t('sessionsEmpty')}
288
301
  </div>
289
302
  ) : (
290
- filteredSessions.map(session => (
291
- <SessionListItem
292
- key={session.key}
293
- session={session}
294
- channel={resolveChannelFromSessionKey(session.key)}
295
- runStatus={sessionRunStatusByKey.get(session.key)}
296
- isSelected={selectedKey === session.key}
297
- onSelect={() => setSelectedKey(session.key)}
298
- />
299
- ))
303
+ filteredSessions.map((session) => {
304
+ const summary = sessionSummaryById.get(session.key);
305
+ if (!summary) {
306
+ return null;
307
+ }
308
+ return (
309
+ <SessionListItem
310
+ key={session.key}
311
+ session={session}
312
+ summary={summary}
313
+ channel={resolveChannelFromSessionKey(session.key)}
314
+ isSelected={selectedSessionId === session.key}
315
+ onSelect={() => setSelectedSessionId(session.key)}
316
+ />
317
+ );
318
+ })
300
319
  )}
301
320
  </div>
302
321
  </div>
303
322
 
304
- {/* RIGHT COLUMN: Detail View Card */}
305
323
  <div className="flex-1 min-w-0 flex flex-col overflow-hidden relative bg-white rounded-2xl shadow-sm border border-gray-200">
306
-
307
324
  {(updateSession.isPending || deleteSession.isPending) && (
308
325
  <div className="absolute top-0 left-0 w-full h-1 bg-primary/20 overflow-hidden z-20">
309
326
  <div className="h-full bg-primary animate-pulse w-1/3 rounded-r-full" />
310
327
  </div>
311
328
  )}
312
329
 
313
- {selectedKey && selectedSession ? (
330
+ {selectedSessionId && selectedSession && selectedSummary ? (
314
331
  <>
315
- {/* Detail Header / Metdata Editor */}
316
332
  <div className="shrink-0 border-b border-gray-100 bg-white px-8 py-5 z-10 space-y-4">
317
333
  <div className="flex items-center justify-between">
318
334
  <div className="flex items-center gap-4">
@@ -322,26 +338,34 @@ export function SessionsConfig() {
322
338
  <div>
323
339
  <div className="flex items-center gap-2.5 mb-1.5">
324
340
  <h3 className="text-lg font-bold text-gray-900 tracking-tight">
325
- {selectedSession.label || selectedSession.key.split(':').pop() || selectedSession.key}
341
+ {sessionDisplayName(selectedSession)}
326
342
  </h3>
327
343
  <span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-gray-100 text-gray-500 uppercase tracking-widest">
328
344
  {displayChannelName(resolveChannelFromSessionKey(selectedSession.key))}
329
345
  </span>
346
+ {selectedSummary.status === 'running' ? <SessionRunBadge status="running" className="h-4 w-4" /> : null}
330
347
  </div>
331
- <div className="text-xs text-gray-500 font-mono break-all line-clamp-1 opacity-70" title={selectedKey}>
332
- {selectedKey}
348
+ <div className="text-xs text-gray-500 font-mono break-all line-clamp-1 opacity-70" title={selectedSessionId}>
349
+ {selectedSessionId}
333
350
  </div>
334
351
  </div>
335
352
  </div>
336
353
  <div className="flex items-center gap-2 shrink-0">
337
- <Button variant="outline" size="sm" onClick={() => setIsEditingMeta(!isEditingMeta)} className={cn("h-8.5 rounded-lg shadow-none border-gray-200 transition-all text-xs font-semibold", isEditingMeta ? "bg-gray-100 text-gray-900" : "hover:bg-gray-50 hover:text-gray-900")}>
354
+ <Button
355
+ variant="outline"
356
+ size="sm"
357
+ onClick={() => setIsEditingMeta(!isEditingMeta)}
358
+ className={cn('h-8.5 rounded-lg shadow-none border-gray-200 transition-all text-xs font-semibold', isEditingMeta ? 'bg-gray-100 text-gray-900' : 'hover:bg-gray-50 hover:text-gray-900')}
359
+ >
338
360
  <SettingsIcon className="w-3.5 h-3.5 mr-1.5" />
339
361
  {t('sessionsMetadata')}
340
362
  </Button>
341
- <Button variant="outline" size="sm" onClick={handleClearHistory} className="h-8.5 rounded-lg shadow-none hover:bg-gray-50 hover:text-gray-900 border-gray-200 text-xs font-semibold text-gray-500">
342
- {t('sessionsClearHistory')}
343
- </Button>
344
- <Button variant="outline" size="sm" onClick={handleDeleteSession} className="h-8.5 rounded-lg shadow-none hover:bg-red-50 hover:text-red-600 hover:border-red-200 border-gray-200 text-xs font-semibold text-red-500">
363
+ <Button
364
+ variant="outline"
365
+ size="sm"
366
+ onClick={handleDeleteSession}
367
+ className="h-8.5 rounded-lg shadow-none hover:bg-red-50 hover:text-red-600 hover:border-red-200 border-gray-200 text-xs font-semibold text-red-500"
368
+ >
345
369
  {t('delete')}
346
370
  </Button>
347
371
  </div>
@@ -352,13 +376,13 @@ export function SessionsConfig() {
352
376
  <Input
353
377
  placeholder={t('sessionsLabelPlaceholder')}
354
378
  value={draftLabel}
355
- onChange={e => setDraftLabel(e.target.value)}
379
+ onChange={(event) => setDraftLabel(event.target.value)}
356
380
  className="h-8 text-sm bg-white"
357
381
  />
358
382
  <Input
359
383
  placeholder={t('sessionsModelPlaceholder')}
360
384
  value={draftModel}
361
- onChange={e => setDraftModel(e.target.value)}
385
+ onChange={(event) => setDraftModel(event.target.value)}
362
386
  className="h-8 text-sm bg-white"
363
387
  />
364
388
  <Button size="sm" onClick={handleSaveMeta} className="h-8 px-4 shrink-0 shadow-none" disabled={updateSession.isPending}>
@@ -368,10 +392,7 @@ export function SessionsConfig() {
368
392
  )}
369
393
  </div>
370
394
 
371
- {/* Chat History Area */}
372
- <div className="flex-1 overflow-y-auto p-6 relative
373
- [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:bg-gray-300/80 [&::-webkit-scrollbar-thumb]:rounded-full">
374
-
395
+ <div className="flex-1 overflow-y-auto p-6 relative [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:bg-gray-300/80 [&::-webkit-scrollbar-thumb]:rounded-full">
375
396
  {historyQuery.isLoading && (
376
397
  <div className="absolute inset-0 flex items-center justify-center bg-gray-50/50 backdrop-blur-sm z-10">
377
398
  <div className="flex flex-col items-center gap-3 animate-pulse">
@@ -395,14 +416,13 @@ export function SessionsConfig() {
395
416
  )}
396
417
 
397
418
  <div className="max-w-3xl mx-auto">
398
- {(historyQuery.data?.messages ?? []).map((message, idx) => (
399
- <SessionMessageBubble key={`${message.timestamp}-${idx}`} message={message} />
419
+ {(historyQuery.data?.messages ?? []).map((message) => (
420
+ <SessionMessageBubble key={message.id} message={message} />
400
421
  ))}
401
422
  </div>
402
423
  </div>
403
424
  </>
404
425
  ) : (
405
- /* Empty State */
406
426
  <div className="flex-1 flex flex-col items-center justify-center text-gray-400 p-8 h-full bg-white">
407
427
  <div className="w-20 h-20 bg-gray-50 rounded-3xl flex items-center justify-center mb-6 border border-gray-100 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.02)] rotate-3">
408
428
  <Inbox className="h-8 w-8 text-gray-300 -rotate-3" />
@@ -23,11 +23,11 @@ describe('auth status bootstrap retry policy', () => {
23
23
  });
24
24
 
25
25
  it('stops retrying after the bootstrap retry budget is exhausted', () => {
26
- expect(shouldRetryAuthStatusBootstrap(19, new Error('Failed to fetch'))).toBe(true);
27
- expect(shouldRetryAuthStatusBootstrap(20, new Error('Failed to fetch'))).toBe(false);
26
+ expect(shouldRetryAuthStatusBootstrap(39, new Error('Failed to fetch'))).toBe(true);
27
+ expect(shouldRetryAuthStatusBootstrap(40, new Error('Failed to fetch'))).toBe(false);
28
28
  });
29
29
 
30
30
  it('keeps the retry delay short and predictable', () => {
31
- expect(AUTH_STATUS_BOOTSTRAP_RETRY_DELAY_MS).toBe(500);
31
+ expect(AUTH_STATUS_BOOTSTRAP_RETRY_DELAY_MS).toBe(250);
32
32
  });
33
33
  });
@@ -1,4 +1,5 @@
1
1
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2
+ import { useEffect, useState } from 'react';
2
3
  import {
3
4
  fetchAuthStatus,
4
5
  loginAuth,
@@ -7,11 +8,13 @@ import {
7
8
  updateAuthEnabled,
8
9
  updateAuthPassword
9
10
  } from '@/api/config';
11
+ import type { AuthStatusView } from '@/api/types';
10
12
  import { toast } from 'sonner';
11
13
  import { t } from '@/lib/i18n';
12
14
 
13
- const AUTH_STATUS_BOOTSTRAP_MAX_RETRIES = 20;
14
- export const AUTH_STATUS_BOOTSTRAP_RETRY_DELAY_MS = 500;
15
+ const AUTH_STATUS_BOOTSTRAP_MAX_RETRIES = 40;
16
+ const AUTH_STATUS_BOOTSTRAP_TIMEOUT_MS = 400;
17
+ export const AUTH_STATUS_BOOTSTRAP_RETRY_DELAY_MS = 250;
15
18
 
16
19
  export function isTransientAuthStatusBootstrapError(error: unknown): boolean {
17
20
  if (!(error instanceof Error)) {
@@ -40,14 +43,23 @@ export function shouldRetryAuthStatusBootstrap(failureCount: number, error: unkn
40
43
  }
41
44
 
42
45
  export function useAuthStatus() {
43
- return useQuery({
46
+ const [bootstrapSettled, setBootstrapSettled] = useState(false);
47
+ const query = useQuery<AuthStatusView>({
44
48
  queryKey: ['auth-status'],
45
- queryFn: fetchAuthStatus,
49
+ queryFn: () => fetchAuthStatus({ timeoutMs: bootstrapSettled ? 5_000 : AUTH_STATUS_BOOTSTRAP_TIMEOUT_MS }),
46
50
  staleTime: 5_000,
47
51
  retry: shouldRetryAuthStatusBootstrap,
48
52
  retryDelay: AUTH_STATUS_BOOTSTRAP_RETRY_DELAY_MS,
49
53
  refetchOnWindowFocus: true
50
54
  });
55
+
56
+ useEffect(() => {
57
+ if (query.isSuccess && !bootstrapSettled) {
58
+ setBootstrapSettled(true);
59
+ }
60
+ }, [bootstrapSettled, query.isSuccess]);
61
+
62
+ return query;
51
63
  }
52
64
 
53
65
  function invalidateProtectedQueries(queryClient: ReturnType<typeof useQueryClient>): Promise<unknown[]> {
@@ -34,14 +34,11 @@ function invalidateSessionQueries(queryClient: QueryClient | undefined, sessionK
34
34
  if (!queryClient) {
35
35
  return;
36
36
  }
37
- queryClient.invalidateQueries({ queryKey: ['sessions'] });
38
37
  queryClient.invalidateQueries({ queryKey: ['ncp-sessions'] });
39
38
  if (sessionKey && sessionKey.trim().length > 0) {
40
- queryClient.invalidateQueries({ queryKey: ['session-history', sessionKey.trim()] });
41
39
  queryClient.invalidateQueries({ queryKey: ['ncp-session-messages', sessionKey.trim()] });
42
40
  return;
43
41
  }
44
- queryClient.invalidateQueries({ queryKey: ['session-history'] });
45
42
  queryClient.invalidateQueries({ queryKey: ['ncp-session-messages'] });
46
43
  }
47
44
 
@@ -55,23 +52,6 @@ function handleConfigUpdatedEvent(queryClient: QueryClient | undefined, path: st
55
52
  invalidateMarketplaceQueries(queryClient, path);
56
53
  }
57
54
 
58
- function handleRunUpdatedEvent(queryClient: QueryClient | undefined, payload: { run: { sessionKey?: string; runId?: string } }): void {
59
- if (!queryClient) {
60
- return;
61
- }
62
- const { sessionKey, runId } = payload.run;
63
- queryClient.invalidateQueries({ queryKey: ['chat-runs'] });
64
- if (sessionKey) {
65
- queryClient.invalidateQueries({ queryKey: ['sessions'] });
66
- queryClient.invalidateQueries({ queryKey: ['session-history', sessionKey] });
67
- } else {
68
- queryClient.invalidateQueries({ queryKey: ['session-history'] });
69
- }
70
- if (runId) {
71
- queryClient.invalidateQueries({ queryKey: ['chat-run', runId] });
72
- }
73
- }
74
-
75
55
  function handleRealtimeEvent(
76
56
  queryClient: QueryClient | undefined,
77
57
  setConnectionStatus: SetConnectionStatus,
@@ -90,10 +70,6 @@ function handleRealtimeEvent(
90
70
  handleConfigUpdatedEvent(queryClient, configPath);
91
71
  return;
92
72
  }
93
- if (event.type === 'run.updated') {
94
- handleRunUpdatedEvent(queryClient, event.payload);
95
- return;
96
- }
97
73
  if (event.type === 'session.updated') {
98
74
  invalidateSessionQueries(queryClient, event.payload.sessionKey);
99
75
  return;