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