@nextclaw/ui 0.3.14 → 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.
@@ -2,9 +2,13 @@ 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';
7
- import { RefreshCw, Save, Search, Trash2 } from 'lucide-react';
6
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
7
+ import { cn } from '@/lib/utils';
8
+ import { t } from '@/lib/i18n';
9
+ import { RefreshCw, Search, Clock, Inbox, Hash, Bot, User, MessageCircle } from 'lucide-react';
10
+
11
+ const UNKNOWN_CHANNEL_KEY = '__unknown_channel__';
8
12
 
9
13
  function formatDate(value?: string): string {
10
14
  if (!value) {
@@ -17,230 +21,366 @@ function formatDate(value?: string): string {
17
21
  return date.toLocaleString();
18
22
  }
19
23
 
20
- type SessionRowProps = {
24
+ function resolveChannelFromSessionKey(key: string): string {
25
+ const separator = key.indexOf(':');
26
+ if (separator <= 0) {
27
+ return UNKNOWN_CHANNEL_KEY;
28
+ }
29
+ const channel = key.slice(0, separator).trim();
30
+ return channel || UNKNOWN_CHANNEL_KEY;
31
+ }
32
+
33
+ function displayChannelName(channel: string): string {
34
+ if (channel === UNKNOWN_CHANNEL_KEY) {
35
+ return t('sessionsUnknownChannel');
36
+ }
37
+ return channel;
38
+ }
39
+
40
+ // ============================================================================
41
+ // COMPONENT: Left Sidebar Session Item
42
+ // ============================================================================
43
+
44
+ type SessionListItemProps = {
21
45
  session: SessionEntryView;
46
+ channel: string;
22
47
  isSelected: boolean;
23
- labelValue: string;
24
- modelValue: string;
25
- onToggleHistory: () => void;
26
- onLabelChange: (value: string) => void;
27
- onModelChange: (value: string) => void;
28
- onSave: () => void;
29
- onClear: () => void;
30
- onDelete: () => void;
48
+ onSelect: () => void;
31
49
  };
32
50
 
33
- function SessionRow(props: SessionRowProps) {
34
- const { session } = props;
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
+
35
55
  return (
36
- <div className="rounded-xl border border-gray-200 p-3 space-y-3">
37
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2 text-xs text-gray-600">
38
- <div>
39
- <span className="font-semibold text-gray-800">Key:</span> {session.key}
40
- </div>
41
- <div>
42
- <span className="font-semibold text-gray-800">Messages:</span> {session.messageCount}
43
- </div>
44
- <div>
45
- <span className="font-semibold text-gray-800">Updated:</span> {formatDate(session.updatedAt)}
46
- </div>
47
- <div>
48
- <span className="font-semibold text-gray-800">Last Role:</span> {session.lastRole ?? '-'}
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}
49
69
  </div>
50
70
  </div>
51
71
 
52
- <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
53
- <Input value={props.labelValue} onChange={(event) => props.onLabelChange(event.target.value)} placeholder="会话标签(可选)" />
54
- <Input
55
- value={props.modelValue}
56
- onChange={(event) => props.onModelChange(event.target.value)}
57
- placeholder="Preferred model(可选)"
58
- />
59
- </div>
60
-
61
- <div className="flex flex-wrap gap-2">
62
- <Button type="button" variant={props.isSelected ? 'default' : 'outline'} size="sm" onClick={props.onToggleHistory}>
63
- {props.isSelected ? '隐藏历史' : '查看历史'}
64
- </Button>
65
- <Button type="button" variant="outline" size="sm" onClick={props.onSave}>
66
- <Save className="h-4 w-4 mr-1" />
67
- 保存元信息
68
- </Button>
69
- <Button type="button" variant="outline" size="sm" onClick={props.onClear}>
70
- 清空历史
71
- </Button>
72
- <Button type="button" variant="outline" size="sm" onClick={props.onDelete}>
73
- <Trash2 className="h-4 w-4 mr-1" />
74
- 删除
75
- </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>
76
81
  </div>
77
- </div>
82
+ </button>
78
83
  );
79
84
  }
80
85
 
81
- function SessionMessageItem({ message, index }: { message: SessionMessageView; index: number }) {
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
+
82
93
  return (
83
- <div key={`${message.timestamp}-${index}`} className="rounded-lg border border-gray-200 p-2">
84
- <div className="text-xs text-gray-500">
85
- <span className="font-semibold text-gray-700">{message.role}</span> · {formatDate(message.timestamp)}
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>
86
117
  </div>
87
- <div className="text-sm whitespace-pre-wrap break-words mt-1">{message.content}</div>
88
118
  </div>
89
119
  );
90
120
  }
91
121
 
122
+ // ============================================================================
123
+ // MAIN PAGE COMPONENT
124
+ // ============================================================================
125
+
92
126
  export function SessionsConfig() {
93
127
  const [query, setQuery] = useState('');
94
- const [limit, setLimit] = useState(100);
95
- const [activeMinutes, setActiveMinutes] = useState(0);
128
+ const [limit] = useState(100);
129
+ const [activeMinutes] = useState(0);
96
130
  const [selectedKey, setSelectedKey] = useState<string | null>(null);
97
- const [labelDraft, setLabelDraft] = useState<Record<string, string>>({});
98
- const [modelDraft, setModelDraft] = useState<Record<string, string>>({});
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('');
99
136
 
100
137
  const sessionsParams = useMemo(() => ({ q: query.trim() || undefined, limit, activeMinutes }), [query, limit, activeMinutes]);
101
138
  const sessionsQuery = useSessions(sessionsParams);
102
139
  const historyQuery = useSessionHistory(selectedKey, 200);
140
+
103
141
  const updateSession = useUpdateSession();
104
142
  const deleteSession = useDeleteSession();
105
143
 
106
- useEffect(() => {
107
- const sessions = sessionsQuery.data?.sessions ?? [];
108
- if (!sessions.length) {
109
- return;
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));
110
151
  }
111
- setLabelDraft((prev) => {
112
- const next = { ...prev };
113
- for (const session of sessions) {
114
- if (!(session.key in next)) {
115
- next[session.key] = session.label ?? '';
116
- }
117
- }
118
- 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);
119
156
  });
120
- setModelDraft((prev) => {
121
- const next = { ...prev };
122
- for (const session of sessions) {
123
- if (!(session.key in next)) {
124
- next[session.key] = session.preferredModel ?? '';
125
- }
126
- }
127
- return next;
128
- });
129
- }, [sessionsQuery.data]);
157
+ }, [sessions]);
130
158
 
131
- const sessions = sessionsQuery.data?.sessions ?? [];
159
+ const filteredSessions = useMemo(() => {
160
+ if (selectedChannel === 'all') return sessions;
161
+ return sessions.filter(s => resolveChannelFromSessionKey(s.key) === selectedChannel);
162
+ }, [sessions, selectedChannel]);
132
163
 
133
- const saveSessionMeta = (key: string) => {
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;
134
177
  updateSession.mutate({
135
- key,
178
+ key: selectedKey,
136
179
  data: {
137
- label: (labelDraft[key] ?? '').trim() || null,
138
- preferredModel: (modelDraft[key] ?? '').trim() || null
180
+ label: draftLabel.trim() || null,
181
+ preferredModel: draftModel.trim() || null
139
182
  }
140
183
  });
141
184
  };
142
185
 
143
- const clearSessionHistory = (key: string) => {
144
- updateSession.mutate({ key, data: { clearHistory: true } });
145
- if (selectedKey === key) {
146
- setSelectedKey(null);
186
+ const handleClearHistory = () => {
187
+ if (!selectedKey) return;
188
+ if (window.confirm(t('sessionsClearHistory') + "?")) {
189
+ updateSession.mutate({ key: selectedKey, data: { clearHistory: true } });
147
190
  }
148
191
  };
149
192
 
150
- const deleteSessionByKey = (key: string) => {
151
- const confirmed = window.confirm(`确认删除会话 ${key} ?`);
152
- if (!confirmed) {
153
- return;
154
- }
155
- deleteSession.mutate(
156
- { key },
157
- {
158
- onSuccess: () => {
159
- if (selectedKey === key) {
160
- setSelectedKey(null);
161
- }
193
+ const handleDeleteSession = () => {
194
+ if (!selectedKey) return;
195
+ if (window.confirm(`${t('sessionsDeleteConfirm')} ?`)) {
196
+ deleteSession.mutate(
197
+ { key: selectedKey },
198
+ {
199
+ onSuccess: () => setSelectedKey(null)
162
200
  }
163
- }
164
- );
201
+ );
202
+ }
165
203
  };
166
204
 
167
205
  return (
168
- <div className="space-y-6 pb-20 animate-fade-in">
169
- <div>
170
- <h2 className="text-2xl font-bold text-gray-900">Sessions</h2>
171
- <p className="text-sm text-gray-500 mt-1">管理会话:筛选、查看历史、改标签/偏好模型、清空和删除。</p>
206
+ <div className="h-[calc(100vh-80px)] w-full max-w-[1400px] mx-auto animate-fade-in flex flex-col pt-6 pb-2">
207
+
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" />
231
+ <Input
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"
236
+ />
237
+ </div>
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>
172
242
  </div>
173
243
 
174
- <Card>
175
- <CardHeader>
176
- <CardTitle>Filters</CardTitle>
177
- <CardDescription>按关键词与活跃窗口筛选会话。</CardDescription>
178
- </CardHeader>
179
- <CardContent className="grid grid-cols-1 md:grid-cols-4 gap-3">
180
- <div className="md:col-span-2 relative">
181
- <Search className="h-4 w-4 absolute left-3 top-3 text-gray-400" />
182
- <Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索 key 或标签" className="pl-9" />
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>
183
251
  </div>
184
- <Input type="number" min={0} value={activeMinutes} onChange={(event) => setActiveMinutes(Math.max(0, Number.parseInt(event.target.value, 10) || 0))} placeholder="活跃分钟(0=不限)" />
185
- <div className="flex gap-2">
186
- <Input type="number" min={1} value={limit} onChange={(event) => setLimit(Math.max(1, Number.parseInt(event.target.value, 10) || 1))} placeholder="Limit" />
187
- <Button type="button" variant="outline" onClick={() => sessionsQuery.refetch()}>
188
- <RefreshCw className="h-4 w-4" />
189
- </Button>
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
265
+ key={session.key}
266
+ session={session}
267
+ channel={resolveChannelFromSessionKey(session.key)}
268
+ isSelected={selectedKey === session.key}
269
+ onSelect={() => setSelectedKey(session.key)}
270
+ />
271
+ ))
272
+ )}
190
273
  </div>
191
- </CardContent>
192
- </Card>
193
-
194
- <Card>
195
- <CardHeader>
196
- <CardTitle>Session List</CardTitle>
197
- <CardDescription>共 {sessionsQuery.data?.total ?? 0} 条;当前展示 {sessions.length} 条。</CardDescription>
198
- </CardHeader>
199
- <CardContent className="space-y-3">
200
- {sessionsQuery.isLoading ? <div className="text-sm text-gray-500">Loading sessions...</div> : null}
201
- {sessionsQuery.error ? <div className="text-sm text-red-600">{(sessionsQuery.error as Error).message}</div> : null}
202
- {!sessionsQuery.isLoading && !sessions.length ? <div className="text-sm text-gray-500">暂无会话。</div> : null}
203
-
204
- {sessions.map((session) => (
205
- <SessionRow
206
- key={session.key}
207
- session={session}
208
- isSelected={selectedKey === session.key}
209
- labelValue={labelDraft[session.key] ?? ''}
210
- modelValue={modelDraft[session.key] ?? ''}
211
- onToggleHistory={() => setSelectedKey((prev) => (prev === session.key ? null : session.key))}
212
- onLabelChange={(value) => setLabelDraft((prev) => ({ ...prev, [session.key]: value }))}
213
- onModelChange={(value) => setModelDraft((prev) => ({ ...prev, [session.key]: value }))}
214
- onSave={() => saveSessionMeta(session.key)}
215
- onClear={() => clearSessionHistory(session.key)}
216
- onDelete={() => deleteSessionByKey(session.key)}
217
- />
218
- ))}
219
- </CardContent>
220
- </Card>
221
-
222
- {selectedKey ? (
223
- <Card>
224
- <CardHeader>
225
- <CardTitle>History: {selectedKey}</CardTitle>
226
- <CardDescription>最近 200 条消息(展示窗口)。</CardDescription>
227
- </CardHeader>
228
- <CardContent className="space-y-2">
229
- {historyQuery.isLoading ? <div className="text-sm text-gray-500">Loading history...</div> : null}
230
- {historyQuery.error ? <div className="text-sm text-red-600">{(historyQuery.error as Error).message}</div> : null}
231
- {historyQuery.data ? (
232
- <div className="text-xs text-gray-500">Total: {historyQuery.data.totalMessages} · metadata: {JSON.stringify(historyQuery.data.metadata ?? {})}</div>
233
- ) : null}
234
- <div className="max-h-[480px] overflow-auto space-y-2">
235
- {(historyQuery.data?.messages ?? []).map((message, index) => (
236
- <SessionMessageItem key={`${message.timestamp}-${index}`} message={message} index={index} />
237
- ))}
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" />
238
282
  </div>
239
- </CardContent>
240
- </Card>
241
- ) : null}
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>
307
+ </div>
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>
315
+ </div>
316
+ </div>
242
317
 
243
- {(updateSession.isPending || deleteSession.isPending) && <div className="text-xs text-gray-500">Applying session changes...</div>}
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>
335
+ </div>
336
+
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>
244
384
  </div>
245
385
  );
246
386
  }
@@ -1,38 +1,37 @@
1
- import { useUiStore } from '@/stores/ui.store';
2
1
  import { cn } from '@/lib/utils';
2
+ import { t } from '@/lib/i18n';
3
3
  import { Cpu, GitBranch, History, MessageSquare, Sparkles } from 'lucide-react';
4
+ import { NavLink } from 'react-router-dom';
4
5
 
5
6
  const navItems = [
6
7
  {
7
- id: 'model' as const,
8
+ target: '/model',
8
9
  label: 'Models',
9
10
  icon: Cpu,
10
11
  },
11
12
  {
12
- id: 'providers' as const,
13
+ target: '/providers',
13
14
  label: 'Providers',
14
15
  icon: Sparkles,
15
16
  },
16
17
  {
17
- id: 'channels' as const,
18
+ target: '/channels',
18
19
  label: 'Channels',
19
20
  icon: MessageSquare,
20
21
  },
21
22
  {
22
- id: 'runtime' as const,
23
+ target: '/runtime',
23
24
  label: 'Routing & Runtime',
24
25
  icon: GitBranch,
25
26
  },
26
27
  {
27
- id: 'sessions' as const,
28
- label: 'Sessions',
28
+ target: '/sessions',
29
+ label: t('sessions'),
29
30
  icon: History,
30
31
  }
31
32
  ];
32
33
 
33
34
  export function Sidebar() {
34
- const { activeTab, setActiveTab } = useUiStore();
35
-
36
35
  return (
37
36
  <aside className="w-[240px] bg-transparent flex flex-col h-full py-6 px-4">
38
37
  {/* Logo Area */}
@@ -50,25 +49,28 @@ export function Sidebar() {
50
49
  <ul className="space-y-1">
51
50
  {navItems.map((item) => {
52
51
  const Icon = item.icon;
53
- const isActive = activeTab === item.id;
54
52
 
55
53
  return (
56
- <li key={item.id}>
57
- <button
58
- onClick={() => setActiveTab(item.id)}
59
- className={cn(
54
+ <li key={item.target}>
55
+ <NavLink
56
+ to={item.target}
57
+ className={({ isActive }) => cn(
60
58
  'group w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-fast',
61
59
  isActive
62
60
  ? 'bg-primary-100 text-primary-700'
63
61
  : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
64
62
  )}
65
63
  >
66
- <Icon className={cn(
67
- 'h-4 w-4 transition-transform duration-fast group-hover:scale-110',
68
- isActive ? 'text-primary' : 'text-gray-500'
69
- )} />
70
- <span className="flex-1 text-left">{item.label}</span>
71
- </button>
64
+ {({ isActive }) => (
65
+ <>
66
+ <Icon className={cn(
67
+ 'h-4 w-4 transition-transform duration-fast group-hover:scale-110',
68
+ isActive ? 'text-primary' : 'text-gray-500'
69
+ )} />
70
+ <span className="flex-1 text-left">{item.label}</span>
71
+ </>
72
+ )}
73
+ </NavLink>
72
74
  </li>
73
75
  );
74
76
  })}