@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.
- package/CHANGELOG.md +8 -2
- package/dist/assets/{ChannelsList-BqsOYnXz.js → ChannelsList-CVPqrxns.js} +4 -4
- package/dist/assets/ChatPage-BO1VUrAY.js +37 -0
- package/dist/assets/{DocBrowser-BmL0QXBZ.js → DocBrowser-FBwg8iji.js} +1 -1
- package/dist/assets/{LogoBadge-C1HiPZPf.js → LogoBadge-BCmJfRT8.js} +1 -1
- package/dist/assets/MarketplacePage-DWxXUOCx.js +49 -0
- package/dist/assets/{McpMarketplacePage-CLHFnNBd.js → McpMarketplacePage-Bth9X_hu.js} +2 -2
- package/dist/assets/{ModelConfig-LQSR58tc.js → ModelConfig-PkSp_ioc.js} +1 -1
- package/dist/assets/ProvidersList-DVDge8wa.js +1 -0
- package/dist/assets/RemoteAccessPage-BVkzfEaL.js +1 -0
- package/dist/assets/RuntimeConfig-ByJs3khh.js +1 -0
- package/dist/assets/{SearchConfig-Chzo_JGs.js → SearchConfig-KZUAqYJN.js} +1 -1
- package/dist/assets/{SecretsConfig-CEIbjZYA.js → SecretsConfig-qwB_Y_Ka.js} +2 -2
- package/dist/assets/SessionsConfig-CGCl4UTr.js +2 -0
- package/dist/assets/index-CrilScMo.css +1 -0
- package/dist/assets/{index-j6A_-1b6.js → index-D41ntvb7.js} +6 -6
- package/dist/assets/{label-GACO2RzW.js → label-7JEFhkur.js} +1 -1
- package/dist/assets/ncp-session-adapter-BOqhkrc-.js +1 -0
- package/dist/assets/{page-layout-DjXaK3A3.js → page-layout-B7q511TE.js} +1 -1
- package/dist/assets/popover-CywJGmPr.js +1 -0
- package/dist/assets/security-config-zi2UxN5r.js +1 -0
- package/dist/assets/skeleton-qUJZQ03S.js +1 -0
- package/dist/assets/{status-dot-IWEBezqb.js → status-dot-BilwNdTT.js} +1 -1
- package/dist/assets/{switch-DCHAJSrA.js → switch-BLp2Pno1.js} +1 -1
- package/dist/assets/tabs-custom-CgIdQMGC.js +1 -0
- package/dist/assets/useConfirmDialog-BitswAkv.js +1 -0
- package/dist/assets/{vendor-CNhxtHCf.js → vendor-D_JxmsLV.js} +87 -87
- package/dist/index.html +3 -3
- package/package.json +4 -4
- package/src/App.test.tsx +42 -10
- package/src/App.tsx +5 -40
- package/src/api/api-base.test.ts +37 -0
- package/src/api/api-base.ts +0 -4
- package/src/api/config.ts +2 -270
- package/src/api/types.ts +0 -117
- package/src/components/chat/ChatPage.tsx +1 -11
- package/src/components/chat/ChatSidebar.test.tsx +1 -50
- package/src/components/chat/ChatSidebar.tsx +0 -5
- package/src/components/chat/README.md +2 -0
- package/src/components/chat/chat-attachment-upload-limit.test.ts +41 -0
- package/src/components/chat/chat-session-display.ts +9 -0
- package/src/components/chat/chat-session-label.service.ts +3 -12
- package/src/components/chat/chat-session-preference-sync.test.ts +10 -13
- package/src/components/chat/chat-stream/types.ts +4 -57
- package/src/components/chat/ncp/NcpChatPage.tsx +3 -3
- package/src/components/chat/useHydratedNcpAgent.test.tsx +77 -0
- package/src/components/config/README.md +2 -0
- package/src/components/config/SessionsConfig.tsx +152 -132
- package/src/hooks/use-auth.test.ts +3 -3
- package/src/hooks/use-auth.ts +16 -4
- package/src/hooks/use-realtime-query-bridge.ts +0 -24
- package/src/hooks/useConfig.ts +10 -137
- package/src/lib/session-run-status.ts +1 -63
- package/src/vite-env.d.ts +1 -0
- package/vite.config.ts +4 -4
- package/dist/assets/ChatPage-CJBYKR-Y.js +0 -38
- package/dist/assets/MarketplacePage-BIRP0NRS.js +0 -49
- package/dist/assets/ProvidersList-CwI-mxah.js +0 -1
- package/dist/assets/RemoteAccessPage-Cw5BqZb6.js +0 -1
- package/dist/assets/RuntimeConfig-DbowSRAb.js +0 -1
- package/dist/assets/SessionsConfig-BR8GfGWL.js +0 -2
- package/dist/assets/chat-message-CPG7zxRR.js +0 -3
- package/dist/assets/index-kaPUhd-8.css +0 -1
- package/dist/assets/popover-DTaFiTmU.js +0 -1
- package/dist/assets/security-config-Dk-yoKvK.js +0 -1
- package/dist/assets/skeleton-Dm2xOBSA.js +0 -1
- package/dist/assets/tabs-custom-DKSbDSB9.js +0 -1
- package/dist/assets/useConfirmDialog-ByJ8A8n7.js +0 -1
- package/src/api/config.stream.test.ts +0 -115
- package/src/components/chat/chat-chain.test.ts +0 -22
- package/src/components/chat/chat-chain.ts +0 -23
- package/src/components/chat/chat-page-data.ts +0 -171
- package/src/components/chat/chat-page-runtime.ts +0 -190
- package/src/components/chat/chat-stream/nextbot-parsers.ts +0 -52
- package/src/components/chat/chat-stream/nextbot-runtime-agent.ts +0 -413
- package/src/components/chat/chat-stream/stream-event-adapter.ts +0 -98
- package/src/components/chat/chat-stream/transport.ts +0 -253
- package/src/components/chat/legacy/LegacyChatPage.tsx +0 -223
- package/src/components/chat/managers/chat-input.manager.ts +0 -228
- package/src/components/chat/managers/chat-thread.manager.ts +0 -87
- package/src/components/chat/presenter/chat.presenter.ts +0 -32
- package/src/components/chat/useChatRuntimeController.ts +0 -134
|
@@ -1,19 +1,15 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
-
import type {
|
|
2
|
+
import type { NcpMessageView, NcpSessionSummaryView, SessionEntryView } from '@/api/types';
|
|
3
3
|
import { useConfirmDialog } from '@/hooks/useConfirmDialog';
|
|
4
|
-
import {
|
|
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
|
-
|
|
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,
|
|
75
|
+
function SessionListItem({ session, summary, channel, isSelected, onSelect }: SessionListItemProps) {
|
|
55
76
|
const channelDisplay = displayChannelName(channel);
|
|
56
|
-
const displayName = session
|
|
77
|
+
const displayName = sessionDisplayName(session);
|
|
57
78
|
|
|
58
79
|
return (
|
|
59
80
|
<button
|
|
60
81
|
onClick={onSelect}
|
|
61
82
|
className={cn(
|
|
62
|
-
|
|
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
|
-
?
|
|
65
|
-
:
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
{
|
|
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(
|
|
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>{
|
|
108
|
+
<span>{summary.messageCount}</span>
|
|
88
109
|
</div>
|
|
89
110
|
</div>
|
|
90
111
|
</button>
|
|
91
112
|
);
|
|
92
113
|
}
|
|
93
114
|
|
|
94
|
-
|
|
95
|
-
|
|
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(
|
|
104
|
-
<div
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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(
|
|
116
|
-
{
|
|
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(
|
|
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 [
|
|
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
|
|
148
|
-
const
|
|
149
|
-
const
|
|
150
|
-
const
|
|
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
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
162
|
-
() =>
|
|
163
|
-
[
|
|
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
|
|
170
|
-
set.add(resolveChannelFromSessionKey(
|
|
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
|
-
}, [
|
|
200
|
+
}, [sessionEntries]);
|
|
178
201
|
|
|
179
|
-
|
|
180
|
-
if (
|
|
181
|
-
|
|
182
|
-
|
|
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);
|
|
216
|
+
setIsEditingMeta(false);
|
|
194
217
|
}, [selectedSession]);
|
|
195
218
|
|
|
196
219
|
const handleSaveMeta = () => {
|
|
197
|
-
if (!
|
|
220
|
+
if (!selectedSessionId) return;
|
|
198
221
|
updateSession.mutate({
|
|
199
|
-
|
|
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);
|
|
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 (!
|
|
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
|
-
{
|
|
240
|
+
{ sessionId: selectedSessionId },
|
|
230
241
|
{
|
|
231
|
-
onSuccess: () =>
|
|
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
|
-
{
|
|
257
|
+
{sessionEntries.length} {t('sessionsListTitle')}
|
|
252
258
|
</span>
|
|
253
|
-
<Button
|
|
254
|
-
|
|
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(
|
|
265
|
-
<SelectItem key={
|
|
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={(
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
{
|
|
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
|
|
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={
|
|
332
|
-
{
|
|
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
|
|
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
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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={
|
|
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={
|
|
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
|
-
|
|
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
|
|
399
|
-
<SessionMessageBubble key={
|
|
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(
|
|
27
|
-
expect(shouldRetryAuthStatusBootstrap(
|
|
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(
|
|
31
|
+
expect(AUTH_STATUS_BOOTSTRAP_RETRY_DELAY_MS).toBe(250);
|
|
32
32
|
});
|
|
33
33
|
});
|
package/src/hooks/use-auth.ts
CHANGED
|
@@ -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 =
|
|
14
|
-
|
|
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
|
-
|
|
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;
|