/** * dashboard-client/src/tabs/SessionsTab/TurnMemoryView.tsx — Turn-by-turn memory * tracking + recall (S52), moved VERBATIM from the top-level TurnsTab.tsx. * * DASH-0b: the Sessions surface absorbs the existent TurnsTab as a drill-down. * This file holds the byte-preserved turns body (the `TurnMemoryView` * component). The standalone `tabs/TurnsTab.tsx` is LEFT UNTOUCHED this sprint — * its copy stays live for flag-off / pre-DASH parity. A DASH-0d cleanup deletes * the standalone copy after a deep-link audit proves no live consumer points * at it. Only the export name changed (TurnsTab → TurnMemoryView); the render * body is otherwise identical. * * Fetches /api/turns (conversation list), expands the active conversation into * per-turn detail (/api/turns/conversation/:id), and renders: * - Conversation summary list (turn count, recall total, epochs, last turn). * - Per-turn rows: turn index, role, ctx tokens/percent + pressure band, * epoch id, and the injected-checkpoint recall set (the memory recalled * at each turn, with score + source). * - Pending rewind intents (S52A) + Fork action (S50C primitive). * * Read-mostly via /api/turns; fork/intent/prune via POST. No absolute URLs * (PREVENT-PI-004: loopback-only, same-origin static bundle). */ import type React from "react"; import { useCallback, useState } from "react"; import { useApi } from "../../hooks/useApi"; import { fetchTurns, fetchConversationTurns, fetchTurnIntents, postFork, postTurnIntent, } from "../../api/client"; import type { TurnsResponse, ConversationTurnsResponse, RewindIntentsResponse, TurnRow, ConversationSummary, } from "@contracts"; import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { HydeDetailPanel } from "../../components/HydeDetailPanel"; function fmtTs(ms: number): string { if (!ms) return "—"; return new Date(ms).toLocaleString(); } function bandVariant(band: TurnRow["pressureBand"]): "success" | "warning" | "danger" | "default" { if (band === "green") return "success"; if (band === "yellow") return "warning"; if (band === "red") return "danger"; return "default"; } function sourceLabel(s: TurnRow["recall"][number]["source"]): string { if (s === "checkpoint") return "ckpt"; if (s === "cluster_summary") return "raptor"; return "mem"; } export function TurnMemoryView(): React.ReactElement { const { data: turns, loading, error, } = useApi( useCallback(() => fetchTurns(), []), { pollInterval: 10_000, }, ); const { data: intents } = useApi( useCallback(() => fetchTurnIntents(), []), { pollInterval: 5_000 }, ); const [expanded, setExpanded] = useState(null); const [detail, setDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [busy, setBusy] = useState(null); const [notice, setNotice] = useState(null); const expand = useCallback( async (conversationId: string) => { if (expanded === conversationId) { setExpanded(null); setDetail(null); return; } setExpanded(conversationId); setDetailLoading(true); setDetail(null); try { const d = await fetchConversationTurns(conversationId); setDetail(d); } finally { setDetailLoading(false); } }, [expanded], ); const onFork = useCallback( async (conversationId: string, turnIndex: number) => { setBusy(`fork:${conversationId}:${turnIndex}`); setNotice(null); try { const out = await postFork(conversationId, turnIndex); setNotice( `Forked → ${out.childConversationId} (${out.recalledCount} recalled)`, ); } catch (e) { setNotice(`Fork failed: ${(e as Error).message}`); } finally { setBusy(null); } }, [], ); const onRewind = useCallback( async (conversationId: string, turnIndex: number) => { setBusy(`rewind:${conversationId}:${turnIndex}`); setNotice(null); try { await postTurnIntent(conversationId, turnIndex); setNotice(`Rewind intent queued for turn ${turnIndex}`); } catch (e) { setNotice(`Rewind failed: ${(e as Error).message}`); } finally { setBusy(null); } }, [], ); if (error && !turns) { return
Error loading turns: {error.message}
; } if (loading && !turns) { return
Loading turns…
; } if (!turns || turns.conversations.length === 0) { return (

Turn-by-turn memory

No turns recorded yet. Per-turn tracking starts when{" "} MEGACOMPACT_TURNS_DB=1 and the session runs through a compaction. Each turn records its context metrics + the checkpoints recalled into it.

); } return (

Turn-by-turn memory tracking + recall

{notice && {notice}} {intents && intents.intents.length > 0 && ( Pending rewind intents ({intents.intents.length})
    {intents.intents.map((i) => (
  • {fmtTs(i.createdAt)} — rewind{" "} {i.conversationId} to turn {i.targetTurnIndex}
  • ))}
)} Conversations ({turns.conversations.length}) {turns.conversations.map((c: ConversationSummary) => ( expand(c.conversationId)} /> ))}
Conversation Turns Recall Epochs Avg ctx% Last turn
{expanded && ( Turns in {expanded} {detailLoading ? (
Loading turns…
) : detail ? ( ) : (
No detail.
)}
)}
); } function ConversationRow({ c, active, expanded, onExpand, }: { c: ConversationSummary; active: boolean; expanded: boolean; onExpand: () => void; }): React.ReactElement { return ( <> {expanded ? "▾" : "▸"} {c.conversationId} {active && active} {c.turnCount} {c.totalRecall} {c.epochCount} {c.avgCtxPercent.toFixed(0)}% {fmtTs(c.lastTurnAt)} ); } function TurnDetail({ detail, busy, onFork, onRewind, }: { detail: ConversationTurnsResponse; busy: string | null; onFork: (conversationId: string, turnIndex: number) => void; onRewind: (conversationId: string, turnIndex: number) => void; }): React.ReactElement { return ( {detail.turns.map((t) => ( ))}
# Role Ctx Band Epoch Recalled checkpoints (memory recall) HyDE Ended Actions
{t.turnIndex} {t.role} {t.ctxTokens ?? "—"} {t.ctxPercent != null && ( {" "} · {t.ctxPercent.toFixed(0)}% )} {t.pressureBand ?? "—"} {t.epochId ? {t.epochId.slice(0, 8)} : "—"} {t.recall.length === 0 ? ( ) : (
    {t.recall.map((r) => (
  • {r.checkpointId.slice(0, 12)}{" "} {sourceLabel(r.source)} · {r.score.toFixed(2)} {r.raptorLevel != null ? ` · L${r.raptorLevel}` : ""}
  • ))}
)}
{fmtTs(t.endedAt)}
); }