@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.
@@ -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, Save, Search, Trash2 } from 'lucide-react';
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
- type SessionRowProps = {
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
- labelValue: string;
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 SessionRow(props: SessionRowProps) {
56
- 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
+
57
55
  return (
58
- <div className="rounded-xl border border-gray-200 p-3 space-y-3">
59
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-2 text-xs text-gray-600">
60
- <div>
61
- <span className="font-semibold text-gray-800">{t('sessionsKeyLabel')}:</span> {session.key}
62
- </div>
63
- <div>
64
- <span className="font-semibold text-gray-800">{t('sessionsChannelLabel')}:</span> {displayChannelName(props.channel)}
65
- </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)}
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
- <span className="font-semibold text-gray-800">{t('sessionsLastRoleLabel')}:</span> {session.lastRole ?? '-'}
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="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
- </div>
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
- </div>
84
+ </button>
103
85
  );
104
86
  }
105
87
 
106
- function SessionMessageItem({ message, index }: { message: SessionMessageView; index: number }) {
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 key={`${message.timestamp}-${index}`} className="rounded-lg border border-gray-200 p-2">
109
- <div className="text-xs text-gray-500">
110
- <span className="font-semibold text-gray-700">{message.role}</span> · {formatDate(message.timestamp)}
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, setLimit] = useState(100);
120
- const [activeMinutes, setActiveMinutes] = useState(0);
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 [labelDraft, setLabelDraft] = useState<Record<string, string>>({});
124
- const [modelDraft, setModelDraft] = useState<Record<string, string>>({});
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
- useEffect(() => {
133
- const sessions = sessionsQuery.data?.sessions ?? [];
134
- if (!sessions.length) {
135
- return;
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
- setLabelDraft((prev) => {
138
- const next = { ...prev };
139
- for (const session of sessions) {
140
- if (!(session.key in next)) {
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 saveSessionMeta = (key: string) => {
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: (labelDraft[key] ?? '').trim() || null,
188
- preferredModel: (modelDraft[key] ?? '').trim() || null
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 clearSessionHistory = (key: string) => {
194
- updateSession.mutate({ key, data: { clearHistory: true } });
195
- if (selectedKey === key) {
196
- setSelectedKey(null);
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 deleteSessionByKey = (key: string) => {
201
- const confirmed = window.confirm(`${t('sessionsDeleteConfirm')} ${key} ?`);
202
- if (!confirmed) {
203
- return;
204
- }
205
- deleteSession.mutate(
206
- { key },
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="space-y-6 pb-20 animate-fade-in">
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>
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
- <Card>
225
- <CardHeader>
226
- <CardTitle>{t('sessionsFiltersTitle')}</CardTitle>
227
- <CardDescription>{t('sessionsFiltersDescription')}</CardDescription>
228
- </CardHeader>
229
- <CardContent className="grid grid-cols-1 md:grid-cols-5 gap-3">
230
- <div className="md:col-span-2 relative">
231
- <Search className="h-4 w-4 absolute left-3 top-3 text-gray-400" />
232
- <Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t('sessionsSearchPlaceholder')} className="pl-9" />
233
- </div>
234
- <Input
235
- type="number"
236
- min={0}
237
- value={activeMinutes}
238
- onChange={(event) => setActiveMinutes(Math.max(0, Number.parseInt(event.target.value, 10) || 0))}
239
- placeholder={t('sessionsActiveMinutesPlaceholder')}
240
- />
241
- <div className="flex gap-2">
242
- <Input
243
- type="number"
244
- min={1}
245
- value={limit}
246
- onChange={(event) => setLimit(Math.max(1, Number.parseInt(event.target.value, 10) || 1))}
247
- placeholder={t('sessionsLimitPlaceholder')}
248
- />
249
- <Button type="button" variant="outline" onClick={() => sessionsQuery.refetch()}>
250
- <RefreshCw className="h-4 w-4" />
251
- </Button>
252
- </div>
253
- <div className="space-y-1">
254
- <div className="text-xs text-gray-500">{t('sessionsGroupModeLabel')}</div>
255
- <select
256
- value={groupMode}
257
- onChange={(event) => setGroupMode(event.target.value as SessionGroupMode)}
258
- className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg bg-white"
259
- >
260
- <option value="all">{t('sessionsGroupModeAll')}</option>
261
- <option value="by-channel">{t('sessionsGroupModeByChannel')}</option>
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
- </CardContent>
265
- </Card>
266
-
267
- <Card>
268
- <CardHeader>
269
- <CardTitle>{t('sessionsListTitle')}</CardTitle>
270
- <CardDescription>
271
- {t('sessionsTotalLabel')} {sessionsQuery.data?.total ?? 0} · {t('sessionsCurrentLabel')} {sessions.length}
272
- </CardDescription>
273
- </CardHeader>
274
- <CardContent className="space-y-3">
275
- {sessionsQuery.isLoading ? <div className="text-sm text-gray-500">{t('sessionsLoading')}</div> : null}
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
- labelValue={labelDraft[session.key] ?? ''}
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
- : groupedSessions.map((group) => (
297
- <div key={group.channel} className="space-y-2">
298
- <div className="text-sm font-semibold text-gray-700">
299
- {t('sessionsChannelLabel')}: {displayChannelName(group.channel)} ({group.rows.length})
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="space-y-3">
302
- {group.rows.map((session) => (
303
- <SessionRow
304
- key={session.key}
305
- session={session}
306
- channel={group.channel}
307
- isSelected={selectedKey === session.key}
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
- ))}
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
- </CardContent>
322
- </Card>
323
-
324
- {selectedKey ? (
325
- <Card>
326
- <CardHeader>
327
- <CardTitle>
328
- {t('sessionsHistoryTitle')}: {selectedKey}
329
- </CardTitle>
330
- <CardDescription>{t('sessionsHistoryDescription')}</CardDescription>
331
- </CardHeader>
332
- <CardContent className="space-y-2">
333
- {historyQuery.isLoading ? <div className="text-sm text-gray-500">{t('sessionsHistoryLoading')}</div> : null}
334
- {historyQuery.error ? <div className="text-sm text-red-600">{(historyQuery.error as Error).message}</div> : null}
335
- {historyQuery.data ? (
336
- <div className="text-xs text-gray-500">
337
- {t('sessionsTotalLabel')}: {historyQuery.data.totalMessages} · metadata: {JSON.stringify(historyQuery.data.metadata ?? {})}
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
- {(updateSession.isPending || deleteSession.isPending) && <div className="text-xs text-gray-500">{t('sessionsApplyingChanges')}</div>}
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
  }