Files
aiturk-hermes-ide/web/src/pages/SessionsPage.tsx
T

2218 lines
74 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
useEffect,
useLayoutEffect,
useMemo,
useState,
useCallback,
useRef,
} from "react";
import { useNavigate } from "react-router";
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
ChevronLeft,
ChevronRight,
Database,
ListFilter,
MessageSquare,
Search,
Trash2,
Clock,
Terminal,
Globe,
MessageCircle,
Hash,
X,
Play,
Eraser,
Download,
Upload,
Pencil,
Check,
Archive,
} from "lucide-react";
import { api } from "@/lib/api";
import { formatSessionPruneResult } from "@/lib/session-prune";
import { shouldRefreshSessions } from "@/lib/session-refresh";
import {
importSummary,
parseImportSessions,
} from "@/lib/session-import";
import type {
SessionInfo,
SessionMessage,
SessionSearchResult,
SessionStoreStats,
StatusResponse,
} from "@/lib/api";
import { timeAgo } from "@/lib/utils";
import { Markdown } from "@/components/Markdown";
import { PlatformsCard } from "@/components/PlatformsCard";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { Button } from "@nous-research/ui/ui/components/button";
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Segmented } from "@nous-research/ui/ui/components/segmented";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
import { Input } from "@nous-research/ui/ui/components/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@nous-research/ui/ui/components/dialog";
import { useSystemActions } from "@/contexts/useSystemActions";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins";
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> =
{
cli: { icon: Terminal, color: "text-primary" },
tui: { icon: Terminal, color: "text-primary" },
telegram: { icon: MessageCircle, color: "text-[oklch(0.65_0.15_250)]" },
discord: { icon: Hash, color: "text-[oklch(0.65_0.15_280)]" },
slack: { icon: MessageSquare, color: "text-[oklch(0.7_0.15_155)]" },
whatsapp: { icon: Globe, color: "text-success" },
whatsapp_cloud: { icon: Globe, color: "text-success" },
signal: { icon: MessageCircle, color: "text-success" },
matrix: { icon: MessageCircle, color: "text-[oklch(0.65_0.15_250)]" },
email: { icon: MessageSquare, color: "text-[oklch(0.7_0.15_155)]" },
sms: { icon: MessageCircle, color: "text-success" },
cron: { icon: Clock, color: "text-warning" },
tool: { icon: Play, color: "text-warning" },
api_server: { icon: Globe, color: "text-muted-foreground" },
acp: { icon: Database, color: "text-muted-foreground" },
hermes_flow: { icon: Play, color: "text-warning" },
vulcan_delegate: { icon: Play, color: "text-warning" },
webhook: { icon: Globe, color: "text-warning" },
};
const AUTOMATION_SESSION_SOURCES = [
"cron",
"tool",
"api_server",
"acp",
"hermes_flow",
"vulcan_delegate",
"webhook",
];
const AUTOMATION_SESSION_SOURCE_SET = new Set(AUTOMATION_SESSION_SOURCES);
const NO_MATCHING_SESSION_SOURCE = "__hermes_dashboard_no_matching_source__";
type SessionFilterCategory = "chats" | "automation" | "all";
type SourceSelectionsByCategory = Record<SessionFilterCategory, string[] | null>;
function isAutomationSource(source: string): boolean {
return AUTOMATION_SESSION_SOURCE_SET.has(source);
}
function sourceBelongsToCategory(
source: string,
category: SessionFilterCategory,
): boolean {
if (category === "all") return true;
if (category === "automation") return isAutomationSource(source);
return !isAutomationSource(source);
}
function sourceLabel(source: string): string {
switch (source) {
case "api_server":
return "API server";
case "acp":
return "ACP";
case "cli":
return "CLI";
case "tui":
return "TUI";
case "telegram":
return "Telegram";
case "discord":
return "Discord";
case "slack":
return "Slack";
case "whatsapp":
return "WhatsApp";
case "whatsapp_cloud":
return "WhatsApp Cloud";
case "sms":
return "SMS";
case "cron":
return "Cron";
case "tool":
return "Tool";
case "hermes_flow":
return "Hermes Flow";
case "vulcan_delegate":
return "Vulcan delegate";
case "webhook":
return "Webhook";
default:
return source
.split("_")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
}
/** Render an FTS5 snippet with highlighted matches.
* The backend wraps matches in >>> and <<< delimiters. */
function SnippetHighlight({ snippet }: { snippet: string }) {
const parts: React.ReactNode[] = [];
const regex = />>>(.*?)<<</g;
let last = 0;
let match: RegExpExecArray | null;
let i = 0;
while ((match = regex.exec(snippet)) !== null) {
if (match.index > last) {
parts.push(snippet.slice(last, match.index));
}
parts.push(
<mark key={i++} className="bg-warning/30 text-warning px-0.5">
{match[1]}
</mark>,
);
last = regex.lastIndex;
}
if (last < snippet.length) {
parts.push(snippet.slice(last));
}
return (
<p className="font-mondwest normal-case mt-0.5 min-w-0 max-w-full truncate text-xs text-text-secondary">
{parts}
</p>
);
}
function ToolCallBlock({
toolCall,
}: {
toolCall: { id: string; function: { name: string; arguments: string } };
}) {
const [open, setOpen] = useState(false);
const { t } = useI18n();
let args = toolCall.function.arguments;
try {
args = JSON.stringify(JSON.parse(args), null, 2);
} catch {
// keep as-is
}
return (
<div className="mt-2 border border-warning/20 bg-warning/5">
<ListItem
onClick={() => setOpen(!open)}
aria-label={`${open ? t.common.collapse : t.common.expand} tool call ${toolCall.function.name}`}
aria-expanded={open}
className="px-3 py-2 text-xs text-warning hover:bg-warning/10 hover:text-warning"
>
{open ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
<span className="font-mono-ui font-medium">
{toolCall.function.name}
</span>
<span className="text-warning/50 ml-auto">{toolCall.id}</span>
</ListItem>
{open && (
<pre className="border-t border-warning/20 px-3 py-2 text-xs text-warning/80 overflow-x-auto whitespace-pre-wrap font-mono">
{args}
</pre>
)}
</div>
);
}
// Context-compaction handoff blocks are persisted as ``role="user"`` or
// ``role="assistant"`` with content starting with one of these prefixes —
// they're metadata inserted by ``agent/context_compressor.py``, NOT real
// turns the user typed or the model replied with. Rendering them with
// the same styling as regular messages confuses operators scrolling the
// session timeline (#29824 — "WebUI can show context compaction block
// instead of latest assistant response after compression"), so we
// detect them here and downgrade them to a muted, clearly-labelled
// "Context handoff" row.
//
// Keep these prefixes (and the END marker below) in sync with
// ``SUMMARY_PREFIX`` / ``LEGACY_SUMMARY_PREFIX`` and the
// merge-into-tail marker in ``agent/context_compressor.py``.
const COMPACTION_PREFIXES = [
"[CONTEXT COMPACTION — REFERENCE ONLY]",
"[CONTEXT COMPACTION - REFERENCE ONLY]",
"[CONTEXT SUMMARY]:",
] as const;
// Marker the compressor inserts between a merged summary and the
// original tail message content. When the summary role would collide
// with both head and tail roles (e.g. head ends with ``user`` and tail
// starts with ``assistant``), the compressor merges the summary as a
// prefix on the first tail message instead of inserting a standalone
// row. We split on this marker so the WebUI still shows the original
// assistant reply as its own readable bubble — otherwise the merged
// row reads as a single opaque "Context compaction" block and the
// user can't see the reply (#29824).
const COMPACTION_END_MARKER =
"--- END OF CONTEXT SUMMARY — respond to the message below, not the summary above ---";
interface CompactionSplit {
/** Summary text (header + body, without the end marker). */
summary: string;
/** Original message content that came after the end marker. */
remainder: string;
}
function splitCompactionContent(content: string): CompactionSplit | null {
const head = content.trimStart();
if (!COMPACTION_PREFIXES.some((p) => head.startsWith(p))) return null;
const markerIdx = content.indexOf(COMPACTION_END_MARKER);
if (markerIdx < 0) {
return { summary: content, remainder: "" };
}
return {
summary: content.slice(0, markerIdx),
remainder: content
.slice(markerIdx + COMPACTION_END_MARKER.length)
.replace(/^\s+/, ""),
};
}
function MessageBubble({
msg,
highlight,
}: {
msg: SessionMessage;
highlight?: string;
}) {
const { t } = useI18n();
const ROLE_STYLES: Record<
string,
{ bg: string; text: string; label: string }
> = {
user: {
bg: "bg-primary/10",
text: "text-primary",
label: t.sessions.roles.user,
},
assistant: {
bg: "bg-success/10",
text: "text-success",
label: t.sessions.roles.assistant,
},
system: {
bg: "bg-muted",
text: "text-muted-foreground",
label: t.sessions.roles.system,
},
tool: {
bg: "bg-warning/10",
text: "text-warning",
label: t.sessions.roles.tool,
},
// Compaction handoffs render as faded system-style metadata with a
// distinctive label so they can't be mistaken for real assistant
// replies during a scroll-back review (#29824).
compaction: {
bg: "bg-muted/50",
text: "text-muted-foreground italic",
label: "Context handoff",
},
};
// When a compaction handoff is merged into the front of the first
// tail message (the compressor's double-collision path —
// ``_merge_summary_into_tail`` in ``agent/context_compressor.py``),
// the message we received is ``[CONTEXT COMPACTION ...] + END_MARKER
// + <original assistant reply>``. We split it back into two visual
// rows here so the operator's actual answer survives as a readable
// bubble next to the (clearly-labelled) handoff metadata (#29824).
const compactionSplit =
typeof msg.content === "string"
? splitCompactionContent(msg.content)
: null;
if (compactionSplit && compactionSplit.remainder) {
return (
<>
<MessageBubble
msg={{ ...msg, content: compactionSplit.summary }}
highlight={highlight}
/>
<MessageBubble
msg={{
...msg,
content: compactionSplit.remainder,
// The remainder is the original assistant reply that the
// compressor pre-pended the summary to — render with the
// normal assistant styling, NOT the muted handoff style.
// ``isCompactionMessage`` returns false on this stripped
// content because it no longer starts with the prefix.
}}
highlight={highlight}
/>
</>
);
}
const isCompaction = compactionSplit !== null;
const style = isCompaction
? ROLE_STYLES.compaction
: ROLE_STYLES[msg.role] ?? ROLE_STYLES.system;
const label = isCompaction
? ROLE_STYLES.compaction.label
: msg.tool_name
? `${t.sessions.roles.tool}: ${msg.tool_name}`
: style.label;
// Check if any search term appears as a prefix of any word in content
const isHit = (() => {
if (!highlight || !msg.content) return false;
const content = msg.content.toLowerCase();
const terms = highlight.toLowerCase().split(/\s+/).filter(Boolean);
return terms.some((term) => content.includes(term));
})();
// Split search query into terms for inline highlighting
const highlightTerms =
isHit && highlight ? highlight.split(/\s+/).filter(Boolean) : undefined;
return (
<div
className={`${style.bg} p-3 ${isHit ? "ring-1 ring-warning/40" : ""}`}
data-search-hit={isHit || undefined}
>
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-semibold ${style.text}`}>{label}</span>
{isHit && (
<Badge tone="warning" className="text-xs py-0 px-1.5">
{t.common.match}
</Badge>
)}
{msg.timestamp && (
<span className="text-xs text-text-tertiary">
{timeAgo(msg.timestamp)}
</span>
)}
</div>
{msg.content &&
(msg.role === "system" ? (
<div className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
{msg.content}
</div>
) : (
<Markdown content={msg.content} highlightTerms={highlightTerms} />
))}
{msg.tool_calls && msg.tool_calls.length > 0 && (
<div className="mt-1">
{msg.tool_calls.map((tc) => (
<ToolCallBlock key={tc.id} toolCall={tc} />
))}
</div>
)}
</div>
);
}
/** Message list with auto-scroll to first search hit. */
function MessageList({
messages,
highlight,
}: {
messages: SessionMessage[];
highlight?: string;
}) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!highlight || !containerRef.current) return;
// Scroll to first hit after render
const timer = setTimeout(() => {
const hit = containerRef.current?.querySelector("[data-search-hit]");
if (hit) {
hit.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, 50);
return () => clearTimeout(timer);
}, [messages, highlight]);
return (
<div
ref={containerRef}
className="flex flex-col gap-3 max-h-[600px] overflow-y-auto pr-2"
>
{messages.map((msg, i) => (
<MessageBubble key={i} msg={msg} highlight={highlight} />
))}
</div>
);
}
function SessionRow({
session,
snippet,
searchQuery,
isExpanded,
isSelected,
onToggle,
onSelectClick,
onDelete,
onRename,
onExport,
resumeInChatEnabled,
}: SessionRowProps) {
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(session.title ?? "");
const [renameSaving, setRenameSaving] = useState(false);
const { t } = useI18n();
const navigate = useNavigate();
useEffect(() => {
if (!isExpanded || messages !== null) return;
let cancelled = false;
api
.getSessionMessages(session.id, session.profile)
.then((resp) => {
if (!cancelled) setMessages(resp.messages);
})
.catch((err) => {
if (!cancelled) setError(String(err));
});
return () => {
cancelled = true;
};
}, [isExpanded, session.id, session.profile, messages]);
const sourceKey = session.source?.split(":")[0];
const sourceInfo = (session.source
? SOURCE_CONFIG[session.source] ?? (sourceKey ? SOURCE_CONFIG[sourceKey] : null)
: null) ?? { icon: Globe, color: "text-muted-foreground" };
const SourceIcon = sourceInfo.icon;
const hasTitle = session.title && session.title !== "Untitled";
const submitRename = async () => {
const value = renameValue.trim();
if (!value || value === session.title) {
setRenaming(false);
return;
}
setRenameSaving(true);
try {
await onRename(session.id, value);
setRenaming(false);
} finally {
setRenameSaving(false);
}
};
const actionButtons = (
<>
<Badge tone="outline" className="text-xs">
<SourceIcon className={`mr-1 h-3 w-3 ${sourceInfo.color}`} />
{session.source ? sourceLabel(session.source) : "local"}
</Badge>
{resumeInChatEnabled && (
<Button
ghost
size="icon"
className="text-muted-foreground hover:text-success"
aria-label={t.sessions.resumeInChat}
title={t.sessions.resumeInChat}
onClick={(e) => {
e.stopPropagation();
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
}}
>
<Play />
</Button>
)}
<Button
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
aria-label="Rename session"
title="Rename session"
onClick={(e) => {
e.stopPropagation();
setRenameValue(
session.title && session.title !== "Untitled"
? session.title
: "",
);
setRenaming(true);
}}
>
<Pencil />
</Button>
<Button
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
aria-label="Export session"
title="Export session JSON"
onClick={(e) => {
e.stopPropagation();
onExport(session.id);
}}
>
<Download />
</Button>
<Button
ghost
destructive
size="icon"
aria-label={t.sessions.deleteSession}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 />
</Button>
</>
);
// Selected rows get a stronger left-edge accent + tinted background so the
// selection state is unambiguous even when scrolling past the bulk-action
// bar at the top. Beat the is_active styling — explicit user selection
// takes priority over "this session is live".
const containerClasses = isSelected
? "border-primary/40 bg-primary/[0.06]"
: session.is_active
? "border-success/30 bg-success/[0.03]"
: "border-border";
// Clicking the checkbox must NOT toggle row expansion; selection and
// expansion are independent gestures. We bind ``onClick`` directly on
// the Checkbox (which Radix forwards to its underlying ``<button
// role=checkbox>``) so the event carries the real ``shiftKey`` state
// for range-select AND so keyboard activation (Space on the focused
// checkbox) toggles selection via the same code path — the browser
// synthesises a click on <button> for Space, so one handler covers
// mouse + keyboard cleanly.
const handleSelectClick = (e: React.MouseEvent) => {
e.stopPropagation();
onSelectClick(e);
};
return (
<div
className={`max-w-full min-w-0 overflow-hidden border transition-colors ${containerClasses}`}
>
<div
className="flex cursor-pointer items-start gap-3 p-3 transition-colors hover:bg-secondary/30"
onClick={onToggle}
>
<span className="flex shrink-0 items-center pt-0.5">
<Checkbox
checked={isSelected}
onClick={handleSelectClick}
aria-label={t.sessions.selectSession}
/>
</span>
<div className={`shrink-0 pt-0.5 ${sourceInfo.color}`}>
<SourceIcon className="h-4 w-4" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-2">
{renaming ? (
<div
className="flex min-w-0 flex-1 items-center gap-1.5"
onClick={(e) => e.stopPropagation()}
>
<Input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submitRename();
else if (e.key === "Escape") setRenaming(false);
}}
placeholder="Session title"
className="h-7 min-w-0 flex-1 py-0 text-sm"
disabled={renameSaving}
/>
<Button
ghost
size="icon"
className="text-muted-foreground hover:text-success"
aria-label="Save title"
title="Save title"
disabled={renameSaving}
onClick={() => void submitRename()}
>
{renameSaving ? (
<Spinner className="text-sm" />
) : (
<Check />
)}
</Button>
<Button
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
aria-label="Cancel rename"
title="Cancel rename"
disabled={renameSaving}
onClick={() => setRenaming(false)}
>
<X />
</Button>
</div>
) : (
<span
className={`font-mondwest normal-case min-w-0 flex-1 truncate text-sm ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}
>
{hasTitle
? session.title
: session.preview
? session.preview.slice(0, 60)
: t.sessions.untitledSession}
</span>
)}
{session.is_active && (
<Badge tone="success" className="shrink-0 text-xs">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
{t.common.live}
</Badge>
)}
</div>
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground">
{session.model && (
<>
<span className="max-w-[min(100%,12rem)] truncate sm:max-w-[180px]">
{session.model.split("/").pop()}
</span>
<span className="text-border">&#183;</span>
</>
)}
<span className="shrink-0">
{session.message_count} {t.common.msgs}
</span>
{session.tool_call_count > 0 && (
<>
<span className="text-border">&#183;</span>
<span className="shrink-0">
{session.tool_call_count} {t.common.tools}
</span>
</>
)}
<span className="text-border">&#183;</span>
<span className="shrink-0">{timeAgo(session.last_active)}</span>
</div>
{snippet && <SnippetHighlight snippet={snippet} />}
</div>
<div className="hidden shrink-0 items-center gap-2 sm:flex">
{actionButtons}
</div>
</div>
<div className="flex flex-wrap items-center gap-2 sm:hidden">
{actionButtons}
</div>
</div>
</div>
{isExpanded && (
<div className="min-w-0 border-t border-border bg-background/50 p-4">
{messages === null && !error && (
<div className="flex items-center justify-center py-8">
<Spinner className="text-xl text-primary" />
</div>
)}
{error && (
<p className="text-sm text-destructive py-4 text-center">{error}</p>
)}
{messages && messages.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">
{t.sessions.noMessages}
</p>
)}
{messages && messages.length > 0 && (
<MessageList messages={messages} highlight={searchQuery} />
)}
</div>
)}
</div>
);
}
type SessionsView = "list" | "overview";
const PAGE_SIZE = 20;
function SessionsPagination({
className,
compact = false,
onPageChange,
page,
total,
}: SessionsPaginationProps) {
const { t } = useI18n();
const pageCount = Math.ceil(total / PAGE_SIZE);
return (
<div
className={`flex items-center ${compact ? "gap-1" : "justify-between pt-2"}${className ? ` ${className}` : ""}`}
>
{!compact && (
<span className="text-xs text-muted-foreground">
{page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, total)}{" "}
{t.common.of} {total}
</span>
)}
<div className="flex items-center gap-1">
<Button
outlined
size="icon"
disabled={page === 0}
onClick={() => onPageChange(page - 1)}
aria-label={t.sessions.previousPage}
>
<ChevronLeft />
</Button>
<span className="px-2 text-xs text-muted-foreground">
{t.common.page} {page + 1} {t.common.of} {pageCount}
</span>
<Button
outlined
size="icon"
disabled={(page + 1) * PAGE_SIZE >= total}
onClick={() => onPageChange(page + 1)}
aria-label={t.sessions.nextPage}
>
<ChevronRight />
</Button>
</div>
</div>
);
}
export default function SessionsPage() {
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [searchResults, setSearchResults] = useState<
SessionSearchResult[] | null
>(null);
const [searching, setSearching] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const importInputRef = useRef<HTMLInputElement | null>(null);
const logScrollRef = useRef<HTMLPreElement | null>(null);
const [status, setStatus] = useState<StatusResponse | null>(null);
const [overviewSessions, setOverviewSessions] = useState<SessionInfo[]>([]);
const [view, setView] = useState<SessionsView>("overview");
const [sessionCategory, setSessionCategory] =
useState<SessionFilterCategory>("chats");
const [sourceSelectionsByCategory, setSourceSelectionsByCategory] =
useState<SourceSelectionsByCategory>({
chats: null,
automation: null,
all: null,
});
const [sourceMenuOpen, setSourceMenuOpen] = useState(false);
const sourceMenuRef = useRef<HTMLDivElement | null>(null);
const sessionsRequestRef = useRef(0);
// Count of empty (no-message, ended, non-archived) sessions across the
// entire DB, populated by /api/sessions/empty/count. Used to:
// • hide the "Delete empty" button when there's nothing to clean up
// • show "(N)" alongside the label
// • surface the count in the confirm dialog body
// Refreshed on mount, after single-session deletes, and after the bulk
// delete itself — none of those code paths can update the global empty
// count from local state alone (per-page list != global DB count).
const [emptyCount, setEmptyCount] = useState(0);
const [deleteEmptyOpen, setDeleteEmptyOpen] = useState(false);
const [deletingEmpty, setDeletingEmpty] = useState(false);
// Bulk-select-then-delete state. ``selectedIds`` is a Set so per-row
// checkbox toggles and ``has()`` lookups are O(1); we wrap mutations
// in a fresh Set so React notices the change (mutating in place
// wouldn't trigger a re-render).
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Index of the last row whose checkbox was clicked WITHOUT shift,
// resolved against the currently visible (post-search) ``filtered``
// list. Used as the anchor for shift-click range select — matches the
// Gmail / Notion / file-explorer convention. ``null`` means "no
// anchor yet", in which case shift-click degrades to a plain toggle.
const lastClickedIndexRef = useRef<number | null>(null);
const [deleteSelectedOpen, setDeleteSelectedOpen] = useState(false);
const [deletingSelected, setDeletingSelected] = useState(false);
const [stats, setStats] = useState<SessionStoreStats | null>(null);
const [pruneOpen, setPruneOpen] = useState(false);
const [pruneDays, setPruneDays] = useState("90");
const [pruning, setPruning] = useState(false);
const [importingSessions, setImportingSessions] = useState(false);
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
const { activeAction, actionStatus, dismissLog } = useSystemActions();
const resumeInChatEnabled = isDashboardEmbeddedChatEnabled();
const selectedSources = sourceSelectionsByCategory[sessionCategory];
const pinnedSourceSelections = useMemo(
() =>
Object.values(sourceSelectionsByCategory).flatMap(
(selection) => selection ?? [],
),
[sourceSelectionsByCategory],
);
const allSourceOptions = useMemo(() => {
const entries = Object.entries(stats?.by_source ?? {}).sort(
([aSource, aCount], [bSource, bCount]) =>
bCount - aCount || sourceLabel(aSource).localeCompare(sourceLabel(bSource)),
);
const seen = new Set(entries.map(([source]) => source));
for (const source of pinnedSourceSelections) {
if (!seen.has(source)) {
entries.unshift([source, 0]);
seen.add(source);
}
}
return entries;
}, [pinnedSourceSelections, stats]);
const allSourceNames = useMemo(
() => allSourceOptions.map(([source]) => source),
[allSourceOptions],
);
const sessionQueryOptions = useMemo(() => {
if (selectedSources !== null) {
if (selectedSources.length === 0) {
return allSourceNames.length > 0
? { excludeSources: allSourceNames }
: { source: NO_MATCHING_SESSION_SOURCE };
}
if (selectedSources.length === 1) {
return { source: selectedSources[0] };
}
const selected = new Set(selectedSources);
const excludedSources = allSourceNames.filter(
(source) => !selected.has(source),
);
return excludedSources.length > 0 ? { excludeSources: excludedSources } : {};
}
if (sessionCategory === "chats") {
return { excludeSources: AUTOMATION_SESSION_SOURCES };
}
if (sessionCategory === "automation") {
const excludedSources = allSourceNames.filter(
(source) => !isAutomationSource(source),
);
return excludedSources.length > 0
? { excludeSources: excludedSources }
: { sources: AUTOMATION_SESSION_SOURCES };
}
return {};
}, [selectedSources, sessionCategory, allSourceNames]);
const categoryDefaultSources = useMemo(() => {
return allSourceNames.filter((source) =>
sourceBelongsToCategory(source, sessionCategory),
);
}, [sessionCategory, allSourceNames]);
const sourceOptions = useMemo(() => {
const selected = new Set(selectedSources ?? []);
return allSourceOptions.filter(
([source]) =>
sourceBelongsToCategory(source, sessionCategory) || selected.has(source),
);
}, [allSourceOptions, selectedSources, sessionCategory]);
const effectiveSelectedSources = selectedSources ?? categoryDefaultSources;
const selectedSourceSet = useMemo(
() => new Set(effectiveSelectedSources),
[effectiveSelectedSources],
);
const defaultSourceFilterLabel = useMemo(() => {
if (sessionCategory === "chats") return "Any chat source";
if (sessionCategory === "automation") return "Any automation source";
return t.sessions.anySource;
}, [sessionCategory, t.sessions.anySource]);
const sourceMenuTitle = useMemo(() => {
if (sessionCategory === "chats") return "Chat sources";
if (sessionCategory === "automation") return "Automation sources";
return t.sessions.sourceFilter;
}, [sessionCategory, t.sessions.sourceFilter]);
const sourceFilterLabel = useMemo(() => {
if (selectedSources === null) {
return defaultSourceFilterLabel;
}
if (selectedSources.length === 0) {
return "No sources";
}
if (selectedSources.length === 1) {
return sourceLabel(selectedSources[0]);
}
return `${selectedSources.length} sources`;
}, [defaultSourceFilterLabel, selectedSources]);
const refreshEmptyCount = useCallback(() => {
api
.getEmptySessionsCount()
.then((r) => setEmptyCount(r.count))
.catch(() => {});
}, []);
const clearSelection = useCallback(() => {
setSelectedIds(new Set());
lastClickedIndexRef.current = null;
}, []);
useLayoutEffect(() => {
if (loading) {
setAfterTitle(null);
return;
}
setAfterTitle(
<Badge tone="secondary" className="text-xs tabular-nums">
{total}
</Badge>,
);
return () => {
setAfterTitle(null);
};
}, [loading, setAfterTitle, total]);
useEffect(() => {
setEnd(
<Button
outlined
size="sm"
onClick={() => setPruneOpen(true)}
prefix={<Archive />}
>
Prune old sessions
</Button>,
);
return () => {
setEnd(null);
};
}, [setEnd]);
useEffect(() => {
if (!sourceMenuOpen) return;
const handlePointerDown = (event: PointerEvent) => {
if (!sourceMenuRef.current?.contains(event.target as Node)) {
setSourceMenuOpen(false);
}
};
document.addEventListener("pointerdown", handlePointerDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
};
}, [sourceMenuOpen]);
const loadSessions = useCallback((p: number, silent = false) => {
// ``silent`` skips the loading spinner so background refreshes
// (triggered when the overview poll detects a new session from
// another process) don't flicker the whole page or drop the user's
// scroll position.
const requestId = silent
? sessionsRequestRef.current
: sessionsRequestRef.current + 1;
if (!silent) sessionsRequestRef.current = requestId;
if (!silent) setLoading(true);
api
.getSessions(PAGE_SIZE, p * PAGE_SIZE, sessionQueryOptions)
.then((resp) => {
if (requestId !== sessionsRequestRef.current) return;
setSessions(resp.sessions);
setTotal(resp.total);
})
.catch(() => {})
.finally(() => {
if (requestId !== sessionsRequestRef.current) return;
if (!silent) setLoading(false);
});
}, [sessionQueryOptions]);
const loadStats = useCallback(() => {
api
.getSessionStats()
.then(setStats)
.catch(() => {});
}, []);
const handleImportSessions = useCallback(
async (files: FileList | null) => {
const file = files?.[0];
if (!file) return;
setImportingSessions(true);
try {
const text = await file.text();
const importedSessions = parseImportSessions(text);
const result = await api.importSessions(importedSessions);
showToast(`Import complete: ${importSummary(result)}`, "success");
clearSelection();
loadSessions(page, true);
loadStats();
refreshEmptyCount();
} catch (error) {
showToast(`Import failed: ${error}`, "error");
} finally {
setImportingSessions(false);
if (importInputRef.current) importInputRef.current.value = "";
}
},
[
clearSelection,
loadSessions,
loadStats,
page,
refreshEmptyCount,
showToast,
],
);
useEffect(() => {
loadStats();
}, [loadStats]);
// Refs for the overview poll's new-session detection. The poll effect
// below is mounted once with stable deps, so it reads the current page
// and the last-seen newest session id through refs instead of capturing
// stale values. ``newestSeenRef`` starts null so the first poll sets a
// baseline without triggering a redundant reload (mount already loads).
const newestSeenRef = useRef<string | null>(null);
const pageRef = useRef(page);
useEffect(() => {
pageRef.current = page;
}, [page]);
useEffect(() => {
let cancelled = false;
queueMicrotask(() => {
if (cancelled) return;
loadSessions(page);
refreshEmptyCount();
});
return () => {
cancelled = true;
};
}, [loadSessions, page, refreshEmptyCount]);
useEffect(() => {
let cancelled = false;
const loadOverview = () => {
api
.getStatus()
.then((nextStatus) => {
if (!cancelled) setStatus(nextStatus);
})
.catch(() => {});
api
.getSessions(50, 0, sessionQueryOptions)
.then((r) => {
if (cancelled) return;
setOverviewSessions(r.sessions);
// The dashboard server and a terminal CLI are separate
// processes sharing one session DB — there is no push channel,
// so we detect sessions created in another process here. The
// overview poll already fetches the 50 newest sessions, so we
// reuse its head id as a cheap change signal: when it changes,
// silently refresh the paginated list so the new session shows
// up in real time without a visible loading flicker.
const newest = r.sessions[0]?.id ?? null;
if (shouldRefreshSessions(newestSeenRef.current, newest)) {
loadSessions(pageRef.current, true);
}
newestSeenRef.current = newest;
})
.catch(() => {});
};
loadOverview();
const id = setInterval(loadOverview, 5000);
return () => {
cancelled = true;
clearInterval(id);
};
}, [loadSessions, sessionQueryOptions]);
useEffect(() => {
const el = logScrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [actionStatus?.lines]);
// Wrapped setters that ALSO clear the bulk selection. The user's
// mental model is "I'm selecting what I can see" — carrying a
// selection across a page change, search input, or view switch
// would arm invisible rows for deletion, which is the exact footgun
// the confirm dialog can't catch. Doing this at the call sites
// instead of in a ``useEffect`` keeps us out of the
// react-hooks/set-state-in-effect lint trap and the cascading
// re-render it warns about.
const goToPage = useCallback(
(p: number) => {
setPage(p);
clearSelection();
},
[clearSelection],
);
const updateSearch = useCallback(
(value: string) => {
setSearch(value);
if (value.trim()) setView("list");
clearSelection();
},
[clearSelection],
);
const switchView = useCallback(
(next: SessionsView) => {
setView(next);
clearSelection();
},
[clearSelection],
);
const updateSessionCategory = useCallback(
(value: string) => {
setSessionCategory(value as SessionFilterCategory);
setSourceMenuOpen(false);
setPage(0);
setExpandedId(null);
clearSelection();
},
[clearSelection],
);
const toggleSourceFilter = useCallback(
(source: string) => {
setSourceSelectionsByCategory((currentByCategory) => {
const current = currentByCategory[sessionCategory];
const next = new Set(current ?? categoryDefaultSources);
if (next.has(source)) {
next.delete(source);
} else {
next.add(source);
}
const nextSelection = sourceOptions
.map(([optionSource]) => optionSource)
.filter((optionSource) => next.has(optionSource));
return {
...currentByCategory,
[sessionCategory]: nextSelection,
};
});
setPage(0);
setExpandedId(null);
clearSelection();
},
[categoryDefaultSources, clearSelection, sessionCategory, sourceOptions],
);
const clearSourceFilters = useCallback(() => {
setSourceSelectionsByCategory((currentByCategory) => ({
...currentByCategory,
[sessionCategory]: null,
}));
setPage(0);
setExpandedId(null);
clearSelection();
}, [clearSelection, sessionCategory]);
// Debounced FTS search
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (!search.trim()) {
debounceRef.current = setTimeout(() => {
setSearchResults(null);
setSearching(false);
}, 0);
return;
}
debounceRef.current = setTimeout(() => {
setSearching(true);
setSearchResults(null);
api
.searchSessions(search.trim(), sessionQueryOptions)
.then((resp) => setSearchResults(resp.results))
.catch(() => setSearchResults(null))
.finally(() => setSearching(false));
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [search, sessionQueryOptions]);
// The profile a listed row was read from — the store that owns it. Every
// per-row request (delete, rename, export, messages) must go there, not to
// the global management profile, which lags the row (it stays "" while the
// sticky active profile equals the dashboard process's own, so the request
// hits the process store — a delete then "succeeds" as already_absent).
// Search rows carry no stamp: undefined falls back to the management profile.
const rowProfile = useCallback(
(id: string) => sessions.find((s) => s.id === id)?.profile,
[sessions],
);
const sessionDelete = useConfirmDelete({
onDelete: useCallback(
async (id: string) => {
try {
await api.deleteSession(id, rowProfile(id));
setSessions((prev) => prev.filter((s) => s.id !== id));
setTotal((prev) => prev - 1);
if (expandedId === id) setExpandedId(null);
// Drop the deleted ID from any active bulk-select set — it
// can't bulk-delete a row that's already gone.
setSelectedIds((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
// A single-session delete might have been an empty one — re-fetch
// the global empty count so the button hides itself / its badge
// ticks down without waiting for the next page navigation.
refreshEmptyCount();
showToast(t.sessions.sessionDeleted, "success");
loadStats();
} catch {
showToast(t.sessions.failedToDelete, "error");
throw new Error("delete failed");
}
},
[
expandedId,
refreshEmptyCount,
rowProfile,
showToast,
loadStats,
t.sessions.sessionDeleted,
t.sessions.failedToDelete,
],
),
});
/** Toggle one row's selection. When ``event.shiftKey`` is true AND we
* have a previous anchor, every row between the anchor and the
* current index (inclusive) is set to the current row's NEW state —
* matches Gmail/Notion/file-explorer semantics. ``visibleList`` must
* be the currently rendered list (post-search), since indices are
* resolved against what the user is actually looking at.
*/
const handleSelectClick = useCallback(
(event: React.MouseEvent, index: number, visibleList: SessionInfo[]) => {
const id = visibleList[index]?.id;
if (!id) return;
setSelectedIds((prev) => {
const next = new Set(prev);
const wasSelected = next.has(id);
const willSelect = !wasSelected;
const anchor = lastClickedIndexRef.current;
// Shift-click extends the selection from the anchor to here.
// Skip if there's no anchor or the anchor is outside the
// visible list — in those cases fall through to a plain toggle
// (the click also resets the anchor below).
if (event.shiftKey && anchor !== null && anchor < visibleList.length) {
const [lo, hi] =
anchor <= index ? [anchor, index] : [index, anchor];
for (let i = lo; i <= hi; i++) {
const rowId = visibleList[i]?.id;
if (!rowId) continue;
if (willSelect) next.add(rowId);
else next.delete(rowId);
}
} else if (willSelect) {
next.add(id);
} else {
next.delete(id);
}
return next;
});
// Always update the anchor to the most recent click — even when
// it was a shift-click that extended a range, the user's next
// shift-click should anchor from here, not from two steps back.
lastClickedIndexRef.current = index;
},
[],
);
const selectAllOnPage = useCallback((visibleList: SessionInfo[]) => {
setSelectedIds((prev) => {
const next = new Set(prev);
for (const s of visibleList) next.add(s.id);
return next;
});
}, []);
const handleDeleteSelected = useCallback(async () => {
const ids = Array.from(selectedIds);
if (ids.length === 0) {
setDeleteSelectedOpen(false);
return;
}
setDeletingSelected(true);
try {
// The selection comes from one listed page, so its rows share one
// owning profile; a mixed selection falls back to the management profile.
const owners = new Set(ids.map(rowProfile));
const resp = await api.bulkDeleteSessions(
ids,
owners.size === 1 ? [...owners][0] : undefined,
);
showToast(
t.sessions.selectedSessionsDeleted.replace(
"{count}",
String(resp.deleted),
),
"success",
);
setDeleteSelectedOpen(false);
// Drop deleted rows out of the visible list immediately rather
// than waiting for the reload. The reload still runs so total /
// pagination stays correct, and so any rows the reload pulls in
// from later pages render in place.
const deletedSet = new Set(ids);
setSessions((prev) => prev.filter((s) => !deletedSet.has(s.id)));
setTotal((prev) => Math.max(0, prev - resp.deleted));
if (expandedId && deletedSet.has(expandedId)) setExpandedId(null);
clearSelection();
loadSessions(page);
refreshEmptyCount();
} catch {
showToast(t.sessions.failedToDeleteSelected, "error");
} finally {
setDeletingSelected(false);
}
}, [
clearSelection,
expandedId,
loadSessions,
page,
refreshEmptyCount,
rowProfile,
selectedIds,
showToast,
t.sessions.failedToDeleteSelected,
t.sessions.selectedSessionsDeleted,
]);
const handleDeleteEmpty = useCallback(async () => {
setDeletingEmpty(true);
try {
const resp = await api.deleteEmptySessions();
// Show count in the toast so users get confirmation of the actual
// number removed (which may differ slightly from `emptyCount` if a
// session entered/left the "empty" set between the count fetch and
// the delete — e.g. an active session just ended without sending
// any messages).
showToast(
t.sessions.emptySessionsDeleted.replace(
"{count}",
String(resp.deleted),
),
"success",
);
setDeleteEmptyOpen(false);
// Reload the current page so any newly-vanished empty sessions
// drop out of the visible list, and re-fetch the empty count so
// the button hides itself.
loadSessions(page);
refreshEmptyCount();
} catch {
showToast(t.sessions.failedToDeleteEmpty, "error");
} finally {
setDeletingEmpty(false);
}
}, [
loadSessions,
page,
refreshEmptyCount,
showToast,
t.sessions.emptySessionsDeleted,
t.sessions.failedToDeleteEmpty,
]);
const handleRename = useCallback(
async (id: string, title: string) => {
try {
await api.renameSession(id, title, rowProfile(id));
setSessions((prev) =>
prev.map((s) => (s.id === id ? { ...s, title } : s)),
);
setOverviewSessions((prev) =>
prev.map((s) => (s.id === id ? { ...s, title } : s)),
);
showToast("Session renamed", "success");
loadStats();
} catch {
showToast("Failed to rename session", "error");
}
},
[rowProfile, showToast, loadStats],
);
const handleExport = useCallback(
async (id: string) => {
try {
const res = await fetch(api.exportSessionUrl(id, rowProfile(id)), {
credentials: "include",
headers: {
"X-Hermes-Session-Token":
(window as unknown as { __HERMES_SESSION_TOKEN__?: string })
.__HERMES_SESSION_TOKEN__ ?? "",
},
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `session-${id}.json`;
a.click();
URL.revokeObjectURL(url);
} catch {
showToast("Failed to export session", "error");
}
},
[rowProfile, showToast],
);
const handlePrune = useCallback(async () => {
const days = parseInt(pruneDays, 10);
if (!Number.isFinite(days) || days < 0) {
showToast("Enter a valid number of days", "error");
return;
}
setPruning(true);
try {
const resp = await api.pruneSessions(days);
showToast(formatSessionPruneResult(resp), "success");
setPruneOpen(false);
loadSessions(0);
setPage(0);
loadStats();
} catch {
showToast("Failed to prune sessions", "error");
} finally {
setPruning(false);
}
}, [pruneDays, showToast, loadSessions, loadStats]);
const pendingSession = sessionDelete.pendingId
? sessions.find((s) => s.id === sessionDelete.pendingId)
: null;
// Build snippet map from search results (session_id → snippet)
const snippetMap = new Map<string, string>();
if (searchResults) {
for (const r of searchResults) {
snippetMap.set(r.session_id, r.snippet);
snippetMap.set(r.id, r.snippet);
}
}
const filtered = searchResults ?? sessions;
const platformEntries = status
? Object.entries(status.gateway_platforms ?? {})
: [];
const recentSessions = overviewSessions
.filter((s) => !s.is_active)
.slice(0, 5);
const isSearching = Boolean(search.trim());
const showOverviewTab =
platformEntries.length > 0 || recentSessions.length > 0;
const showList = view === "list" || isSearching || !showOverviewTab;
const showPagination = showList && !isSearching && total > PAGE_SIZE;
const alerts: { message: string; detail?: string }[] = [];
if (status) {
if (status.gateway_state === "startup_failed") {
alerts.push({
message: t.status.gatewayFailedToStart,
detail: status.gateway_exit_reason ?? undefined,
});
}
const failedPlatformEntries = platformEntries.filter(
([, info]) => info.state === "fatal" || info.state === "disconnected",
);
for (const [name, info] of failedPlatformEntries) {
const stateLabel =
info.state === "fatal"
? t.status.platformError
: t.status.platformDisconnected;
alerts.push({
message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${stateLabel}`,
detail: info.error_message ?? undefined,
});
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
return (
<div className="flex min-w-0 w-full max-w-full flex-col gap-4">
<PluginSlot name="sessions:top" />
<Toast toast={toast} />
<input
ref={importInputRef}
type="file"
accept=".json,.jsonl,application/json,application/x-ndjson"
className="hidden"
onChange={(event) => void handleImportSessions(event.currentTarget.files)}
/>
<DeleteConfirmDialog
open={sessionDelete.isOpen}
onCancel={sessionDelete.cancel}
onConfirm={sessionDelete.confirm}
title={t.sessions.confirmDeleteTitle}
description={
pendingSession?.title && pendingSession.title !== "Untitled"
? `"${pendingSession.title}" — ${t.sessions.confirmDeleteMessage}`
: t.sessions.confirmDeleteMessage
}
loading={sessionDelete.isDeleting}
/>
<DeleteConfirmDialog
open={deleteEmptyOpen}
onCancel={() => setDeleteEmptyOpen(false)}
onConfirm={handleDeleteEmpty}
title={t.sessions.deleteEmptyConfirmTitle}
description={t.sessions.deleteEmptyConfirmMessage.replace(
"{count}",
String(emptyCount),
)}
loading={deletingEmpty}
/>
<DeleteConfirmDialog
open={deleteSelectedOpen}
onCancel={() => setDeleteSelectedOpen(false)}
onConfirm={handleDeleteSelected}
title={t.sessions.deleteSelectedConfirmTitle.replace(
"{count}",
String(selectedIds.size),
)}
description={t.sessions.deleteSelectedConfirmMessage.replace(
"{count}",
String(selectedIds.size),
)}
loading={deletingSelected}
/>
<Dialog
open={pruneOpen}
onOpenChange={(open) => {
if (!pruning) setPruneOpen(open);
}}
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Prune old sessions</DialogTitle>
<DialogDescription>
Permanently remove archived sessions whose last activity is older
than the given number of days. Active sessions are never pruned.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<label
htmlFor="prune-days"
className="text-xs font-medium text-muted-foreground"
>
Older than (days)
</label>
<Input
id="prune-days"
type="number"
min={0}
value={pruneDays}
onChange={(e) => setPruneDays(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handlePrune();
}}
disabled={pruning}
/>
</div>
<DialogFooter>
<Button
outlined
onClick={() => setPruneOpen(false)}
disabled={pruning}
>
{t.common.cancel}
</Button>
<Button
destructive
onClick={() => void handlePrune()}
disabled={pruning}
className="gap-1.5"
>
{pruning && <Spinner className="text-sm" />}
Prune
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{stats && (
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 border border-border bg-background-base/40 px-4 py-3">
<div className="flex flex-col">
<span className="text-lg font-semibold tabular-nums leading-none">
{stats.total}
</span>
<span className="text-xs text-muted-foreground">Total</span>
</div>
<div className="flex flex-col">
<span className="text-lg font-semibold tabular-nums leading-none text-success">
{stats.active_store}
</span>
<span className="text-xs text-muted-foreground">Active in store</span>
</div>
<div className="flex flex-col">
<span className="text-lg font-semibold tabular-nums leading-none">
{stats.archived}
</span>
<span className="text-xs text-muted-foreground">Archived</span>
</div>
<div className="flex flex-col">
<span className="text-lg font-semibold tabular-nums leading-none">
{stats.messages}
</span>
<span className="text-xs text-muted-foreground">Messages</span>
</div>
{Object.keys(stats.by_source).length > 0 && (
<div className="flex flex-col">
<span className="text-lg font-semibold tabular-nums leading-none">
{Object.keys(stats.by_source).length}
</span>
<span className="text-xs text-muted-foreground">Sources</span>
</div>
)}
</div>
)}
{alerts.length > 0 && (
<div className="border border-destructive/30 bg-destructive/[0.06] p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="flex flex-col gap-2 min-w-0">
{alerts.map((alert, i) => (
<div key={i}>
<p className="text-sm font-medium text-destructive">
{alert.message}
</p>
{alert.detail && (
<p className="text-xs text-destructive/70 mt-0.5">
{alert.detail}
</p>
)}
</div>
))}
</div>
</div>
</div>
)}
{activeAction && (
<div className="border border-border bg-background-base/50">
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
{actionStatus?.running ? (
<Spinner className="shrink-0 text-[0.875rem] text-warning" />
) : actionStatus?.exit_code === 0 ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
) : actionStatus !== null ? (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-destructive" />
) : (
<Spinner className="shrink-0 text-[0.875rem] text-muted-foreground" />
)}
<span className="text-xs font-mondwest tracking-[0.12em] truncate">
{activeAction === "restart"
? t.status.restartGateway
: t.status.updateHermes}
</span>
<Badge
tone={
actionStatus?.running
? "warning"
: actionStatus?.exit_code === 0
? "success"
: actionStatus
? "destructive"
: "outline"
}
className="text-xs shrink-0"
>
{actionStatus?.running
? t.status.running
: actionStatus?.exit_code === 0
? t.status.actionFinished
: actionStatus
? `${t.status.actionFailed} (${actionStatus.exit_code ?? "?"})`
: t.common.loading}
</Badge>
</div>
<Button
ghost
size="icon"
onClick={dismissLog}
className="shrink-0 text-text-secondary hover:text-foreground"
aria-label={t.common.close}
>
<X />
</Button>
</div>
<pre
ref={logScrollRef}
className="max-h-72 overflow-auto px-3 py-2 font-mono-ui text-xs leading-relaxed whitespace-pre-wrap break-all"
>
{actionStatus?.lines && actionStatus.lines.length > 0
? actionStatus.lines.join("\n")
: t.status.waitingForOutput}
</pre>
</div>
)}
{(showOverviewTab && !isSearching) || showList ? (
<div className="flex w-full min-w-0 flex-wrap items-center gap-2 sm:gap-3">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2 sm:gap-3">
<Segmented
className="w-fit shrink-0"
size="md"
value={sessionCategory}
onChange={updateSessionCategory}
options={[
{ value: "chats", label: t.sessions.filterChats },
{ value: "automation", label: t.sessions.filterAutomation },
{ value: "all", label: t.sessions.filterAll },
]}
/>
<div ref={sourceMenuRef} className="relative shrink-0">
<Button
outlined
size="sm"
prefix={<ListFilter />}
suffix={
<ChevronDown
className={`transition-transform ${sourceMenuOpen ? "rotate-180" : ""}`}
/>
}
className="h-8 min-w-[10rem] max-w-[14rem] justify-between text-xs"
aria-label={t.sessions.sourceFilter}
aria-expanded={sourceMenuOpen}
onClick={() => setSourceMenuOpen((open) => !open)}
>
<span className="min-w-0 truncate">{sourceFilterLabel}</span>
</Button>
{sourceMenuOpen && (
<div
className="absolute left-0 top-full z-30 mt-1 w-[18rem] max-w-[calc(100vw-2rem)] border border-border bg-background-base shadow-lg"
>
<div className="flex items-center justify-between gap-2 border-b border-border px-2 py-1.5">
<span className="min-w-0 truncate text-xs text-muted-foreground">
{sourceMenuTitle}
</span>
{selectedSources !== null && (
<Button
ghost
size="xs"
onClick={clearSourceFilters}
className="shrink-0"
>
{t.common.clear}
</Button>
)}
</div>
<div className="max-h-64 overflow-y-auto p-1">
{sourceOptions.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
{sourceMenuTitle}
</div>
) : (
sourceOptions.map(([source, count]) => {
const selected = selectedSourceSet.has(source);
const SourceIcon = SOURCE_CONFIG[source]?.icon ?? Terminal;
const sourceColor =
SOURCE_CONFIG[source]?.color ?? "text-muted-foreground";
return (
<div
key={source}
className="flex min-w-0 items-center gap-2 px-2 py-1.5 hover:bg-secondary/40"
>
<Checkbox
checked={selected}
onClick={(event) => {
event.stopPropagation();
toggleSourceFilter(source);
}}
aria-label={`${t.sessions.sourceFilter}: ${sourceLabel(source)}`}
/>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left text-xs"
onClick={() => toggleSourceFilter(source)}
>
<SourceIcon className={`h-3.5 w-3.5 shrink-0 ${sourceColor}`} />
<span className="min-w-0 flex-1 truncate">
{sourceLabel(source)}
</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{count}
</span>
</button>
</div>
);
})
)}
</div>
</div>
)}
</div>
{showOverviewTab && !isSearching && (
<Segmented
className="w-fit shrink-0"
size="md"
value={view}
onChange={switchView}
options={[
{ value: "overview", label: t.sessions.overview },
{ value: "list", label: t.sessions.history },
]}
/>
)}
{showList && (
<div className="relative min-w-0 w-full sm:w-auto sm:min-w-[12rem] sm:max-w-md sm:flex-1">
{searching ? (
<Spinner className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[0.875rem] text-primary" />
) : (
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
)}
<Input
placeholder={t.sessions.searchPlaceholder}
value={search}
onChange={(e) => updateSearch(e.target.value)}
className="h-8 py-0 pr-7 pl-8 text-xs leading-none"
/>
{search && (
<Button
ghost
size="xs"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => updateSearch("")}
aria-label={t.common.clear}
>
<X />
</Button>
)}
</div>
)}
{showList && emptyCount > 0 && !isSearching && (
<Button
outlined
destructive
size="sm"
className="shrink-0"
onClick={() => setDeleteEmptyOpen(true)}
aria-label={t.sessions.deleteEmpty}
title={t.sessions.deleteEmpty}
prefix={<Eraser />}
>
<span className="font-mondwest normal-case text-xs">
{t.sessions.deleteEmpty} ({emptyCount})
</span>
</Button>
)}
{!isSearching && (
<Button
outlined
size="sm"
className="shrink-0"
disabled={importingSessions}
onClick={() => importInputRef.current?.click()}
aria-label="Import exported sessions"
title="Import exported session JSON or JSONL"
prefix={importingSessions ? <Spinner /> : <Upload />}
>
<span className="font-mondwest normal-case text-xs">
Import sessions
</span>
</Button>
)}
</div>
{showPagination && (
<SessionsPagination
compact
className="shrink-0 sm:ml-auto"
page={page}
total={total}
onPageChange={goToPage}
/>
)}
</div>
) : null}
{showList && selectedIds.size > 0 && (
<div
className="flex flex-wrap items-center gap-2 border border-primary/30 bg-primary/[0.06] px-3 py-2"
role="region"
aria-label={t.sessions.selectedCount.replace(
"{count}",
String(selectedIds.size),
)}
>
<span className="font-mondwest normal-case text-xs text-primary tabular-nums">
{t.sessions.selectedCount.replace(
"{count}",
String(selectedIds.size),
)}
</span>
{filtered.some((s) => !selectedIds.has(s.id)) && (
<Button
ghost
size="sm"
onClick={() => selectAllOnPage(filtered)}
aria-label={t.sessions.selectAllOnPage}
title={t.sessions.selectAllOnPage}
>
<span className="font-mondwest normal-case text-xs">
{t.sessions.selectAllOnPage}
</span>
</Button>
)}
<Button
ghost
size="sm"
onClick={clearSelection}
aria-label={t.sessions.clearSelection}
title={t.sessions.clearSelection}
>
<span className="font-mondwest normal-case text-xs">
{t.sessions.clearSelection}
</span>
</Button>
<Button
outlined
destructive
size="sm"
className="ml-auto"
onClick={() => setDeleteSelectedOpen(true)}
aria-label={t.sessions.deleteSelected.replace(
"{count}",
String(selectedIds.size),
)}
title={t.sessions.deleteSelected.replace(
"{count}",
String(selectedIds.size),
)}
prefix={<Trash2 />}
>
<span className="font-mondwest normal-case text-xs">
{t.sessions.deleteSelected.replace(
"{count}",
String(selectedIds.size),
)}
</span>
</Button>
</div>
)}
{showList ? (
filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Clock className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">
{search
? t.sessions.noMatch
: selectedSources !== null || sessionCategory !== "chats"
? t.sessions.noSessionsInFilter
: t.sessions.noSessions}
</p>
{!search && sessionCategory === "chats" && selectedSources === null && (
<p className="text-xs mt-1 text-text-tertiary">
{t.sessions.startConversation}
</p>
)}
</div>
) : (
<>
<div className="flex min-w-0 flex-col gap-1.5">
{filtered.map((s, index) => (
<SessionRow
key={s.id}
session={s}
snippet={snippetMap.get(s.id)}
searchQuery={search || undefined}
isExpanded={expandedId === s.id}
isSelected={selectedIds.has(s.id)}
onToggle={() =>
setExpandedId((prev) => (prev === s.id ? null : s.id))
}
onSelectClick={(event) =>
handleSelectClick(event, index, filtered)
}
onDelete={() => sessionDelete.requestDelete(s.id)}
onRename={handleRename}
onExport={handleExport}
resumeInChatEnabled={resumeInChatEnabled}
/>
))}
</div>
{showPagination && (
<SessionsPagination
page={page}
total={total}
onPageChange={goToPage}
/>
)}
</>
)
) : (
<div className="flex min-w-0 flex-col gap-4">
{platformEntries.length > 0 && status && (
<PlatformsCard platforms={platformEntries} />
)}
{recentSessions.length > 0 && (
<Card className="min-w-0 max-w-full overflow-hidden">
<CardHeader className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<Clock className="h-5 w-5 shrink-0 text-muted-foreground" />
<CardTitle className="min-w-0 truncate text-base">
{t.status.recentSessions}
</CardTitle>
</div>
</CardHeader>
<CardContent className="grid min-w-0 gap-3">
{recentSessions.map((s) => (
<div
key={s.id}
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"
>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span
className={`font-mondwest normal-case min-w-0 truncate text-sm ${s.title ? "font-medium" : "text-muted-foreground italic"}`}
>
{s.title ??
(s.preview
? s.preview.slice(0, 60)
: t.common.untitled)}
</span>
<span className="min-w-0 break-words text-xs text-muted-foreground">
{s.model && (
<>
<span className="font-mono-ui">
{s.model.split("/").pop()}
</span>{" "}
·{" "}
</>
)}
{s.message_count} {t.common.msgs} ·{" "}
{timeAgo(s.last_active)}
</span>
{s.preview && s.title && (
<p className="font-mondwest normal-case min-w-0 max-w-full text-xs leading-snug text-text-tertiary [overflow-wrap:anywhere]">
{s.preview}
</p>
)}
</div>
<Badge
tone="outline"
className="shrink-0 self-start text-xs sm:self-center"
>
<Database className="mr-1 h-3 w-3" />
{s.source ? sourceLabel(s.source) : "local"}
</Badge>
</div>
))}
</CardContent>
</Card>
)}
</div>
)}
<PluginSlot name="sessions:bottom" />
</div>
);
}
interface SessionRowProps {
isExpanded: boolean;
isSelected: boolean;
onDelete: () => void;
onExport: (id: string) => void;
onRename: (id: string, title: string) => Promise<void>;
onSelectClick: (event: React.MouseEvent) => void;
onToggle: () => void;
resumeInChatEnabled: boolean;
searchQuery?: string;
session: SessionInfo;
snippet?: string;
}
interface SessionsPaginationProps {
className?: string;
compact?: boolean;
onPageChange: (page: number) => void;
page: number;
total: number;
}