@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.
Files changed (98) hide show
  1. package/eslint.config.js +23 -0
  2. package/index.html +24 -0
  3. package/package.json +54 -26
  4. package/package.json.bak +89 -0
  5. package/package.json.pub +88 -0
  6. package/src/App.tsx +1173 -0
  7. package/src/components/AuthWidget.tsx +150 -0
  8. package/src/components/AutoField.tsx +206 -0
  9. package/src/components/Backdrop.tsx +93 -0
  10. package/src/components/ChatSidebar.tsx +394 -0
  11. package/src/components/DeleteConfirmDialog.tsx +40 -0
  12. package/src/components/LanguageSwitcher.tsx +186 -0
  13. package/src/components/Markdown.tsx +383 -0
  14. package/src/components/ModelInfoCard.tsx +112 -0
  15. package/src/components/ModelPickerDialog.tsx +470 -0
  16. package/src/components/OAuthLoginModal.tsx +374 -0
  17. package/src/components/OAuthProvidersCard.tsx +287 -0
  18. package/src/components/PlatformsCard.tsx +97 -0
  19. package/src/components/ScheduleBuilder.tsx +273 -0
  20. package/src/components/SidebarFooter.tsx +42 -0
  21. package/src/components/SidebarStatusStrip.tsx +72 -0
  22. package/src/components/SlashPopover.tsx +171 -0
  23. package/src/components/ThemeSwitcher.tsx +243 -0
  24. package/src/components/ToolCall.tsx +228 -0
  25. package/src/components/ToolsetConfigDrawer.tsx +448 -0
  26. package/src/contexts/PageHeaderProvider.tsx +139 -0
  27. package/src/contexts/SystemActions.tsx +120 -0
  28. package/src/contexts/page-header-context.ts +12 -0
  29. package/src/contexts/system-actions-context.ts +18 -0
  30. package/src/contexts/usePageHeader.ts +10 -0
  31. package/src/contexts/useSystemActions.ts +15 -0
  32. package/src/hooks/useModalBehavior.ts +44 -0
  33. package/src/hooks/useSidebarStatus.ts +27 -0
  34. package/src/i18n/af.ts +702 -0
  35. package/src/i18n/context.tsx +123 -0
  36. package/src/i18n/de.ts +701 -0
  37. package/src/i18n/en.ts +708 -0
  38. package/src/i18n/es.ts +701 -0
  39. package/src/i18n/fr.ts +701 -0
  40. package/src/i18n/ga.ts +702 -0
  41. package/src/i18n/hu.ts +702 -0
  42. package/src/i18n/index.ts +2 -0
  43. package/src/i18n/it.ts +701 -0
  44. package/src/i18n/ja.ts +702 -0
  45. package/src/i18n/ko.ts +702 -0
  46. package/src/i18n/pt.ts +702 -0
  47. package/src/i18n/ru.ts +702 -0
  48. package/src/i18n/tr.ts +702 -0
  49. package/src/i18n/types.ts +710 -0
  50. package/src/i18n/uk.ts +702 -0
  51. package/src/i18n/zh-hant.ts +702 -0
  52. package/src/i18n/zh.ts +698 -0
  53. package/src/index.css +274 -0
  54. package/src/lib/api.ts +1585 -0
  55. package/src/lib/dashboard-flags.ts +15 -0
  56. package/src/lib/format.ts +9 -0
  57. package/src/lib/fuzzy.ts +192 -0
  58. package/src/lib/gatewayClient.ts +253 -0
  59. package/src/lib/nested.ts +23 -0
  60. package/src/lib/resolve-page-title.ts +41 -0
  61. package/src/lib/schedule.ts +382 -0
  62. package/src/lib/slashExec.ts +163 -0
  63. package/src/lib/utils.ts +35 -0
  64. package/src/main.tsx +25 -0
  65. package/src/pages/AnalyticsPage.tsx +601 -0
  66. package/src/pages/ChannelsPage.tsx +772 -0
  67. package/src/pages/ChatPage.tsx +889 -0
  68. package/src/pages/ConfigPage.tsx +660 -0
  69. package/src/pages/CronPage.tsx +524 -0
  70. package/src/pages/DocsPage.tsx +69 -0
  71. package/src/pages/EnvPage.tsx +918 -0
  72. package/src/pages/LogsPage.tsx +246 -0
  73. package/src/pages/McpPage.tsx +757 -0
  74. package/src/pages/ModelsPage.tsx +994 -0
  75. package/src/pages/PairingPage.tsx +276 -0
  76. package/src/pages/PluginsPage.tsx +580 -0
  77. package/src/pages/ProfilesPage.tsx +559 -0
  78. package/src/pages/SessionsPage.tsx +936 -0
  79. package/src/pages/SkillsPage.tsx +557 -0
  80. package/src/pages/SystemPage.tsx +1259 -0
  81. package/src/pages/WebhooksPage.tsx +483 -0
  82. package/src/plugins/PluginPage.tsx +64 -0
  83. package/src/plugins/index.ts +6 -0
  84. package/src/plugins/registry.ts +151 -0
  85. package/src/plugins/sdk.d.ts +160 -0
  86. package/src/plugins/slots.ts +199 -0
  87. package/src/plugins/types.ts +37 -0
  88. package/src/plugins/usePlugins.ts +133 -0
  89. package/src/themes/context.tsx +443 -0
  90. package/src/themes/fonts.ts +160 -0
  91. package/src/themes/index.ts +3 -0
  92. package/src/themes/presets.ts +477 -0
  93. package/src/themes/types.ts +187 -0
  94. package/tsconfig.app.json +34 -0
  95. package/tsconfig.json +7 -0
  96. package/tsconfig.node.json +26 -0
  97. package/vite.config.ts +124 -0
  98. 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">&#183;</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">&#183;</span>
381
+ <span className="shrink-0">
382
+ {session.tool_call_count} {t.common.tools}
383
+ </span>
384
+ </>
385
+ )}
386
+ <span className="text-border">&#183;</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
+ }