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