@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.
@@ -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 } 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
- 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)}
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 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>
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
- </div>
82
+ </button>
103
83
  );
104
84
  }
105
85
 
106
- 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
+
107
93
  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)}
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, setLimit] = useState(100);
120
- const [activeMinutes, setActiveMinutes] = useState(0);
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 [labelDraft, setLabelDraft] = useState<Record<string, string>>({});
124
- 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('');
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
- useEffect(() => {
133
- const sessions = sessionsQuery.data?.sessions ?? [];
134
- if (!sessions.length) {
135
- 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));
136
151
  }
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;
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 saveSessionMeta = (key: string) => {
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: (labelDraft[key] ?? '').trim() || null,
188
- preferredModel: (modelDraft[key] ?? '').trim() || null
180
+ label: draftLabel.trim() || null,
181
+ preferredModel: draftModel.trim() || null
189
182
  }
190
183
  });
191
184
  };
192
185
 
193
- const clearSessionHistory = (key: string) => {
194
- updateSession.mutate({ key, data: { clearHistory: true } });
195
- if (selectedKey === key) {
196
- setSelectedKey(null);
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 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
- }
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="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>
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
- <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">
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
- 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')}
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
- <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>
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
- </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
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
- 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)}
269
+ onSelect={() => setSelectedKey(session.key)}
294
270
  />
295
271
  ))
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})
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="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
- ))}
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
- </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 ?? {})}
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
- {(updateSession.isPending || deleteSession.isPending) && <div className="text-xs text-gray-500">{t('sessionsApplyingChanges')}</div>}
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
  }