@nextclaw/ui 0.3.15 → 0.3.17
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 +16 -0
- package/dist/assets/index-CPXV1dWr.js +337 -0
- package/dist/assets/index-Wn63frSd.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/App.tsx +2 -0
- package/src/api/config.ts +3 -2
- package/src/api/marketplace.ts +110 -0
- package/src/api/types.ts +85 -0
- package/src/components/config/ChannelsList.tsx +2 -2
- package/src/components/config/ModelConfig.tsx +3 -3
- package/src/components/config/ProvidersList.tsx +3 -3
- package/src/components/config/SessionsConfig.tsx +298 -252
- package/src/components/doc-browser/DocBrowser.tsx +272 -0
- package/src/components/doc-browser/DocBrowserContext.tsx +134 -0
- package/src/components/doc-browser/index.ts +3 -0
- package/src/components/doc-browser/useDocLinkInterceptor.ts +33 -0
- package/src/components/layout/AppLayout.tsx +25 -8
- package/src/components/layout/Sidebar.tsx +32 -5
- package/src/components/marketplace/MarketplacePage.tsx +408 -0
- package/src/components/ui/select.tsx +135 -0
- package/src/hooks/useMarketplace.ts +59 -0
- package/src/index.css +11 -4
- package/src/lib/i18n.ts +10 -1
- package/src/styles/design-system.css +256 -214
- package/dist/assets/index-AVHp7I-v.js +0 -266
- package/dist/assets/index-MxqXd9ts.css +0 -1
|
@@ -2,15 +2,14 @@ import { useEffect, useMemo, useState } from 'react';
|
|
|
2
2
|
import type { SessionEntryView, SessionMessageView } from '@/api/types';
|
|
3
3
|
import { useDeleteSession, useSessionHistory, useSessions, useUpdateSession } from '@/hooks/useConfig';
|
|
4
4
|
import { Button } from '@/components/ui/button';
|
|
5
|
-
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
6
5
|
import { Input } from '@/components/ui/input';
|
|
6
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
7
|
+
import { cn } from '@/lib/utils';
|
|
7
8
|
import { t } from '@/lib/i18n';
|
|
8
|
-
import { RefreshCw,
|
|
9
|
+
import { RefreshCw, Search, Clock, Inbox, Hash, Bot, User, MessageCircle, Settings as SettingsIcon } from 'lucide-react';
|
|
9
10
|
|
|
10
11
|
const UNKNOWN_CHANNEL_KEY = '__unknown_channel__';
|
|
11
12
|
|
|
12
|
-
type SessionGroupMode = 'all' | 'by-channel';
|
|
13
|
-
|
|
14
13
|
function formatDate(value?: string): string {
|
|
15
14
|
if (!value) {
|
|
16
15
|
return '-';
|
|
@@ -38,315 +37,362 @@ function displayChannelName(channel: string): string {
|
|
|
38
37
|
return channel;
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
|
|
40
|
+
// ============================================================================
|
|
41
|
+
// COMPONENT: Left Sidebar Session Item
|
|
42
|
+
// ============================================================================
|
|
43
|
+
|
|
44
|
+
type SessionListItemProps = {
|
|
42
45
|
session: SessionEntryView;
|
|
43
46
|
channel: string;
|
|
44
47
|
isSelected: boolean;
|
|
45
|
-
|
|
46
|
-
modelValue: string;
|
|
47
|
-
onToggleHistory: () => void;
|
|
48
|
-
onLabelChange: (value: string) => void;
|
|
49
|
-
onModelChange: (value: string) => void;
|
|
50
|
-
onSave: () => void;
|
|
51
|
-
onClear: () => void;
|
|
52
|
-
onDelete: () => void;
|
|
48
|
+
onSelect: () => void;
|
|
53
49
|
};
|
|
54
50
|
|
|
55
|
-
function
|
|
56
|
-
const
|
|
51
|
+
function SessionListItem({ session, channel, isSelected, onSelect }: SessionListItemProps) {
|
|
52
|
+
const channelDisplay = displayChannelName(channel);
|
|
53
|
+
const displayName = session.label || session.key.split(':').pop() || session.key;
|
|
54
|
+
|
|
57
55
|
return (
|
|
58
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
<span className="font-semibold text-gray-800">{t('sessionsUpdatedLabel')}:</span> {formatDate(session.updatedAt)}
|
|
56
|
+
<button
|
|
57
|
+
onClick={onSelect}
|
|
58
|
+
className={cn(
|
|
59
|
+
"w-full text-left p-3.5 rounded-xl transition-all duration-200 outline-none focus:outline-none focus:ring-0 group",
|
|
60
|
+
isSelected
|
|
61
|
+
? "bg-brand-50 border border-brand-100/50"
|
|
62
|
+
: "bg-transparent border border-transparent hover:bg-gray-50/80"
|
|
63
|
+
)}
|
|
64
|
+
>
|
|
65
|
+
<div className="flex items-start justify-between mb-1.5">
|
|
66
|
+
<div className={cn("font-semibold truncate pr-2 flex-1 text-sm", isSelected ? "text-brand-800" : "text-gray-900")}>
|
|
67
|
+
{displayName}
|
|
71
68
|
</div>
|
|
72
|
-
<div>
|
|
73
|
-
|
|
69
|
+
<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")}>
|
|
70
|
+
{channelDisplay}
|
|
74
71
|
</div>
|
|
75
72
|
</div>
|
|
76
73
|
|
|
77
|
-
<div className="
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
<div className="flex flex-wrap gap-2">
|
|
87
|
-
<Button type="button" variant={props.isSelected ? 'default' : 'outline'} size="sm" onClick={props.onToggleHistory}>
|
|
88
|
-
{props.isSelected ? t('sessionsHideHistory') : t('sessionsShowHistory')}
|
|
89
|
-
</Button>
|
|
90
|
-
<Button type="button" variant="outline" size="sm" onClick={props.onSave}>
|
|
91
|
-
<Save className="h-4 w-4 mr-1" />
|
|
92
|
-
{t('sessionsSaveMeta')}
|
|
93
|
-
</Button>
|
|
94
|
-
<Button type="button" variant="outline" size="sm" onClick={props.onClear}>
|
|
95
|
-
{t('sessionsClearHistory')}
|
|
96
|
-
</Button>
|
|
97
|
-
<Button type="button" variant="outline" size="sm" onClick={props.onDelete}>
|
|
98
|
-
<Trash2 className="h-4 w-4 mr-1" />
|
|
99
|
-
{t('delete')}
|
|
100
|
-
</Button>
|
|
74
|
+
<div className={cn("flex items-center text-xs justify-between mt-2 font-medium", isSelected ? "text-brand-600/80" : "text-gray-400")}>
|
|
75
|
+
<div className="flex items-center gap-1.5">
|
|
76
|
+
<Clock className="w-3.5 h-3.5 opacity-70" />
|
|
77
|
+
<span className="truncate max-w-[100px]">{formatDate(session.updatedAt).split(' ')[0]}</span>
|
|
78
|
+
</div>
|
|
79
|
+
<div className="flex items-center gap-1">
|
|
80
|
+
<MessageCircle className="w-3.5 h-3.5 opacity-70" />
|
|
81
|
+
<span>{session.messageCount}</span>
|
|
82
|
+
</div>
|
|
101
83
|
</div>
|
|
102
|
-
</
|
|
84
|
+
</button>
|
|
103
85
|
);
|
|
104
86
|
}
|
|
105
87
|
|
|
106
|
-
|
|
88
|
+
// ============================================================================
|
|
89
|
+
// COMPONENT: Right Side Chat Bubble Message Item
|
|
90
|
+
// ============================================================================
|
|
91
|
+
|
|
92
|
+
function SessionMessageBubble({ message }: { message: SessionMessageView }) {
|
|
93
|
+
const isUser = message.role.toLowerCase() === 'user';
|
|
94
|
+
|
|
107
95
|
return (
|
|
108
|
-
<div
|
|
109
|
-
<div className=
|
|
110
|
-
|
|
96
|
+
<div className={cn("flex w-full mb-6", isUser ? "justify-end" : "justify-start")}>
|
|
97
|
+
<div className={cn(
|
|
98
|
+
"max-w-[85%] rounded-[1.25rem] p-5 flex gap-3 text-sm",
|
|
99
|
+
isUser
|
|
100
|
+
? "bg-primary text-white rounded-tr-sm"
|
|
101
|
+
: "bg-gray-50 text-gray-800 rounded-tl-sm border border-gray-100/50"
|
|
102
|
+
)}>
|
|
103
|
+
<div className="shrink-0 pt-0.5">
|
|
104
|
+
{isUser ? <User className="w-4 h-4 text-primary-100" /> : <Bot className="w-4 h-4 text-gray-400" />}
|
|
105
|
+
</div>
|
|
106
|
+
<div className="flex-1 space-y-1 overflow-x-hidden">
|
|
107
|
+
<div className="flex items-baseline justify-between gap-4 mb-2">
|
|
108
|
+
<span className={cn("font-semibold text-xs", isUser ? "text-primary-50" : "text-gray-900 capitalize")}>
|
|
109
|
+
{message.role}
|
|
110
|
+
</span>
|
|
111
|
+
<span className={cn("text-[10px]", isUser ? "text-primary-200" : "text-gray-400")}>
|
|
112
|
+
{formatDate(message.timestamp)}
|
|
113
|
+
</span>
|
|
114
|
+
</div>
|
|
115
|
+
<div className="whitespace-pre-wrap break-words leading-relaxed text-[15px]">
|
|
116
|
+
{message.content}
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
111
119
|
</div>
|
|
112
|
-
<div className="text-sm whitespace-pre-wrap break-words mt-1">{message.content}</div>
|
|
113
120
|
</div>
|
|
114
121
|
);
|
|
115
122
|
}
|
|
116
123
|
|
|
124
|
+
// ============================================================================
|
|
125
|
+
// MAIN PAGE COMPONENT
|
|
126
|
+
// ============================================================================
|
|
127
|
+
|
|
117
128
|
export function SessionsConfig() {
|
|
118
129
|
const [query, setQuery] = useState('');
|
|
119
|
-
const [limit
|
|
120
|
-
const [activeMinutes
|
|
121
|
-
const [groupMode, setGroupMode] = useState<SessionGroupMode>('all');
|
|
130
|
+
const [limit] = useState(100);
|
|
131
|
+
const [activeMinutes] = useState(0);
|
|
122
132
|
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
|
123
|
-
const [
|
|
124
|
-
|
|
133
|
+
const [selectedChannel, setSelectedChannel] = useState<string>('all');
|
|
134
|
+
|
|
135
|
+
// Local state drafts for editing the currently selected session
|
|
136
|
+
const [draftLabel, setDraftLabel] = useState('');
|
|
137
|
+
const [draftModel, setDraftModel] = useState('');
|
|
138
|
+
const [isEditingMeta, setIsEditingMeta] = useState(false);
|
|
125
139
|
|
|
126
140
|
const sessionsParams = useMemo(() => ({ q: query.trim() || undefined, limit, activeMinutes }), [query, limit, activeMinutes]);
|
|
127
141
|
const sessionsQuery = useSessions(sessionsParams);
|
|
128
142
|
const historyQuery = useSessionHistory(selectedKey, 200);
|
|
143
|
+
|
|
129
144
|
const updateSession = useUpdateSession();
|
|
130
145
|
const deleteSession = useDeleteSession();
|
|
131
146
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
147
|
+
const sessions = useMemo(() => sessionsQuery.data?.sessions ?? [], [sessionsQuery.data?.sessions]);
|
|
148
|
+
const selectedSession = useMemo(() => sessions.find(s => s.key === selectedKey), [sessions, selectedKey]);
|
|
149
|
+
|
|
150
|
+
const channels = useMemo(() => {
|
|
151
|
+
const set = new Set<string>();
|
|
152
|
+
for (const s of sessions) {
|
|
153
|
+
set.add(resolveChannelFromSessionKey(s.key));
|
|
136
154
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
next[session.key] = session.label ?? '';
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
return next;
|
|
155
|
+
return Array.from(set).sort((a, b) => {
|
|
156
|
+
if (a === UNKNOWN_CHANNEL_KEY) return 1;
|
|
157
|
+
if (b === UNKNOWN_CHANNEL_KEY) return -1;
|
|
158
|
+
return a.localeCompare(b);
|
|
145
159
|
});
|
|
146
|
-
setModelDraft((prev) => {
|
|
147
|
-
const next = { ...prev };
|
|
148
|
-
for (const session of sessions) {
|
|
149
|
-
if (!(session.key in next)) {
|
|
150
|
-
next[session.key] = session.preferredModel ?? '';
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
return next;
|
|
154
|
-
});
|
|
155
|
-
}, [sessionsQuery.data]);
|
|
156
|
-
|
|
157
|
-
const sessions = sessionsQuery.data?.sessions ?? [];
|
|
158
|
-
|
|
159
|
-
const groupedSessions = useMemo(() => {
|
|
160
|
-
const buckets = new Map<string, SessionEntryView[]>();
|
|
161
|
-
for (const session of sessions) {
|
|
162
|
-
const channel = resolveChannelFromSessionKey(session.key);
|
|
163
|
-
const list = buckets.get(channel);
|
|
164
|
-
if (list) {
|
|
165
|
-
list.push(session);
|
|
166
|
-
} else {
|
|
167
|
-
buckets.set(channel, [session]);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
return Array.from(buckets.entries())
|
|
171
|
-
.sort((a, b) => {
|
|
172
|
-
if (a[0] === UNKNOWN_CHANNEL_KEY) {
|
|
173
|
-
return 1;
|
|
174
|
-
}
|
|
175
|
-
if (b[0] === UNKNOWN_CHANNEL_KEY) {
|
|
176
|
-
return -1;
|
|
177
|
-
}
|
|
178
|
-
return a[0].localeCompare(b[0]);
|
|
179
|
-
})
|
|
180
|
-
.map(([channel, rows]) => ({ channel, rows }));
|
|
181
160
|
}, [sessions]);
|
|
182
161
|
|
|
183
|
-
const
|
|
162
|
+
const filteredSessions = useMemo(() => {
|
|
163
|
+
if (selectedChannel === 'all') return sessions;
|
|
164
|
+
return sessions.filter(s => resolveChannelFromSessionKey(s.key) === selectedChannel);
|
|
165
|
+
}, [sessions, selectedChannel]);
|
|
166
|
+
|
|
167
|
+
// Sync draft states when selecting a new session
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (selectedSession) {
|
|
170
|
+
setDraftLabel(selectedSession.label || '');
|
|
171
|
+
setDraftModel(selectedSession.preferredModel || '');
|
|
172
|
+
} else {
|
|
173
|
+
setDraftLabel('');
|
|
174
|
+
setDraftModel('');
|
|
175
|
+
}
|
|
176
|
+
setIsEditingMeta(false); // Reset editing state when switching sessions
|
|
177
|
+
}, [selectedSession]);
|
|
178
|
+
|
|
179
|
+
const handleSaveMeta = () => {
|
|
180
|
+
if (!selectedKey) return;
|
|
184
181
|
updateSession.mutate({
|
|
185
|
-
key,
|
|
182
|
+
key: selectedKey,
|
|
186
183
|
data: {
|
|
187
|
-
label:
|
|
188
|
-
preferredModel:
|
|
184
|
+
label: draftLabel.trim() || null,
|
|
185
|
+
preferredModel: draftModel.trim() || null
|
|
189
186
|
}
|
|
190
187
|
});
|
|
188
|
+
setIsEditingMeta(false); // Close editor on save
|
|
191
189
|
};
|
|
192
190
|
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
if (
|
|
196
|
-
|
|
191
|
+
const handleClearHistory = () => {
|
|
192
|
+
if (!selectedKey) return;
|
|
193
|
+
if (window.confirm(t('sessionsClearHistory') + "?")) {
|
|
194
|
+
updateSession.mutate({ key: selectedKey, data: { clearHistory: true } });
|
|
197
195
|
}
|
|
198
196
|
};
|
|
199
197
|
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
if (
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
{
|
|
208
|
-
onSuccess: () => {
|
|
209
|
-
if (selectedKey === key) {
|
|
210
|
-
setSelectedKey(null);
|
|
211
|
-
}
|
|
198
|
+
const handleDeleteSession = () => {
|
|
199
|
+
if (!selectedKey) return;
|
|
200
|
+
if (window.confirm(`${t('sessionsDeleteConfirm')} ?`)) {
|
|
201
|
+
deleteSession.mutate(
|
|
202
|
+
{ key: selectedKey },
|
|
203
|
+
{
|
|
204
|
+
onSuccess: () => setSelectedKey(null)
|
|
212
205
|
}
|
|
213
|
-
|
|
214
|
-
|
|
206
|
+
);
|
|
207
|
+
}
|
|
215
208
|
};
|
|
216
209
|
|
|
217
210
|
return (
|
|
218
|
-
<div className="
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
<
|
|
211
|
+
<div className="h-[calc(100vh-80px)] w-full max-w-[1400px] mx-auto animate-fade-in flex flex-col pt-6 pb-2">
|
|
212
|
+
|
|
213
|
+
<div className="flex items-center justify-between mb-6 shrink-0">
|
|
214
|
+
<div>
|
|
215
|
+
<h2 className="text-2xl font-bold text-gray-900 tracking-tight">{t('sessionsPageTitle')}</h2>
|
|
216
|
+
<p className="text-sm text-gray-500 mt-1">{t('sessionsPageDescription')}</p>
|
|
217
|
+
</div>
|
|
222
218
|
</div>
|
|
223
219
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
<
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
</
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
</select>
|
|
220
|
+
{/* Main Mailbox Layout */}
|
|
221
|
+
<div className="flex-1 flex gap-6 min-h-0 relative">
|
|
222
|
+
|
|
223
|
+
{/* LEFT COLUMN: List Card */}
|
|
224
|
+
<div className="w-[320px] flex flex-col shrink-0 bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
|
225
|
+
|
|
226
|
+
{/* List Card Header & Toolbar */}
|
|
227
|
+
<div className="px-4 py-4 border-b border-gray-100 bg-white z-10 shrink-0 space-y-3">
|
|
228
|
+
<div className="flex items-center justify-between">
|
|
229
|
+
<span className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider">
|
|
230
|
+
{sessions.length} {t('sessionsListTitle')}
|
|
231
|
+
</span>
|
|
232
|
+
<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()}>
|
|
233
|
+
<RefreshCw className={cn("h-3.5 w-3.5", sessionsQuery.isFetching && "animate-spin")} />
|
|
234
|
+
</Button>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<Select value={selectedChannel} onValueChange={setSelectedChannel}>
|
|
238
|
+
<SelectTrigger className="w-full h-8.5 rounded-lg bg-gray-50/50 hover:bg-gray-100 border-gray-200 focus:ring-0 shadow-none text-xs font-medium text-gray-700">
|
|
239
|
+
<SelectValue placeholder="All Channels" />
|
|
240
|
+
</SelectTrigger>
|
|
241
|
+
<SelectContent className="rounded-xl shadow-lg border-gray-100 max-w-[280px]">
|
|
242
|
+
<SelectItem value="all" className="rounded-lg text-xs">All Channels</SelectItem>
|
|
243
|
+
{channels.map(c => (
|
|
244
|
+
<SelectItem key={c} value={c} className="rounded-lg text-xs truncate pr-6">{displayChannelName(c)}</SelectItem>
|
|
245
|
+
))}
|
|
246
|
+
</SelectContent>
|
|
247
|
+
</Select>
|
|
248
|
+
|
|
249
|
+
<div className="relative w-full">
|
|
250
|
+
<Search className="h-3.5 w-3.5 absolute left-3 top-2.5 text-gray-400" />
|
|
251
|
+
<Input
|
|
252
|
+
value={query}
|
|
253
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
254
|
+
placeholder={t('sessionsSearchPlaceholder')}
|
|
255
|
+
className="pl-8 h-8.5 rounded-lg bg-gray-50/50 border-gray-200 focus-visible:bg-white text-xs"
|
|
256
|
+
/>
|
|
257
|
+
</div>
|
|
263
258
|
</div>
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
{sessionsQuery.error ? <div className="text-sm text-red-600">{(sessionsQuery.error as Error).message}</div> : null}
|
|
277
|
-
{!sessionsQuery.isLoading && !sessions.length ? <div className="text-sm text-gray-500">{t('sessionsEmpty')}</div> : null}
|
|
278
|
-
|
|
279
|
-
{groupMode === 'all'
|
|
280
|
-
? sessions.map((session) => (
|
|
281
|
-
<SessionRow
|
|
259
|
+
|
|
260
|
+
<div className="flex-1 overflow-y-auto p-3 space-y-1 pb-10 custom-scrollbar relative">
|
|
261
|
+
{sessionsQuery.isLoading ? (
|
|
262
|
+
<div className="text-sm text-gray-400 p-4 text-center">{t('sessionsLoading')}</div>
|
|
263
|
+
) : filteredSessions.length === 0 ? (
|
|
264
|
+
<div className="text-sm text-gray-400 p-4 text-center border-2 border-dashed border-gray-100 rounded-xl mt-4">
|
|
265
|
+
<Inbox className="w-8 h-8 mx-auto mb-2 text-gray-300" />
|
|
266
|
+
{t('sessionsEmpty')}
|
|
267
|
+
</div>
|
|
268
|
+
) : (
|
|
269
|
+
filteredSessions.map(session => (
|
|
270
|
+
<SessionListItem
|
|
282
271
|
key={session.key}
|
|
283
272
|
session={session}
|
|
284
273
|
channel={resolveChannelFromSessionKey(session.key)}
|
|
285
274
|
isSelected={selectedKey === session.key}
|
|
286
|
-
|
|
287
|
-
modelValue={modelDraft[session.key] ?? ''}
|
|
288
|
-
onToggleHistory={() => setSelectedKey((prev) => (prev === session.key ? null : session.key))}
|
|
289
|
-
onLabelChange={(value) => setLabelDraft((prev) => ({ ...prev, [session.key]: value }))}
|
|
290
|
-
onModelChange={(value) => setModelDraft((prev) => ({ ...prev, [session.key]: value }))}
|
|
291
|
-
onSave={() => saveSessionMeta(session.key)}
|
|
292
|
-
onClear={() => clearSessionHistory(session.key)}
|
|
293
|
-
onDelete={() => deleteSessionByKey(session.key)}
|
|
275
|
+
onSelect={() => setSelectedKey(session.key)}
|
|
294
276
|
/>
|
|
295
277
|
))
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
{/* RIGHT COLUMN: Detail View Card */}
|
|
283
|
+
<div className="flex-1 min-w-0 flex flex-col overflow-hidden relative bg-white rounded-2xl shadow-sm border border-gray-200">
|
|
284
|
+
|
|
285
|
+
{(updateSession.isPending || deleteSession.isPending) && (
|
|
286
|
+
<div className="absolute top-0 left-0 w-full h-1 bg-primary/20 overflow-hidden z-20">
|
|
287
|
+
<div className="h-full bg-primary animate-pulse w-1/3 rounded-r-full" />
|
|
288
|
+
</div>
|
|
289
|
+
)}
|
|
290
|
+
|
|
291
|
+
{selectedKey && selectedSession ? (
|
|
292
|
+
<>
|
|
293
|
+
{/* Detail Header / Metdata Editor */}
|
|
294
|
+
<div className="shrink-0 border-b border-gray-100 bg-white px-8 py-5 z-10 space-y-4">
|
|
295
|
+
<div className="flex items-center justify-between">
|
|
296
|
+
<div className="flex items-center gap-4">
|
|
297
|
+
<div className="h-12 w-12 rounded-[14px] bg-gray-50 border border-gray-100 flex items-center justify-center text-gray-400 shrink-0">
|
|
298
|
+
<Hash className="h-6 w-6" />
|
|
299
|
+
</div>
|
|
300
|
+
<div>
|
|
301
|
+
<div className="flex items-center gap-2.5 mb-1.5">
|
|
302
|
+
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
|
|
303
|
+
{selectedSession.label || selectedSession.key.split(':').pop() || selectedSession.key}
|
|
304
|
+
</h3>
|
|
305
|
+
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-gray-100 text-gray-500 uppercase tracking-widest">
|
|
306
|
+
{displayChannelName(resolveChannelFromSessionKey(selectedSession.key))}
|
|
307
|
+
</span>
|
|
308
|
+
</div>
|
|
309
|
+
<div className="text-xs text-gray-500 font-mono break-all line-clamp-1 opacity-70" title={selectedKey}>
|
|
310
|
+
{selectedKey}
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
300
313
|
</div>
|
|
301
|
-
<div className="
|
|
302
|
-
{
|
|
303
|
-
<
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
onModelChange={(value) => setModelDraft((prev) => ({ ...prev, [session.key]: value }))}
|
|
313
|
-
onSave={() => saveSessionMeta(session.key)}
|
|
314
|
-
onClear={() => clearSessionHistory(session.key)}
|
|
315
|
-
onDelete={() => deleteSessionByKey(session.key)}
|
|
316
|
-
/>
|
|
317
|
-
))}
|
|
314
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
315
|
+
<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")}>
|
|
316
|
+
<SettingsIcon className="w-3.5 h-3.5 mr-1.5" />
|
|
317
|
+
Metadata
|
|
318
|
+
</Button>
|
|
319
|
+
<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">
|
|
320
|
+
{t('sessionsClearHistory')}
|
|
321
|
+
</Button>
|
|
322
|
+
<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">
|
|
323
|
+
{t('delete')}
|
|
324
|
+
</Button>
|
|
318
325
|
</div>
|
|
319
326
|
</div>
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
327
|
+
|
|
328
|
+
{isEditingMeta && (
|
|
329
|
+
<div className="flex items-center gap-3 bg-gray-50/50 p-3 rounded-lg border border-gray-100 animate-slide-in">
|
|
330
|
+
<Input
|
|
331
|
+
placeholder={t('sessionsLabelPlaceholder')}
|
|
332
|
+
value={draftLabel}
|
|
333
|
+
onChange={e => setDraftLabel(e.target.value)}
|
|
334
|
+
className="h-8 text-sm bg-white"
|
|
335
|
+
/>
|
|
336
|
+
<Input
|
|
337
|
+
placeholder={t('sessionsModelPlaceholder')}
|
|
338
|
+
value={draftModel}
|
|
339
|
+
onChange={e => setDraftModel(e.target.value)}
|
|
340
|
+
className="h-8 text-sm bg-white"
|
|
341
|
+
/>
|
|
342
|
+
<Button size="sm" onClick={handleSaveMeta} className="h-8 px-4 shrink-0 shadow-none" disabled={updateSession.isPending}>
|
|
343
|
+
{t('sessionsSaveMeta')}
|
|
344
|
+
</Button>
|
|
345
|
+
</div>
|
|
346
|
+
)}
|
|
338
347
|
</div>
|
|
339
|
-
) : null}
|
|
340
|
-
<div className="max-h-[480px] overflow-auto space-y-2">
|
|
341
|
-
{(historyQuery.data?.messages ?? []).map((message, index) => (
|
|
342
|
-
<SessionMessageItem key={`${message.timestamp}-${index}`} message={message} index={index} />
|
|
343
|
-
))}
|
|
344
|
-
</div>
|
|
345
|
-
</CardContent>
|
|
346
|
-
</Card>
|
|
347
|
-
) : null}
|
|
348
348
|
|
|
349
|
-
|
|
349
|
+
{/* Chat History Area */}
|
|
350
|
+
<div className="flex-1 overflow-y-auto p-6 relative
|
|
351
|
+
[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:bg-gray-300/80 [&::-webkit-scrollbar-thumb]:rounded-full">
|
|
352
|
+
|
|
353
|
+
{historyQuery.isLoading && (
|
|
354
|
+
<div className="absolute inset-0 flex items-center justify-center bg-gray-50/50 backdrop-blur-sm z-10">
|
|
355
|
+
<div className="flex flex-col items-center gap-3 animate-pulse">
|
|
356
|
+
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
|
|
357
|
+
<span className="text-sm font-medium text-gray-500">{t('sessionsHistoryLoading')}</span>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
)}
|
|
361
|
+
|
|
362
|
+
{historyQuery.error && (
|
|
363
|
+
<div className="text-center p-6 bg-red-50 rounded-xl text-red-600 border border-red-100 text-sm">
|
|
364
|
+
{(historyQuery.error as Error).message}
|
|
365
|
+
</div>
|
|
366
|
+
)}
|
|
367
|
+
|
|
368
|
+
{!historyQuery.isLoading && historyQuery.data?.messages.length === 0 && (
|
|
369
|
+
<div className="h-full flex flex-col items-center justify-center text-gray-400">
|
|
370
|
+
<MessageCircle className="w-12 h-12 mb-3 text-gray-300" />
|
|
371
|
+
<p className="text-sm">{t('sessionsEmpty')}</p>
|
|
372
|
+
</div>
|
|
373
|
+
)}
|
|
374
|
+
|
|
375
|
+
<div className="max-w-3xl mx-auto">
|
|
376
|
+
{(historyQuery.data?.messages ?? []).map((message, idx) => (
|
|
377
|
+
<SessionMessageBubble key={`${message.timestamp}-${idx}`} message={message} />
|
|
378
|
+
))}
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
</>
|
|
382
|
+
) : (
|
|
383
|
+
/* Empty State */
|
|
384
|
+
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 p-8 h-full bg-white">
|
|
385
|
+
<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">
|
|
386
|
+
<Inbox className="h-8 w-8 text-gray-300 -rotate-3" />
|
|
387
|
+
</div>
|
|
388
|
+
<h3 className="text-lg font-bold text-gray-900 mb-2">No Session Selected</h3>
|
|
389
|
+
<p className="text-sm text-center max-w-sm leading-relaxed">
|
|
390
|
+
Select a session from the list on the left to view its chat history and configure its metadata.
|
|
391
|
+
</p>
|
|
392
|
+
</div>
|
|
393
|
+
)}
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
350
396
|
</div>
|
|
351
397
|
);
|
|
352
398
|
}
|