/** * dashboard-client/src/tabs/SessionsTab.tsx — Sessions tab (S39, DASH-0b). * * DASH-0b: the Sessions surface absorbs the existent TurnsTab as a drill-down. * A `SessionsViews` toggle ("Active Sessions" / "Turn Memory") switches between * the existent sessions summary/chart/table body and the turns view (rendered by * `SessionsTab/TurnMemoryView.tsx` — the turns body moved VERBATIM from the * standalone TurnsTab.tsx, which is left untouched this sprint for flag-off * parity). * * Flag: `MEGACOMPACT_DASH_0B` default ON. The dashboard client is a browser * bundle with NO `process` global, so the positive sprint flag cannot be read * server-side-style (`process.env`) inside the client. Instead the flag is read * from the server-authoritative `/api/rag-settings` state: the server resolves * `process.env["MEGACOMPACT_DASH_0B"] ?? default` into the `SettingState.value` * boolean. When the flag is OFF (settings confirmed value=false), this surface * renders ONLY the existent sessions body — no toggle, no TurnMemoryView branch * (byte-identical predecessor). The standalone TurnsTab.tsx top-level surface is * untouched and remains reachable for flag-off parity. * * Data sources: fetchSessions() + fetchSessionTimeseries(minutes). Both poll * every 2s for near-real-time updates. useSSE() listens for session_sample * events emitted by appendTokenSample (Step 5) so the chart refetches * instantly between polls when a new token sample is appended to events.log. * * Empty state: shows "No active sessions." when the /api/sessions response * returns an empty array (e.g. no pi processes are running yet). */ import type React from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useApi } from "../hooks/useApi"; import { useSSE } from "../hooks/useSSE"; import { fetchSessions, fetchSessionTimeseries, fetchSettings } from "../api/client"; import type { SseEvent, SseSessionSample, SessionsResponse, SessionTimeseriesResponse, SettingsResponse, } from "@contracts"; import { SessionsMemoryChart } from "../components/SessionsMemoryChart"; import { ActiveSessionsTable } from "../components/ActiveSessionsTable"; import { Toggle } from "../components/ui/toggle"; import { Card, CardContent } from "../components/ui/card"; import { TurnMemoryView } from "./SessionsTab/TurnMemoryView"; /** * Shape of the {@link SseSessionSample} SSE event appended to events.log by * `appendTokenSample` (src/store/sqlite/global-index.ts). The contract now * defines this variant as part of the {@link SseEvent} union (Step 5); the * local alias keeps the surface terser in this file. */ type SessionSampleEvent = SseSessionSample; /** Type guard: narrows an SSE event to a session_sample event. */ function isSessionSample( e: SseEvent, ): e is SessionSampleEvent { return e.type === "session_sample"; } const DASH_0B_KEY = "MEGACOMPACT_DASH_0B"; /** Resolve the DASH-0b consolidation flag from the server settings state. * Absent/not-yet-loaded => false (flag-off posture), so flag-off users never * see a toggle flash; the toggle appears only once settings confirm it is ON. */ function dash0bEnabled(settings: SettingsResponse | null): boolean { if (!settings) return false; for (const cat of settings.categories) { for (const s of cat.settings) { if (s.key === DASH_0B_KEY && s.type === "boolean") return s.value === true; } } return false; } const WINDOWS: ReadonlyArray<{ value: number; label: string }> = [ { value: 5, label: "5m" }, { value: 15, label: "15m" }, { value: 30, label: "30m" }, { value: 60, label: "60m" }, ] as const; interface SummaryTilesProps { sessions: SessionsResponse; } function SummaryTiles({ sessions }: SummaryTilesProps): React.ReactElement { const active = sessions.sessions.length; const combinedTokens = sessions.sessions.reduce( (sum, s) => sum + (s.tokens ?? 0), 0, ); const maxWindow = sessions.sessions.reduce( (max, s) => Math.max(max, s.ctxWindow), 0, ); const combinedPct = maxWindow > 0 ? (combinedTokens / maxWindow) * 100 : 0; return (