@nastechai/agent 0.16.0 → 0.17.0
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/eslint.config.js +23 -0
- package/index.html +24 -0
- package/package.json +54 -26
- package/package.json.bak +89 -0
- package/package.json.pub +88 -0
- package/src/App.tsx +1173 -0
- package/src/components/AuthWidget.tsx +150 -0
- package/src/components/AutoField.tsx +206 -0
- package/src/components/Backdrop.tsx +93 -0
- package/src/components/ChatSidebar.tsx +394 -0
- package/src/components/DeleteConfirmDialog.tsx +40 -0
- package/src/components/LanguageSwitcher.tsx +186 -0
- package/src/components/Markdown.tsx +383 -0
- package/src/components/ModelInfoCard.tsx +112 -0
- package/src/components/ModelPickerDialog.tsx +470 -0
- package/src/components/OAuthLoginModal.tsx +374 -0
- package/src/components/OAuthProvidersCard.tsx +287 -0
- package/src/components/PlatformsCard.tsx +97 -0
- package/src/components/ScheduleBuilder.tsx +273 -0
- package/src/components/SidebarFooter.tsx +42 -0
- package/src/components/SidebarStatusStrip.tsx +72 -0
- package/src/components/SlashPopover.tsx +171 -0
- package/src/components/ThemeSwitcher.tsx +243 -0
- package/src/components/ToolCall.tsx +228 -0
- package/src/components/ToolsetConfigDrawer.tsx +448 -0
- package/src/contexts/PageHeaderProvider.tsx +139 -0
- package/src/contexts/SystemActions.tsx +120 -0
- package/src/contexts/page-header-context.ts +12 -0
- package/src/contexts/system-actions-context.ts +18 -0
- package/src/contexts/usePageHeader.ts +10 -0
- package/src/contexts/useSystemActions.ts +15 -0
- package/src/hooks/useModalBehavior.ts +44 -0
- package/src/hooks/useSidebarStatus.ts +27 -0
- package/src/i18n/af.ts +702 -0
- package/src/i18n/context.tsx +123 -0
- package/src/i18n/de.ts +701 -0
- package/src/i18n/en.ts +708 -0
- package/src/i18n/es.ts +701 -0
- package/src/i18n/fr.ts +701 -0
- package/src/i18n/ga.ts +702 -0
- package/src/i18n/hu.ts +702 -0
- package/src/i18n/index.ts +2 -0
- package/src/i18n/it.ts +701 -0
- package/src/i18n/ja.ts +702 -0
- package/src/i18n/ko.ts +702 -0
- package/src/i18n/pt.ts +702 -0
- package/src/i18n/ru.ts +702 -0
- package/src/i18n/tr.ts +702 -0
- package/src/i18n/types.ts +710 -0
- package/src/i18n/uk.ts +702 -0
- package/src/i18n/zh-hant.ts +702 -0
- package/src/i18n/zh.ts +698 -0
- package/src/index.css +274 -0
- package/src/lib/api.ts +1585 -0
- package/src/lib/dashboard-flags.ts +15 -0
- package/src/lib/format.ts +9 -0
- package/src/lib/fuzzy.ts +192 -0
- package/src/lib/gatewayClient.ts +253 -0
- package/src/lib/nested.ts +23 -0
- package/src/lib/resolve-page-title.ts +41 -0
- package/src/lib/schedule.ts +382 -0
- package/src/lib/slashExec.ts +163 -0
- package/src/lib/utils.ts +35 -0
- package/src/main.tsx +25 -0
- package/src/pages/AnalyticsPage.tsx +601 -0
- package/src/pages/ChannelsPage.tsx +772 -0
- package/src/pages/ChatPage.tsx +889 -0
- package/src/pages/ConfigPage.tsx +660 -0
- package/src/pages/CronPage.tsx +524 -0
- package/src/pages/DocsPage.tsx +69 -0
- package/src/pages/EnvPage.tsx +918 -0
- package/src/pages/LogsPage.tsx +246 -0
- package/src/pages/McpPage.tsx +757 -0
- package/src/pages/ModelsPage.tsx +994 -0
- package/src/pages/PairingPage.tsx +276 -0
- package/src/pages/PluginsPage.tsx +580 -0
- package/src/pages/ProfilesPage.tsx +559 -0
- package/src/pages/SessionsPage.tsx +936 -0
- package/src/pages/SkillsPage.tsx +557 -0
- package/src/pages/SystemPage.tsx +1259 -0
- package/src/pages/WebhooksPage.tsx +483 -0
- package/src/plugins/PluginPage.tsx +64 -0
- package/src/plugins/index.ts +6 -0
- package/src/plugins/registry.ts +151 -0
- package/src/plugins/sdk.d.ts +160 -0
- package/src/plugins/slots.ts +199 -0
- package/src/plugins/types.ts +37 -0
- package/src/plugins/usePlugins.ts +133 -0
- package/src/themes/context.tsx +443 -0
- package/src/themes/fonts.ts +160 -0
- package/src/themes/index.ts +3 -0
- package/src/themes/presets.ts +477 -0
- package/src/themes/types.ts +187 -0
- package/tsconfig.app.json +34 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +124 -0
- package/vite.config.ts.timestamp-1780999102396-af6b77b30ebd8.mjs +105 -0
|
@@ -0,0 +1,936 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useLayoutEffect,
|
|
4
|
+
useState,
|
|
5
|
+
useCallback,
|
|
6
|
+
useRef,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { useNavigate } from "react-router-dom";
|
|
9
|
+
import {
|
|
10
|
+
AlertTriangle,
|
|
11
|
+
CheckCircle2,
|
|
12
|
+
ChevronDown,
|
|
13
|
+
ChevronLeft,
|
|
14
|
+
ChevronRight,
|
|
15
|
+
Database,
|
|
16
|
+
MessageSquare,
|
|
17
|
+
Search,
|
|
18
|
+
Trash2,
|
|
19
|
+
Clock,
|
|
20
|
+
Terminal,
|
|
21
|
+
Globe,
|
|
22
|
+
MessageCircle,
|
|
23
|
+
Hash,
|
|
24
|
+
X,
|
|
25
|
+
Play,
|
|
26
|
+
} from "lucide-react";
|
|
27
|
+
import { api } from "@/lib/api";
|
|
28
|
+
import type {
|
|
29
|
+
SessionInfo,
|
|
30
|
+
SessionMessage,
|
|
31
|
+
SessionSearchResult,
|
|
32
|
+
StatusResponse,
|
|
33
|
+
} from "@/lib/api";
|
|
34
|
+
import { timeAgo } from "@/lib/utils";
|
|
35
|
+
import { Markdown } from "@/components/Markdown";
|
|
36
|
+
import { PlatformsCard } from "@/components/PlatformsCard";
|
|
37
|
+
import { Toast } from "@nastechai/ui/ui/components/toast";
|
|
38
|
+
import { Button } from "@nastechai/ui/ui/components/button";
|
|
39
|
+
import { ListItem } from "@nastechai/ui/ui/components/list-item";
|
|
40
|
+
import { Segmented } from "@nastechai/ui/ui/components/segmented";
|
|
41
|
+
import { Spinner } from "@nastechai/ui/ui/components/spinner";
|
|
42
|
+
import { Badge } from "@nastechai/ui/ui/components/badge";
|
|
43
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@nastechai/ui/ui/components/card";
|
|
44
|
+
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
|
45
|
+
import { useConfirmDelete } from "@nastechai/ui/hooks/use-confirm-delete";
|
|
46
|
+
import { Input } from "@nastechai/ui/ui/components/input";
|
|
47
|
+
import { useSystemActions } from "@/contexts/useSystemActions";
|
|
48
|
+
import { useToast } from "@nastechai/ui/hooks/use-toast";
|
|
49
|
+
import { useI18n } from "@/i18n";
|
|
50
|
+
import { usePageHeader } from "@/contexts/usePageHeader";
|
|
51
|
+
import { PluginSlot } from "@/plugins";
|
|
52
|
+
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
|
|
53
|
+
|
|
54
|
+
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> =
|
|
55
|
+
{
|
|
56
|
+
cli: { icon: Terminal, color: "text-primary" },
|
|
57
|
+
telegram: { icon: MessageCircle, color: "text-[oklch(0.65_0.15_250)]" },
|
|
58
|
+
discord: { icon: Hash, color: "text-[oklch(0.65_0.15_280)]" },
|
|
59
|
+
slack: { icon: MessageSquare, color: "text-[oklch(0.7_0.15_155)]" },
|
|
60
|
+
whatsapp: { icon: Globe, color: "text-success" },
|
|
61
|
+
cron: { icon: Clock, color: "text-warning" },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Render an FTS5 snippet with highlighted matches.
|
|
65
|
+
* The backend wraps matches in >>> and <<< delimiters. */
|
|
66
|
+
function SnippetHighlight({ snippet }: { snippet: string }) {
|
|
67
|
+
const parts: React.ReactNode[] = [];
|
|
68
|
+
const regex = />>>(.*?)<<</g;
|
|
69
|
+
let last = 0;
|
|
70
|
+
let match: RegExpExecArray | null;
|
|
71
|
+
let i = 0;
|
|
72
|
+
while ((match = regex.exec(snippet)) !== null) {
|
|
73
|
+
if (match.index > last) {
|
|
74
|
+
parts.push(snippet.slice(last, match.index));
|
|
75
|
+
}
|
|
76
|
+
parts.push(
|
|
77
|
+
<mark key={i++} className="bg-warning/30 text-warning px-0.5">
|
|
78
|
+
{match[1]}
|
|
79
|
+
</mark>,
|
|
80
|
+
);
|
|
81
|
+
last = regex.lastIndex;
|
|
82
|
+
}
|
|
83
|
+
if (last < snippet.length) {
|
|
84
|
+
parts.push(snippet.slice(last));
|
|
85
|
+
}
|
|
86
|
+
return (
|
|
87
|
+
<p className="font-mondwest normal-case mt-0.5 min-w-0 max-w-full truncate text-xs text-text-secondary">
|
|
88
|
+
{parts}
|
|
89
|
+
</p>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function ToolCallBlock({
|
|
94
|
+
toolCall,
|
|
95
|
+
}: {
|
|
96
|
+
toolCall: { id: string; function: { name: string; arguments: string } };
|
|
97
|
+
}) {
|
|
98
|
+
const [open, setOpen] = useState(false);
|
|
99
|
+
const { t } = useI18n();
|
|
100
|
+
|
|
101
|
+
let args = toolCall.function.arguments;
|
|
102
|
+
try {
|
|
103
|
+
args = JSON.stringify(JSON.parse(args), null, 2);
|
|
104
|
+
} catch {
|
|
105
|
+
// keep as-is
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className="mt-2 border border-warning/20 bg-warning/5">
|
|
110
|
+
<ListItem
|
|
111
|
+
onClick={() => setOpen(!open)}
|
|
112
|
+
aria-label={`${open ? t.common.collapse : t.common.expand} tool call ${toolCall.function.name}`}
|
|
113
|
+
aria-expanded={open}
|
|
114
|
+
className="px-3 py-2 text-xs text-warning hover:bg-warning/10 hover:text-warning"
|
|
115
|
+
>
|
|
116
|
+
{open ? (
|
|
117
|
+
<ChevronDown className="h-3 w-3" />
|
|
118
|
+
) : (
|
|
119
|
+
<ChevronRight className="h-3 w-3" />
|
|
120
|
+
)}
|
|
121
|
+
<span className="font-mono-ui font-medium">
|
|
122
|
+
{toolCall.function.name}
|
|
123
|
+
</span>
|
|
124
|
+
<span className="text-warning/50 ml-auto">{toolCall.id}</span>
|
|
125
|
+
</ListItem>
|
|
126
|
+
{open && (
|
|
127
|
+
<pre className="border-t border-warning/20 px-3 py-2 text-xs text-warning/80 overflow-x-auto whitespace-pre-wrap font-mono">
|
|
128
|
+
{args}
|
|
129
|
+
</pre>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function MessageBubble({
|
|
136
|
+
msg,
|
|
137
|
+
highlight,
|
|
138
|
+
}: {
|
|
139
|
+
msg: SessionMessage;
|
|
140
|
+
highlight?: string;
|
|
141
|
+
}) {
|
|
142
|
+
const { t } = useI18n();
|
|
143
|
+
|
|
144
|
+
const ROLE_STYLES: Record<
|
|
145
|
+
string,
|
|
146
|
+
{ bg: string; text: string; label: string }
|
|
147
|
+
> = {
|
|
148
|
+
user: {
|
|
149
|
+
bg: "bg-primary/10",
|
|
150
|
+
text: "text-primary",
|
|
151
|
+
label: t.sessions.roles.user,
|
|
152
|
+
},
|
|
153
|
+
assistant: {
|
|
154
|
+
bg: "bg-success/10",
|
|
155
|
+
text: "text-success",
|
|
156
|
+
label: t.sessions.roles.assistant,
|
|
157
|
+
},
|
|
158
|
+
system: {
|
|
159
|
+
bg: "bg-muted",
|
|
160
|
+
text: "text-muted-foreground",
|
|
161
|
+
label: t.sessions.roles.system,
|
|
162
|
+
},
|
|
163
|
+
tool: {
|
|
164
|
+
bg: "bg-warning/10",
|
|
165
|
+
text: "text-warning",
|
|
166
|
+
label: t.sessions.roles.tool,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const style = ROLE_STYLES[msg.role] ?? ROLE_STYLES.system;
|
|
171
|
+
const label = msg.tool_name
|
|
172
|
+
? `${t.sessions.roles.tool}: ${msg.tool_name}`
|
|
173
|
+
: style.label;
|
|
174
|
+
|
|
175
|
+
// Check if any search term appears as a prefix of any word in content
|
|
176
|
+
const isHit = (() => {
|
|
177
|
+
if (!highlight || !msg.content) return false;
|
|
178
|
+
const content = msg.content.toLowerCase();
|
|
179
|
+
const terms = highlight.toLowerCase().split(/\s+/).filter(Boolean);
|
|
180
|
+
return terms.some((term) => content.includes(term));
|
|
181
|
+
})();
|
|
182
|
+
|
|
183
|
+
// Split search query into terms for inline highlighting
|
|
184
|
+
const highlightTerms =
|
|
185
|
+
isHit && highlight ? highlight.split(/\s+/).filter(Boolean) : undefined;
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<div
|
|
189
|
+
className={`${style.bg} p-3 ${isHit ? "ring-1 ring-warning/40" : ""}`}
|
|
190
|
+
data-search-hit={isHit || undefined}
|
|
191
|
+
>
|
|
192
|
+
<div className="flex items-center gap-2 mb-1">
|
|
193
|
+
<span className={`text-xs font-semibold ${style.text}`}>{label}</span>
|
|
194
|
+
{isHit && (
|
|
195
|
+
<Badge tone="warning" className="text-xs py-0 px-1.5">
|
|
196
|
+
{t.common.match}
|
|
197
|
+
</Badge>
|
|
198
|
+
)}
|
|
199
|
+
{msg.timestamp && (
|
|
200
|
+
<span className="text-xs text-text-tertiary">
|
|
201
|
+
{timeAgo(msg.timestamp)}
|
|
202
|
+
</span>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
{msg.content &&
|
|
206
|
+
(msg.role === "system" ? (
|
|
207
|
+
<div className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
|
|
208
|
+
{msg.content}
|
|
209
|
+
</div>
|
|
210
|
+
) : (
|
|
211
|
+
<Markdown content={msg.content} highlightTerms={highlightTerms} />
|
|
212
|
+
))}
|
|
213
|
+
{msg.tool_calls && msg.tool_calls.length > 0 && (
|
|
214
|
+
<div className="mt-1">
|
|
215
|
+
{msg.tool_calls.map((tc) => (
|
|
216
|
+
<ToolCallBlock key={tc.id} toolCall={tc} />
|
|
217
|
+
))}
|
|
218
|
+
</div>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Message list with auto-scroll to first search hit. */
|
|
225
|
+
function MessageList({
|
|
226
|
+
messages,
|
|
227
|
+
highlight,
|
|
228
|
+
}: {
|
|
229
|
+
messages: SessionMessage[];
|
|
230
|
+
highlight?: string;
|
|
231
|
+
}) {
|
|
232
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
233
|
+
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
if (!highlight || !containerRef.current) return;
|
|
236
|
+
// Scroll to first hit after render
|
|
237
|
+
const timer = setTimeout(() => {
|
|
238
|
+
const hit = containerRef.current?.querySelector("[data-search-hit]");
|
|
239
|
+
if (hit) {
|
|
240
|
+
hit.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
241
|
+
}
|
|
242
|
+
}, 50);
|
|
243
|
+
return () => clearTimeout(timer);
|
|
244
|
+
}, [messages, highlight]);
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<div
|
|
248
|
+
ref={containerRef}
|
|
249
|
+
className="flex flex-col gap-3 max-h-[600px] overflow-y-auto pr-2"
|
|
250
|
+
>
|
|
251
|
+
{messages.map((msg, i) => (
|
|
252
|
+
<MessageBubble key={i} msg={msg} highlight={highlight} />
|
|
253
|
+
))}
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function SessionRow({
|
|
259
|
+
session,
|
|
260
|
+
snippet,
|
|
261
|
+
searchQuery,
|
|
262
|
+
isExpanded,
|
|
263
|
+
onToggle,
|
|
264
|
+
onDelete,
|
|
265
|
+
resumeInChatEnabled,
|
|
266
|
+
}: {
|
|
267
|
+
session: SessionInfo;
|
|
268
|
+
snippet?: string;
|
|
269
|
+
searchQuery?: string;
|
|
270
|
+
isExpanded: boolean;
|
|
271
|
+
onToggle: () => void;
|
|
272
|
+
onDelete: () => void;
|
|
273
|
+
resumeInChatEnabled: boolean;
|
|
274
|
+
}) {
|
|
275
|
+
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
|
|
276
|
+
const [loading, setLoading] = useState(false);
|
|
277
|
+
const [error, setError] = useState<string | null>(null);
|
|
278
|
+
const { t } = useI18n();
|
|
279
|
+
const navigate = useNavigate();
|
|
280
|
+
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
if (isExpanded && messages === null && !loading) {
|
|
283
|
+
setLoading(true);
|
|
284
|
+
api
|
|
285
|
+
.getSessionMessages(session.id)
|
|
286
|
+
.then((resp) => setMessages(resp.messages))
|
|
287
|
+
.catch((err) => setError(String(err)))
|
|
288
|
+
.finally(() => setLoading(false));
|
|
289
|
+
}
|
|
290
|
+
}, [isExpanded, session.id, messages, loading]);
|
|
291
|
+
|
|
292
|
+
const sourceInfo = (session.source
|
|
293
|
+
? SOURCE_CONFIG[session.source]
|
|
294
|
+
: null) ?? { icon: Globe, color: "text-muted-foreground" };
|
|
295
|
+
const SourceIcon = sourceInfo.icon;
|
|
296
|
+
const hasTitle = session.title && session.title !== "Untitled";
|
|
297
|
+
|
|
298
|
+
const actionButtons = (
|
|
299
|
+
<>
|
|
300
|
+
<Badge tone="outline" className="text-xs">
|
|
301
|
+
{session.source ?? "local"}
|
|
302
|
+
</Badge>
|
|
303
|
+
|
|
304
|
+
{resumeInChatEnabled && (
|
|
305
|
+
<Button
|
|
306
|
+
ghost
|
|
307
|
+
size="icon"
|
|
308
|
+
className="text-muted-foreground hover:text-success"
|
|
309
|
+
aria-label={t.sessions.resumeInChat}
|
|
310
|
+
title={t.sessions.resumeInChat}
|
|
311
|
+
onClick={(e) => {
|
|
312
|
+
e.stopPropagation();
|
|
313
|
+
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
|
|
314
|
+
}}
|
|
315
|
+
>
|
|
316
|
+
<Play />
|
|
317
|
+
</Button>
|
|
318
|
+
)}
|
|
319
|
+
|
|
320
|
+
<Button
|
|
321
|
+
ghost
|
|
322
|
+
destructive
|
|
323
|
+
size="icon"
|
|
324
|
+
aria-label={t.sessions.deleteSession}
|
|
325
|
+
onClick={(e) => {
|
|
326
|
+
e.stopPropagation();
|
|
327
|
+
onDelete();
|
|
328
|
+
}}
|
|
329
|
+
>
|
|
330
|
+
<Trash2 />
|
|
331
|
+
</Button>
|
|
332
|
+
</>
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
return (
|
|
336
|
+
<div
|
|
337
|
+
className={`max-w-full min-w-0 overflow-hidden border transition-colors ${
|
|
338
|
+
session.is_active
|
|
339
|
+
? "border-success/30 bg-success/[0.03]"
|
|
340
|
+
: "border-border"
|
|
341
|
+
}`}
|
|
342
|
+
>
|
|
343
|
+
<div
|
|
344
|
+
className="flex cursor-pointer items-start gap-3 p-3 transition-colors hover:bg-secondary/30"
|
|
345
|
+
onClick={onToggle}
|
|
346
|
+
>
|
|
347
|
+
<div className={`shrink-0 pt-0.5 ${sourceInfo.color}`}>
|
|
348
|
+
<SourceIcon className="h-4 w-4" />
|
|
349
|
+
</div>
|
|
350
|
+
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
|
351
|
+
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
|
|
352
|
+
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
353
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
354
|
+
<span
|
|
355
|
+
className={`font-mondwest normal-case min-w-0 flex-1 truncate text-sm ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}
|
|
356
|
+
>
|
|
357
|
+
{hasTitle
|
|
358
|
+
? session.title
|
|
359
|
+
: session.preview
|
|
360
|
+
? session.preview.slice(0, 60)
|
|
361
|
+
: t.sessions.untitledSession}
|
|
362
|
+
</span>
|
|
363
|
+
{session.is_active && (
|
|
364
|
+
<Badge tone="success" className="shrink-0 text-xs">
|
|
365
|
+
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
|
366
|
+
{t.common.live}
|
|
367
|
+
</Badge>
|
|
368
|
+
)}
|
|
369
|
+
</div>
|
|
370
|
+
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground">
|
|
371
|
+
<span className="max-w-[min(100%,12rem)] truncate sm:max-w-[180px]">
|
|
372
|
+
{(session.model ?? t.common.unknown).split("/").pop()}
|
|
373
|
+
</span>
|
|
374
|
+
<span className="text-border">·</span>
|
|
375
|
+
<span className="shrink-0">
|
|
376
|
+
{session.message_count} {t.common.msgs}
|
|
377
|
+
</span>
|
|
378
|
+
{session.tool_call_count > 0 && (
|
|
379
|
+
<>
|
|
380
|
+
<span className="text-border">·</span>
|
|
381
|
+
<span className="shrink-0">
|
|
382
|
+
{session.tool_call_count} {t.common.tools}
|
|
383
|
+
</span>
|
|
384
|
+
</>
|
|
385
|
+
)}
|
|
386
|
+
<span className="text-border">·</span>
|
|
387
|
+
<span className="shrink-0">{timeAgo(session.last_active)}</span>
|
|
388
|
+
</div>
|
|
389
|
+
{snippet && <SnippetHighlight snippet={snippet} />}
|
|
390
|
+
</div>
|
|
391
|
+
|
|
392
|
+
<div className="hidden shrink-0 items-center gap-2 sm:flex">
|
|
393
|
+
{actionButtons}
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
|
|
397
|
+
<div className="flex flex-wrap items-center gap-2 sm:hidden">
|
|
398
|
+
{actionButtons}
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
|
|
403
|
+
{isExpanded && (
|
|
404
|
+
<div className="min-w-0 border-t border-border bg-background/50 p-4">
|
|
405
|
+
{loading && (
|
|
406
|
+
<div className="flex items-center justify-center py-8">
|
|
407
|
+
<Spinner className="text-xl text-primary" />
|
|
408
|
+
</div>
|
|
409
|
+
)}
|
|
410
|
+
{error && (
|
|
411
|
+
<p className="text-sm text-destructive py-4 text-center">{error}</p>
|
|
412
|
+
)}
|
|
413
|
+
{messages && messages.length === 0 && (
|
|
414
|
+
<p className="text-sm text-muted-foreground py-4 text-center">
|
|
415
|
+
{t.sessions.noMessages}
|
|
416
|
+
</p>
|
|
417
|
+
)}
|
|
418
|
+
{messages && messages.length > 0 && (
|
|
419
|
+
<MessageList messages={messages} highlight={searchQuery} />
|
|
420
|
+
)}
|
|
421
|
+
</div>
|
|
422
|
+
)}
|
|
423
|
+
</div>
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
type SessionsView = "list" | "overview";
|
|
428
|
+
|
|
429
|
+
const PAGE_SIZE = 20;
|
|
430
|
+
|
|
431
|
+
function SessionsPagination({
|
|
432
|
+
className,
|
|
433
|
+
compact = false,
|
|
434
|
+
onPageChange,
|
|
435
|
+
page,
|
|
436
|
+
total,
|
|
437
|
+
}: SessionsPaginationProps) {
|
|
438
|
+
const { t } = useI18n();
|
|
439
|
+
const pageCount = Math.ceil(total / PAGE_SIZE);
|
|
440
|
+
|
|
441
|
+
return (
|
|
442
|
+
<div
|
|
443
|
+
className={`flex items-center ${compact ? "gap-1" : "justify-between pt-2"}${className ? ` ${className}` : ""}`}
|
|
444
|
+
>
|
|
445
|
+
{!compact && (
|
|
446
|
+
<span className="text-xs text-muted-foreground">
|
|
447
|
+
{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)}{" "}
|
|
448
|
+
{t.common.of} {total}
|
|
449
|
+
</span>
|
|
450
|
+
)}
|
|
451
|
+
|
|
452
|
+
<div className="flex items-center gap-1">
|
|
453
|
+
<Button
|
|
454
|
+
outlined
|
|
455
|
+
size="icon"
|
|
456
|
+
disabled={page === 0}
|
|
457
|
+
onClick={() => onPageChange(page - 1)}
|
|
458
|
+
aria-label={t.sessions.previousPage}
|
|
459
|
+
>
|
|
460
|
+
<ChevronLeft />
|
|
461
|
+
</Button>
|
|
462
|
+
<span className="px-2 text-xs text-muted-foreground">
|
|
463
|
+
{t.common.page} {page + 1} {t.common.of} {pageCount}
|
|
464
|
+
</span>
|
|
465
|
+
<Button
|
|
466
|
+
outlined
|
|
467
|
+
size="icon"
|
|
468
|
+
disabled={(page + 1) * PAGE_SIZE >= total}
|
|
469
|
+
onClick={() => onPageChange(page + 1)}
|
|
470
|
+
aria-label={t.sessions.nextPage}
|
|
471
|
+
>
|
|
472
|
+
<ChevronRight />
|
|
473
|
+
</Button>
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export default function SessionsPage() {
|
|
480
|
+
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
|
481
|
+
const [total, setTotal] = useState(0);
|
|
482
|
+
const [page, setPage] = useState(0);
|
|
483
|
+
const [loading, setLoading] = useState(true);
|
|
484
|
+
const [search, setSearch] = useState("");
|
|
485
|
+
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
486
|
+
const [searchResults, setSearchResults] = useState<
|
|
487
|
+
SessionSearchResult[] | null
|
|
488
|
+
>(null);
|
|
489
|
+
const [searching, setSearching] = useState(false);
|
|
490
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
|
491
|
+
const logScrollRef = useRef<HTMLPreElement | null>(null);
|
|
492
|
+
const [status, setStatus] = useState<StatusResponse | null>(null);
|
|
493
|
+
const [overviewSessions, setOverviewSessions] = useState<SessionInfo[]>([]);
|
|
494
|
+
const [view, setView] = useState<SessionsView>("overview");
|
|
495
|
+
const { toast, showToast } = useToast();
|
|
496
|
+
const { t } = useI18n();
|
|
497
|
+
const { setAfterTitle } = usePageHeader();
|
|
498
|
+
const { activeAction, actionStatus, dismissLog } = useSystemActions();
|
|
499
|
+
const resumeInChatEnabled = isDashboardEmbeddedChatEnabled();
|
|
500
|
+
|
|
501
|
+
useLayoutEffect(() => {
|
|
502
|
+
if (loading) {
|
|
503
|
+
setAfterTitle(null);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
setAfterTitle(
|
|
507
|
+
<Badge tone="secondary" className="text-xs tabular-nums">
|
|
508
|
+
{total}
|
|
509
|
+
</Badge>,
|
|
510
|
+
);
|
|
511
|
+
return () => {
|
|
512
|
+
setAfterTitle(null);
|
|
513
|
+
};
|
|
514
|
+
}, [loading, setAfterTitle, total]);
|
|
515
|
+
|
|
516
|
+
const loadSessions = useCallback((p: number) => {
|
|
517
|
+
setLoading(true);
|
|
518
|
+
api
|
|
519
|
+
.getSessions(PAGE_SIZE, p * PAGE_SIZE)
|
|
520
|
+
.then((resp) => {
|
|
521
|
+
setSessions(resp.sessions);
|
|
522
|
+
setTotal(resp.total);
|
|
523
|
+
})
|
|
524
|
+
.catch(() => {})
|
|
525
|
+
.finally(() => setLoading(false));
|
|
526
|
+
}, []);
|
|
527
|
+
|
|
528
|
+
useEffect(() => {
|
|
529
|
+
loadSessions(page);
|
|
530
|
+
}, [loadSessions, page]);
|
|
531
|
+
|
|
532
|
+
useEffect(() => {
|
|
533
|
+
const loadOverview = () => {
|
|
534
|
+
api
|
|
535
|
+
.getStatus()
|
|
536
|
+
.then(setStatus)
|
|
537
|
+
.catch(() => {});
|
|
538
|
+
api
|
|
539
|
+
.getSessions(50)
|
|
540
|
+
.then((r) => setOverviewSessions(r.sessions))
|
|
541
|
+
.catch(() => {});
|
|
542
|
+
};
|
|
543
|
+
loadOverview();
|
|
544
|
+
const id = setInterval(loadOverview, 5000);
|
|
545
|
+
return () => clearInterval(id);
|
|
546
|
+
}, []);
|
|
547
|
+
|
|
548
|
+
useEffect(() => {
|
|
549
|
+
const el = logScrollRef.current;
|
|
550
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
551
|
+
}, [actionStatus?.lines]);
|
|
552
|
+
|
|
553
|
+
// Debounced FTS search
|
|
554
|
+
useEffect(() => {
|
|
555
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
556
|
+
|
|
557
|
+
if (!search.trim()) {
|
|
558
|
+
setSearchResults(null);
|
|
559
|
+
setSearching(false);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
setSearching(true);
|
|
564
|
+
debounceRef.current = setTimeout(() => {
|
|
565
|
+
api
|
|
566
|
+
.searchSessions(search.trim())
|
|
567
|
+
.then((resp) => setSearchResults(resp.results))
|
|
568
|
+
.catch(() => setSearchResults(null))
|
|
569
|
+
.finally(() => setSearching(false));
|
|
570
|
+
}, 300);
|
|
571
|
+
|
|
572
|
+
return () => {
|
|
573
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
574
|
+
};
|
|
575
|
+
}, [search]);
|
|
576
|
+
|
|
577
|
+
const sessionDelete = useConfirmDelete({
|
|
578
|
+
onDelete: useCallback(
|
|
579
|
+
async (id: string) => {
|
|
580
|
+
try {
|
|
581
|
+
await api.deleteSession(id);
|
|
582
|
+
setSessions((prev) => prev.filter((s) => s.id !== id));
|
|
583
|
+
setTotal((prev) => prev - 1);
|
|
584
|
+
if (expandedId === id) setExpandedId(null);
|
|
585
|
+
showToast(t.sessions.sessionDeleted, "success");
|
|
586
|
+
} catch {
|
|
587
|
+
showToast(t.sessions.failedToDelete, "error");
|
|
588
|
+
throw new Error("delete failed");
|
|
589
|
+
}
|
|
590
|
+
},
|
|
591
|
+
[
|
|
592
|
+
expandedId,
|
|
593
|
+
showToast,
|
|
594
|
+
t.sessions.sessionDeleted,
|
|
595
|
+
t.sessions.failedToDelete,
|
|
596
|
+
],
|
|
597
|
+
),
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
const pendingSession = sessionDelete.pendingId
|
|
601
|
+
? sessions.find((s) => s.id === sessionDelete.pendingId)
|
|
602
|
+
: null;
|
|
603
|
+
|
|
604
|
+
// Build snippet map from search results (session_id → snippet)
|
|
605
|
+
const snippetMap = new Map<string, string>();
|
|
606
|
+
if (searchResults) {
|
|
607
|
+
for (const r of searchResults) {
|
|
608
|
+
snippetMap.set(r.session_id, r.snippet);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// When searching, filter sessions to those with FTS matches;
|
|
613
|
+
// when not searching, show all sessions
|
|
614
|
+
const filtered = searchResults
|
|
615
|
+
? sessions.filter((s) => snippetMap.has(s.id))
|
|
616
|
+
: sessions;
|
|
617
|
+
|
|
618
|
+
const platformEntries = status
|
|
619
|
+
? Object.entries(status.gateway_platforms ?? {})
|
|
620
|
+
: [];
|
|
621
|
+
const recentSessions = overviewSessions
|
|
622
|
+
.filter((s) => !s.is_active)
|
|
623
|
+
.slice(0, 5);
|
|
624
|
+
|
|
625
|
+
const isSearching = Boolean(search.trim());
|
|
626
|
+
const showOverviewTab =
|
|
627
|
+
platformEntries.length > 0 || recentSessions.length > 0;
|
|
628
|
+
const showList = view === "list" || isSearching || !showOverviewTab;
|
|
629
|
+
const showPagination = showList && !searchResults && total > PAGE_SIZE;
|
|
630
|
+
|
|
631
|
+
useEffect(() => {
|
|
632
|
+
if (isSearching) setView("list");
|
|
633
|
+
}, [isSearching]);
|
|
634
|
+
|
|
635
|
+
const alerts: { message: string; detail?: string }[] = [];
|
|
636
|
+
if (status) {
|
|
637
|
+
if (status.gateway_state === "startup_failed") {
|
|
638
|
+
alerts.push({
|
|
639
|
+
message: t.status.gatewayFailedToStart,
|
|
640
|
+
detail: status.gateway_exit_reason ?? undefined,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
const failedPlatformEntries = platformEntries.filter(
|
|
644
|
+
([, info]) => info.state === "fatal" || info.state === "disconnected",
|
|
645
|
+
);
|
|
646
|
+
for (const [name, info] of failedPlatformEntries) {
|
|
647
|
+
const stateLabel =
|
|
648
|
+
info.state === "fatal"
|
|
649
|
+
? t.status.platformError
|
|
650
|
+
: t.status.platformDisconnected;
|
|
651
|
+
alerts.push({
|
|
652
|
+
message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${stateLabel}`,
|
|
653
|
+
detail: info.error_message ?? undefined,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (loading) {
|
|
659
|
+
return (
|
|
660
|
+
<div className="flex items-center justify-center py-24">
|
|
661
|
+
<Spinner className="text-2xl text-primary" />
|
|
662
|
+
</div>
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return (
|
|
667
|
+
<div className="flex min-w-0 w-full max-w-full flex-col gap-4">
|
|
668
|
+
<PluginSlot name="sessions:top" />
|
|
669
|
+
<Toast toast={toast} />
|
|
670
|
+
|
|
671
|
+
<DeleteConfirmDialog
|
|
672
|
+
open={sessionDelete.isOpen}
|
|
673
|
+
onCancel={sessionDelete.cancel}
|
|
674
|
+
onConfirm={sessionDelete.confirm}
|
|
675
|
+
title={t.sessions.confirmDeleteTitle}
|
|
676
|
+
description={
|
|
677
|
+
pendingSession?.title && pendingSession.title !== "Untitled"
|
|
678
|
+
? `"${pendingSession.title}" — ${t.sessions.confirmDeleteMessage}`
|
|
679
|
+
: t.sessions.confirmDeleteMessage
|
|
680
|
+
}
|
|
681
|
+
loading={sessionDelete.isDeleting}
|
|
682
|
+
/>
|
|
683
|
+
|
|
684
|
+
{alerts.length > 0 && (
|
|
685
|
+
<div className="border border-destructive/30 bg-destructive/[0.06] p-4">
|
|
686
|
+
<div className="flex items-start gap-3">
|
|
687
|
+
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
|
688
|
+
<div className="flex flex-col gap-2 min-w-0">
|
|
689
|
+
{alerts.map((alert, i) => (
|
|
690
|
+
<div key={i}>
|
|
691
|
+
<p className="text-sm font-medium text-destructive">
|
|
692
|
+
{alert.message}
|
|
693
|
+
</p>
|
|
694
|
+
{alert.detail && (
|
|
695
|
+
<p className="text-xs text-destructive/70 mt-0.5">
|
|
696
|
+
{alert.detail}
|
|
697
|
+
</p>
|
|
698
|
+
)}
|
|
699
|
+
</div>
|
|
700
|
+
))}
|
|
701
|
+
</div>
|
|
702
|
+
</div>
|
|
703
|
+
</div>
|
|
704
|
+
)}
|
|
705
|
+
|
|
706
|
+
{activeAction && (
|
|
707
|
+
<div className="border border-border bg-background-base/50">
|
|
708
|
+
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
|
|
709
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
710
|
+
{actionStatus?.running ? (
|
|
711
|
+
<Spinner className="shrink-0 text-[0.875rem] text-warning" />
|
|
712
|
+
) : actionStatus?.exit_code === 0 ? (
|
|
713
|
+
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
|
|
714
|
+
) : actionStatus !== null ? (
|
|
715
|
+
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-destructive" />
|
|
716
|
+
) : (
|
|
717
|
+
<Spinner className="shrink-0 text-[0.875rem] text-muted-foreground" />
|
|
718
|
+
)}
|
|
719
|
+
|
|
720
|
+
<span className="text-xs font-mondwest tracking-[0.12em] truncate">
|
|
721
|
+
{activeAction === "restart"
|
|
722
|
+
? t.status.restartGateway
|
|
723
|
+
: t.status.updateNasTech}
|
|
724
|
+
</span>
|
|
725
|
+
|
|
726
|
+
<Badge
|
|
727
|
+
tone={
|
|
728
|
+
actionStatus?.running
|
|
729
|
+
? "warning"
|
|
730
|
+
: actionStatus?.exit_code === 0
|
|
731
|
+
? "success"
|
|
732
|
+
: actionStatus
|
|
733
|
+
? "destructive"
|
|
734
|
+
: "outline"
|
|
735
|
+
}
|
|
736
|
+
className="text-xs shrink-0"
|
|
737
|
+
>
|
|
738
|
+
{actionStatus?.running
|
|
739
|
+
? t.status.running
|
|
740
|
+
: actionStatus?.exit_code === 0
|
|
741
|
+
? t.status.actionFinished
|
|
742
|
+
: actionStatus
|
|
743
|
+
? `${t.status.actionFailed} (${actionStatus.exit_code ?? "?"})`
|
|
744
|
+
: t.common.loading}
|
|
745
|
+
</Badge>
|
|
746
|
+
</div>
|
|
747
|
+
|
|
748
|
+
<Button
|
|
749
|
+
ghost
|
|
750
|
+
size="icon"
|
|
751
|
+
onClick={dismissLog}
|
|
752
|
+
className="shrink-0 text-text-secondary hover:text-foreground"
|
|
753
|
+
aria-label={t.common.close}
|
|
754
|
+
>
|
|
755
|
+
<X />
|
|
756
|
+
</Button>
|
|
757
|
+
</div>
|
|
758
|
+
|
|
759
|
+
<pre
|
|
760
|
+
ref={logScrollRef}
|
|
761
|
+
className="max-h-72 overflow-auto px-3 py-2 font-mono-ui text-xs leading-relaxed whitespace-pre-wrap break-all"
|
|
762
|
+
>
|
|
763
|
+
{actionStatus?.lines && actionStatus.lines.length > 0
|
|
764
|
+
? actionStatus.lines.join("\n")
|
|
765
|
+
: t.status.waitingForOutput}
|
|
766
|
+
</pre>
|
|
767
|
+
</div>
|
|
768
|
+
)}
|
|
769
|
+
|
|
770
|
+
{(showOverviewTab && !isSearching) || showList ? (
|
|
771
|
+
<div className="flex w-full min-w-0 flex-wrap items-center gap-2 sm:gap-3">
|
|
772
|
+
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2 sm:gap-3">
|
|
773
|
+
{showOverviewTab && !isSearching && (
|
|
774
|
+
<Segmented
|
|
775
|
+
className="w-fit shrink-0"
|
|
776
|
+
size="md"
|
|
777
|
+
value={view}
|
|
778
|
+
onChange={setView}
|
|
779
|
+
options={[
|
|
780
|
+
{ value: "overview", label: t.sessions.overview },
|
|
781
|
+
{ value: "list", label: t.sessions.history },
|
|
782
|
+
]}
|
|
783
|
+
/>
|
|
784
|
+
)}
|
|
785
|
+
|
|
786
|
+
{showList && (
|
|
787
|
+
<div className="relative min-w-0 w-full sm:w-auto sm:min-w-[12rem] sm:max-w-md sm:flex-1">
|
|
788
|
+
{searching ? (
|
|
789
|
+
<Spinner className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[0.875rem] text-primary" />
|
|
790
|
+
) : (
|
|
791
|
+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
|
792
|
+
)}
|
|
793
|
+
<Input
|
|
794
|
+
placeholder={t.sessions.searchPlaceholder}
|
|
795
|
+
value={search}
|
|
796
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
797
|
+
className="h-8 py-0 pr-7 pl-8 text-xs leading-none"
|
|
798
|
+
/>
|
|
799
|
+
{search && (
|
|
800
|
+
<Button
|
|
801
|
+
ghost
|
|
802
|
+
size="xs"
|
|
803
|
+
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
804
|
+
onClick={() => setSearch("")}
|
|
805
|
+
aria-label={t.common.clear}
|
|
806
|
+
>
|
|
807
|
+
<X />
|
|
808
|
+
</Button>
|
|
809
|
+
)}
|
|
810
|
+
</div>
|
|
811
|
+
)}
|
|
812
|
+
</div>
|
|
813
|
+
|
|
814
|
+
{showPagination && (
|
|
815
|
+
<SessionsPagination
|
|
816
|
+
compact
|
|
817
|
+
className="shrink-0 sm:ml-auto"
|
|
818
|
+
page={page}
|
|
819
|
+
total={total}
|
|
820
|
+
onPageChange={setPage}
|
|
821
|
+
/>
|
|
822
|
+
)}
|
|
823
|
+
</div>
|
|
824
|
+
) : null}
|
|
825
|
+
|
|
826
|
+
{showList ? (
|
|
827
|
+
filtered.length === 0 ? (
|
|
828
|
+
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
|
829
|
+
<Clock className="h-8 w-8 mb-3 opacity-40" />
|
|
830
|
+
<p className="text-sm font-medium">
|
|
831
|
+
{search ? t.sessions.noMatch : t.sessions.noSessions}
|
|
832
|
+
</p>
|
|
833
|
+
{!search && (
|
|
834
|
+
<p className="text-xs mt-1 text-text-tertiary">
|
|
835
|
+
{t.sessions.startConversation}
|
|
836
|
+
</p>
|
|
837
|
+
)}
|
|
838
|
+
</div>
|
|
839
|
+
) : (
|
|
840
|
+
<>
|
|
841
|
+
<div className="flex min-w-0 flex-col gap-1.5">
|
|
842
|
+
{filtered.map((s) => (
|
|
843
|
+
<SessionRow
|
|
844
|
+
key={s.id}
|
|
845
|
+
session={s}
|
|
846
|
+
snippet={snippetMap.get(s.id)}
|
|
847
|
+
searchQuery={search || undefined}
|
|
848
|
+
isExpanded={expandedId === s.id}
|
|
849
|
+
onToggle={() =>
|
|
850
|
+
setExpandedId((prev) => (prev === s.id ? null : s.id))
|
|
851
|
+
}
|
|
852
|
+
onDelete={() => sessionDelete.requestDelete(s.id)}
|
|
853
|
+
resumeInChatEnabled={resumeInChatEnabled}
|
|
854
|
+
/>
|
|
855
|
+
))}
|
|
856
|
+
</div>
|
|
857
|
+
|
|
858
|
+
{showPagination && (
|
|
859
|
+
<SessionsPagination
|
|
860
|
+
page={page}
|
|
861
|
+
total={total}
|
|
862
|
+
onPageChange={setPage}
|
|
863
|
+
/>
|
|
864
|
+
)}
|
|
865
|
+
</>
|
|
866
|
+
)
|
|
867
|
+
) : (
|
|
868
|
+
<div className="flex min-w-0 flex-col gap-4">
|
|
869
|
+
{platformEntries.length > 0 && status && (
|
|
870
|
+
<PlatformsCard platforms={platformEntries} />
|
|
871
|
+
)}
|
|
872
|
+
|
|
873
|
+
{recentSessions.length > 0 && (
|
|
874
|
+
<Card className="min-w-0 max-w-full overflow-hidden">
|
|
875
|
+
<CardHeader className="min-w-0">
|
|
876
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
877
|
+
<Clock className="h-5 w-5 shrink-0 text-muted-foreground" />
|
|
878
|
+
<CardTitle className="min-w-0 truncate text-base">
|
|
879
|
+
{t.status.recentSessions}
|
|
880
|
+
</CardTitle>
|
|
881
|
+
</div>
|
|
882
|
+
</CardHeader>
|
|
883
|
+
|
|
884
|
+
<CardContent className="grid min-w-0 gap-3">
|
|
885
|
+
{recentSessions.map((s) => (
|
|
886
|
+
<div
|
|
887
|
+
key={s.id}
|
|
888
|
+
className="flex min-w-0 max-w-full flex-col gap-2 border border-border p-3 sm:flex-row sm:items-center sm:justify-between"
|
|
889
|
+
>
|
|
890
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
891
|
+
<span className="font-mondwest normal-case min-w-0 truncate text-sm font-medium">
|
|
892
|
+
{s.title ?? t.common.untitled}
|
|
893
|
+
</span>
|
|
894
|
+
|
|
895
|
+
<span className="min-w-0 break-words text-xs text-muted-foreground">
|
|
896
|
+
<span className="font-mono-ui">
|
|
897
|
+
{(s.model ?? t.common.unknown).split("/").pop()}
|
|
898
|
+
</span>{" "}
|
|
899
|
+
· {s.message_count} {t.common.msgs} ·{" "}
|
|
900
|
+
{timeAgo(s.last_active)}
|
|
901
|
+
</span>
|
|
902
|
+
|
|
903
|
+
{s.preview && (
|
|
904
|
+
<p className="font-mondwest normal-case min-w-0 max-w-full text-xs leading-snug text-text-tertiary [overflow-wrap:anywhere]">
|
|
905
|
+
{s.preview}
|
|
906
|
+
</p>
|
|
907
|
+
)}
|
|
908
|
+
</div>
|
|
909
|
+
|
|
910
|
+
<Badge
|
|
911
|
+
tone="outline"
|
|
912
|
+
className="shrink-0 self-start text-xs sm:self-center"
|
|
913
|
+
>
|
|
914
|
+
<Database className="mr-1 h-3 w-3" />
|
|
915
|
+
{s.source ?? "local"}
|
|
916
|
+
</Badge>
|
|
917
|
+
</div>
|
|
918
|
+
))}
|
|
919
|
+
</CardContent>
|
|
920
|
+
</Card>
|
|
921
|
+
)}
|
|
922
|
+
</div>
|
|
923
|
+
)}
|
|
924
|
+
|
|
925
|
+
<PluginSlot name="sessions:bottom" />
|
|
926
|
+
</div>
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
interface SessionsPaginationProps {
|
|
931
|
+
className?: string;
|
|
932
|
+
compact?: boolean;
|
|
933
|
+
onPageChange: (page: number) => void;
|
|
934
|
+
page: number;
|
|
935
|
+
total: number;
|
|
936
|
+
}
|