hermes-agent/web/src/components/ChatSessionList.tsx
Teknium 0d7abd555c
fix(dashboard): sort chat session switcher by most-recent activity (#49104)
The Chat-tab session switcher rendered rows in the API's default
order="created" (original start time) while each row displays
last_active — so a session you just messaged in could sit below an
older one, and the list looked unsorted against its own timestamps.

Pass order="recent" from ChatSessionList so the switcher sorts by
latest activity across the compression chain (most-recently-used at
top, ChatGPT-style; long conversations that auto-compressed into a new
continuation id stay on the first page). Adds an optional, defaulted
`order` arg to api.getSessions; the paginated Sessions page keeps the
stable created order.
2026-06-19 07:58:56 -07:00

260 lines
9.1 KiB
TypeScript

/**
* ChatSessionList — a ChatGPT-style conversation switcher that sits beside
* the embedded TUI on the dashboard Chat tab.
*
* It lists the most recent sessions for the active management profile and
* lets the user swap between them without leaving the Chat page. Selecting
* a row sets `/chat?resume=<id>`; ChatPage treats the resume target as part
* of the PTY identity, so the change tears down the current terminal child
* and respawns it resuming that conversation (see ChatPage.tsx). The
* "New session" action clears the resume param, which spawns a fresh PTY.
*
* Best-effort, like ChatSidebar: a failed fetch surfaces a small inline
* error with a retry affordance and the terminal pane keeps working.
*
* This is a navigation surface, NOT a session-management one — delete,
* rename, export, and bulk actions live on the Sessions page. Keeping this
* panel read-only (plus select / new) avoids duplicating that machinery and
* keeps the chat context focused on switching conversations quickly.
*/
import { Button } from "@nous-research/ui/ui/components/button";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { AlertCircle, MessageSquarePlus, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useI18n } from "@/i18n";
import { api, type SessionInfo } from "@/lib/api";
import { cn, timeAgo } from "@/lib/utils";
const SESSION_LIMIT = 30;
interface ChatSessionListProps {
/** Active resume target (the session currently shown in the terminal). */
activeSessionId: string | null;
/** Management profile from the dashboard switcher — scopes the listing. */
profile?: string;
className?: string;
/** Optional callback fired after a row is picked (e.g. close mobile sheet). */
onPicked?: () => void;
/**
* Starts a fresh chat. ChatPage supplies its `startFreshDashboardChat`,
* which clears `?resume` AND bumps the reconnect nonce so a brand-new PTY
* spawns even when the user is already on an unsaved fresh session. When
* omitted, we fall back to clearing the resume param ourselves.
*/
onNewChat?: () => void;
}
function rowLabel(session: SessionInfo, untitled: string): string {
const title = session.title?.trim();
if (title && title !== "Untitled") return title;
const preview = session.preview?.trim();
if (preview) return preview;
return untitled;
}
export function ChatSessionList({
activeSessionId,
profile,
className,
onPicked,
onNewChat,
}: ChatSessionListProps) {
const { t } = useI18n();
const [, setSearchParams] = useSearchParams();
const [sessions, setSessions] = useState<SessionInfo[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Bumped to force a refetch (after switching, on Refresh, on mount).
const [reloadNonce, setReloadNonce] = useState(0);
// `profile` is read inside the fetch; it's part of the scope key so a
// profile switch refetches. The empty-string fallback keeps the dep
// stable when no profile is selected (default profile).
const scopeKey = profile ?? "";
// Monotonic request token: only the most recent fetch is allowed to
// commit state, so a fast profile switch (or Refresh spam) can't land a
// stale list out of order.
const reqRef = useRef(0);
const load = useCallback(() => {
const myReq = ++reqRef.current;
setLoading(true);
setError(null);
api
.getSessions(SESSION_LIMIT, 0, scopeKey, "recent")
.then((res) => {
if (reqRef.current !== myReq) return;
setSessions(res.sessions);
})
.catch((e: Error) => {
if (reqRef.current !== myReq) return;
setError(e.message || "failed to load sessions");
})
.finally(() => {
if (reqRef.current === myReq) setLoading(false);
});
}, [scopeKey]);
useEffect(() => {
// Dashboard data surfaces fetch from an effect on mount + scope change;
// keep this local and explicit until the shared lint profile is updated
// for async loaders (matches FilesPage).
// eslint-disable-next-line react-hooks/set-state-in-effect
load();
// `reloadNonce` is a manual refetch trigger (Refresh button / row pick).
}, [load, reloadNonce]);
const reload = useCallback(() => setReloadNonce((n) => n + 1), []);
// Picking a row sets `/chat?resume=<id>`. Re-picking the row already in
// the terminal is a no-op (avoids a needless PTY teardown).
const pick = useCallback(
(id: string) => {
onPicked?.();
if (id === activeSessionId) return;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set("resume", id);
return next;
},
{ replace: false },
);
},
[activeSessionId, onPicked, setSearchParams],
);
// "New chat" prefers ChatPage's robust handler (clears resume + forces a
// PTY respawn even from an already-fresh session). Fallback: clear the
// resume param ourselves, which spawns a fresh PTY whenever one was being
// resumed. Session management (delete/rename/export) lives on the Sessions
// page; this panel only switches and starts conversations.
const startNew = useCallback(() => {
onPicked?.();
if (onNewChat) {
onNewChat();
return;
}
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete("resume");
return next;
},
{ replace: false },
);
}, [onNewChat, onPicked, setSearchParams]);
const content = useMemo(() => {
if (loading && sessions === null) {
return (
<div className="flex items-center justify-center gap-2 px-2 py-6 text-xs text-text-secondary">
<Spinner /> {t.common.loading}
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-start gap-2 px-2 py-4 text-xs">
<div className="flex items-start gap-2 text-destructive">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span className="wrap-break-word">{error}</span>
</div>
<Button size="sm" outlined onClick={reload} prefix={<RefreshCw />}>
{t.common.retry}
</Button>
</div>
);
}
if (!sessions || sessions.length === 0) {
return (
<div className="px-2 py-6 text-center text-xs text-text-secondary">
{t.sessions.noSessions}
</div>
);
}
return (
<div className="flex flex-col gap-0.5">
{sessions.map((s) => {
const isActive = s.id === activeSessionId;
return (
<ListItem
key={s.id}
onClick={() => pick(s.id)}
aria-current={isActive ? "true" : undefined}
className={cn(
"flex-col items-start gap-0.5 rounded px-2 py-1.5",
"normal-case tracking-normal",
isActive
? "bg-primary/10 text-foreground border-l-2 border-primary"
: "text-text-secondary hover:bg-midground/5 hover:text-foreground",
)}
>
<span className="w-full truncate text-sm font-medium">
{rowLabel(s, t.sessions.untitledSession)}
</span>
<span className="flex w-full items-center gap-1.5 text-[0.6875rem] text-text-tertiary">
<span>{timeAgo(s.last_active)}</span>
{s.message_count > 0 && (
<>
<span aria-hidden>·</span>
<span>{s.message_count} msgs</span>
</>
)}
{s.source && s.source !== "cli" && (
<>
<span aria-hidden>·</span>
<span className="truncate">{s.source}</span>
</>
)}
</span>
</ListItem>
);
})}
</div>
);
}, [activeSessionId, error, loading, pick, reload, sessions, t]);
return (
<aside
className={cn(
"flex h-full w-full min-w-0 shrink-0 flex-col overflow-hidden",
className,
)}
>
<div className="flex items-center justify-between gap-2 px-2 pb-2">
<span className="text-display text-xs tracking-wider text-text-tertiary">
{t.sessions.title}
</span>
<Button
ghost
size="icon"
onClick={reload}
aria-label={t.common.refresh}
title={t.common.refresh}
className="text-text-secondary hover:text-foreground"
>
<RefreshCw className={cn(loading && "animate-spin")} />
</Button>
</div>
<Button
outlined
size="sm"
onClick={startNew}
prefix={<MessageSquarePlus />}
className="mx-2 mb-2 justify-center"
>
{t.sessions.newChat}
</Button>
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-1 pb-1">
{content}
</div>
</aside>
);
}