diff --git a/openplanter-desktop/frontend/src/api/invoke.ts b/openplanter-desktop/frontend/src/api/invoke.ts index d2a9baa7..814e9435 100644 --- a/openplanter-desktop/frontend/src/api/invoke.ts +++ b/openplanter-desktop/frontend/src/api/invoke.ts @@ -22,7 +22,8 @@ export async function solve(objective: string, sessionId: string): Promise return invoke("solve", { objective, sessionId }); } -export async function getSessionHistory(sessionId: string): Promise { +export async function getSessionHistory(sessionId: string | null): Promise { + if (!sessionId) return []; return invoke("get_session_history", { sessionId }); } diff --git a/openplanter-desktop/frontend/src/api/types.ts b/openplanter-desktop/frontend/src/api/types.ts index aaefd02b..1126fb30 100644 --- a/openplanter-desktop/frontend/src/api/types.ts +++ b/openplanter-desktop/frontend/src/api/types.ts @@ -141,6 +141,12 @@ export interface OverviewActionView { export interface OverviewRevelationProvenanceView { source: string; step_index?: number; + turn_id?: string; + event_id?: string; + replay_seq?: number; + replay_line?: number; + source_refs?: string[]; + evidence_refs?: string[]; } export interface OverviewRevelationView { diff --git a/openplanter-desktop/frontend/src/components/GraphPane.ts b/openplanter-desktop/frontend/src/components/GraphPane.ts index 0464623f..d531e827 100644 --- a/openplanter-desktop/frontend/src/components/GraphPane.ts +++ b/openplanter-desktop/frontend/src/components/GraphPane.ts @@ -291,14 +291,17 @@ export function createGraphPane(): HTMLElement { // --- Drawer open/close --- function openWikiDrawer(detail: OpenWikiDrawerDetail): void { + const wikiPath = detail.wikiPath.trim(); + if (!wikiPath) return; + hideDetail(); - currentDrawerWikiPath = detail.wikiPath; + currentDrawerWikiPath = wikiPath; - const sourceNode = findSourceNodeByPath(detail.wikiPath); + const sourceNode = findSourceNodeByPath(wikiPath); drawerTitle.textContent = detail.requestedTitle || sourceNode?.label || - fallbackTitleFromPath(detail.wikiPath); + fallbackTitleFromPath(wikiPath); drawerBody.innerHTML = 'Loading...'; drawerBackdrop.classList.add("visible"); drawer.classList.add("visible"); @@ -309,7 +312,7 @@ export function createGraphPane(): HTMLElement { } const loadSeq = ++drawerLoadSeq; - readWikiFile(detail.wikiPath).then((content) => { + readWikiFile(wikiPath).then((content) => { if (loadSeq !== drawerLoadSeq) return; drawerBody.innerHTML = md.render(content); interceptDrawerLinks(); diff --git a/openplanter-desktop/frontend/src/components/InvestigationPane.test.ts b/openplanter-desktop/frontend/src/components/InvestigationPane.test.ts index 61ce3ff8..50157d26 100644 --- a/openplanter-desktop/frontend/src/components/InvestigationPane.test.ts +++ b/openplanter-desktop/frontend/src/components/InvestigationPane.test.ts @@ -25,6 +25,7 @@ vi.mock("./GraphPane", () => ({ vi.mock("../graph/sessionBaseline", () => sessionBaselineMocks); import { appState } from "../state/store"; +import { OPEN_WIKI_DRAWER_EVENT } from "../wiki/drawerEvents"; import { createInvestigationPane } from "./InvestigationPane"; describe("createInvestigationPane", () => { @@ -38,6 +39,7 @@ describe("createInvestigationPane", () => { }); afterEach(() => { + vi.useRealTimers(); sessionBaselineMocks.primeGraphSessionBaseline.mockClear(); sessionBaselineMocks.resetGraphSessionState.mockClear(); appState.set(originalState); @@ -98,4 +100,54 @@ describe("createInvestigationPane", () => { expect(sessionBaselineMocks.resetGraphSessionState).not.toHaveBeenCalled(); expect(sessionBaselineMocks.primeGraphSessionBaseline).not.toHaveBeenCalled(); }); + + it("re-dispatches wiki drawer events after lazy-mounting the graph pane", async () => { + const pane = createInvestigationPane(); + document.body.appendChild(pane); + const timerSpy = vi.spyOn(window, "setTimeout"); + + window.dispatchEvent(new CustomEvent(OPEN_WIKI_DRAWER_EVENT, { + detail: { + wikiPath: "wiki/acme.md", + source: "chat", + }, + })); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(appState.get().investigationViewTab).toBe("graph"); + expect(pane.querySelector(".graph-pane")).not.toBeNull(); + expect(timerSpy).toHaveBeenCalledTimes(1); + timerSpy.mockRestore(); + }); + + it("drops stale queued wiki re-dispatches when a newer event arrives first", () => { + vi.useFakeTimers(); + + const pane = createInvestigationPane(); + document.body.appendChild(pane); + const dispatchSpy = vi.spyOn(window, "dispatchEvent"); + + window.dispatchEvent(new CustomEvent(OPEN_WIKI_DRAWER_EVENT, { + detail: { + wikiPath: "wiki/first.md", + source: "chat", + }, + })); + window.dispatchEvent(new CustomEvent(OPEN_WIKI_DRAWER_EVENT, { + detail: { + wikiPath: "wiki/second.md", + source: "chat", + }, + })); + + vi.runAllTimers(); + + const drawerDispatches = dispatchSpy.mock.calls.filter(([event]) => { + return event instanceof CustomEvent && event.type === OPEN_WIKI_DRAWER_EVENT; + }); + + expect(drawerDispatches).toHaveLength(2); + dispatchSpy.mockRestore(); + }); }); diff --git a/openplanter-desktop/frontend/src/components/InvestigationPane.ts b/openplanter-desktop/frontend/src/components/InvestigationPane.ts index 93adac0f..6a4643fe 100644 --- a/openplanter-desktop/frontend/src/components/InvestigationPane.ts +++ b/openplanter-desktop/frontend/src/components/InvestigationPane.ts @@ -3,6 +3,7 @@ import { primeGraphSessionBaseline, resetGraphSessionState, } from "../graph/sessionBaseline"; +import { OPEN_WIKI_DRAWER_EVENT, type OpenWikiDrawerDetail } from "../wiki/drawerEvents"; import { createGraphPane } from "./GraphPane"; import { createOverviewPane } from "./OverviewPane"; @@ -29,6 +30,7 @@ export function createInvestigationPane(): HTMLElement { let graphPane: HTMLElement | null = null; let activeTab = ""; + let pendingWikiRedispatch: number | null = null; function ensureGraphPane(): HTMLElement { if (!graphPane) { @@ -87,6 +89,35 @@ export function createInvestigationPane(): HTMLElement { void primeGraphSessionBaseline(); }) as EventListener); + window.addEventListener(OPEN_WIKI_DRAWER_EVENT, ((e: CustomEvent) => { + const detail = e.detail; + if (!detail) return; + if (pendingWikiRedispatch != null) { + window.clearTimeout(pendingWikiRedispatch); + pendingWikiRedispatch = null; + } + // Capture this before switching tabs. The state update below synchronously mounts the + // graph pane via the store subscription, but listeners added during the current dispatch + // will not observe this event. + const needsRedispatch = !graphPane; + + if (appState.get().investigationViewTab !== "graph") { + appState.update((state) => ({ + ...state, + investigationViewTab: "graph", + })); + } + + if (needsRedispatch) { + pendingWikiRedispatch = window.setTimeout(() => { + pendingWikiRedispatch = null; + window.dispatchEvent( + new CustomEvent(OPEN_WIKI_DRAWER_EVENT, { detail }), + ); + }, 0); + } + }) as EventListener); + tabs.append(overviewTab, graphTab); pane.append(tabs, content); diff --git a/openplanter-desktop/frontend/src/components/OverviewPane.test.ts b/openplanter-desktop/frontend/src/components/OverviewPane.test.ts index be5f82c9..dc508657 100644 --- a/openplanter-desktop/frontend/src/components/OverviewPane.test.ts +++ b/openplanter-desktop/frontend/src/components/OverviewPane.test.ts @@ -9,6 +9,10 @@ vi.mock("@tauri-apps/api/core", async () => { import type { InvestigationOverviewView } from "../api/types"; import { appState } from "../state/store"; +import { + OPEN_WIKI_DRAWER_EVENT, + type OpenWikiDrawerDetail, +} from "../wiki/drawerEvents"; import { createOverviewPane } from "./OverviewPane"; function makeOverview( @@ -96,7 +100,9 @@ describe("createOverviewPane", () => { overviewError: null, overviewSelectedWikiPath: null, }); + (HTMLElement.prototype as { scrollIntoView?: () => void }).scrollIntoView = () => {}; __setHandler("read_wiki_file", ({ path }: { path: string }) => `# ${path}\n\nMock wiki document`); + __setHandler("get_session_history", () => []); }); afterEach(() => { @@ -215,6 +221,56 @@ describe("createOverviewPane", () => { expect(pane.textContent).not.toContain("Stale overview should be ignored"); }); + it("invalidates stale replay responses when the session changes", async () => { + let sessionOneHistoryResolve: ((value: Array<{ + seq: number; + timestamp: string; + role: string; + content: string; + }>) => void) | null = null; + + __setHandler("get_investigation_overview", () => + makeOverview({ + session_id: "session-1", + }), + ); + __setHandler("get_session_history", ({ sessionId }: { sessionId: string }) => { + if (sessionId === "session-1") { + return new Promise((resolve) => { + sessionOneHistoryResolve = resolve; + }); + } + return []; + }); + + const pane = createOverviewPane(); + document.body.appendChild(pane); + + await vi.waitFor(() => { + expect(sessionOneHistoryResolve).not.toBeNull(); + }); + + window.dispatchEvent(new CustomEvent("session-changed", { detail: { isNew: false } })); + + sessionOneHistoryResolve!([ + { + seq: 1, + timestamp: "2026-03-17T12:06:00Z", + role: "assistant", + content: "Stale replay from the previous session", + }, + ]); + + await Promise.resolve(); + await Promise.resolve(); + + expect(pane.textContent).not.toContain("Stale replay from the previous session"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + await Promise.resolve(); + await Promise.resolve(); + }); + it("keeps the selected wiki page stable across overview refreshes", async () => { let overviewCalls = 0; const readPaths: string[] = []; @@ -316,4 +372,237 @@ describe("createOverviewPane", () => { expect(pane.querySelector(".overview-document-viewport")).toBe(viewport); expect(viewport.scrollTop).toBe(64); }); + + it("renders step zero replay entries as Step 0", async () => { + __setHandler("get_investigation_overview", () => makeOverview()); + __setHandler("get_session_history", () => [ + { + seq: 7, + timestamp: "2026-03-17T12:04:00Z", + role: "step-summary", + content: "Initial summary", + step_number: 0, + step_model_preview: "Initial summary", + }, + ]); + + const pane = createOverviewPane(); + document.body.appendChild(pane); + + await vi.waitFor(() => { + expect(pane.textContent).toContain("Step 0"); + }); + }); + + it("uses replay line locators to focus the matching replay entry by file order", async () => { + __setHandler("get_investigation_overview", () => + makeOverview({ + recent_revelations: [ + { + revelation_id: "openplanter.revelation|replay_line:1", + occurred_at: "2026-03-17T12:05:00Z", + title: "Line-based evidence", + summary: "Should focus the first replay line even when seq differs.", + provenance: { + source: "agent_step", + step_index: 0, + }, + }, + ], + }), + ); + __setHandler("get_session_history", () => [ + { + seq: 42, + timestamp: "2026-03-17T12:04:00Z", + role: "assistant", + content: "First replay entry", + }, + { + seq: 99, + timestamp: "2026-03-17T12:06:00Z", + role: "assistant", + content: "Second replay entry", + }, + ]); + + const pane = createOverviewPane(); + document.body.appendChild(pane); + + await vi.waitFor(() => { + expect(pane.textContent).toContain("Line-based evidence"); + expect(pane.textContent).toContain("First replay entry"); + }); + + const lineChip = Array.from(pane.querySelectorAll("button")).find( + (button) => button.textContent === "line 1", + ) as HTMLButtonElement | undefined; + expect(lineChip).toBeDefined(); + + lineChip!.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const focused = pane.querySelector('[data-replay-seq="42"]') as HTMLElement | null; + expect(focused).not.toBeNull(); + expect(focused?.style.outline).toContain("var(--accent)"); + }); + + it("keeps a focused replay target visible even when it falls outside the default replay window", async () => { + __setHandler("get_investigation_overview", () => + makeOverview({ + recent_revelations: [ + { + revelation_id: "openplanter.revelation|replay_seq:1", + occurred_at: "2026-03-17T12:05:00Z", + title: "Older replay anchor", + summary: "This revelation anchors to the oldest replay entry.", + provenance: { + source: "agent_step", + step_index: 1, + }, + }, + ], + }), + ); + __setHandler("get_session_history", () => + Array.from({ length: 16 }, (_, index) => ({ + seq: index + 1, + timestamp: `2026-03-17T12:${String(index).padStart(2, "0")}:00Z`, + role: "assistant", + content: `Replay entry ${index + 1}`, + })), + ); + + const pane = createOverviewPane(); + document.body.appendChild(pane); + + await vi.waitFor(() => { + expect(pane.textContent).toContain("Replay entry 16"); + }); + + const replayChip = Array.from(pane.querySelectorAll("button")).find( + (button) => button.textContent === "replay #1", + ) as HTMLButtonElement | undefined; + expect(replayChip).toBeDefined(); + + replayChip!.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const focused = pane.querySelector('[data-replay-seq="1"]') as HTMLElement | null; + expect(focused).not.toBeNull(); + expect(focused?.style.outline).toContain("var(--accent)"); + }); + + it("normalizes wiki locators that use the wiki: prefix", async () => { + __setHandler("get_investigation_overview", () => + makeOverview({ + recent_revelations: [ + { + revelation_id: "rev-1", + occurred_at: "2026-03-17T12:05:00Z", + title: "Wiki locator", + summary: "This locator should open the namespaced wiki path.", + provenance: { + source: "agent_step", + source_refs: ["wiki:acme.md"], + }, + }, + ], + }), + ); + + const pane = createOverviewPane(); + document.body.appendChild(pane); + + await vi.waitFor(() => { + expect(pane.textContent).toContain("Wiki locator"); + }); + + let openedDetail: OpenWikiDrawerDetail | null = null; + const listener = ((event: CustomEvent) => { + openedDetail = event.detail; + }) as EventListener; + window.addEventListener(OPEN_WIKI_DRAWER_EVENT, listener); + + const wikiChip = Array.from(pane.querySelectorAll("button")).find( + (button) => button.textContent === "acme.md", + ) as HTMLButtonElement | undefined; + expect(wikiChip).toBeDefined(); + + wikiChip!.click(); + + expect(openedDetail?.wikiPath).toBe("wiki/acme.md"); + expect(appState.get().overviewSelectedWikiPath).toBe("wiki/acme.md"); + + window.removeEventListener(OPEN_WIKI_DRAWER_EVENT, listener); + }); + + it("does not treat unrelated nowiki references as wiki navigation targets", async () => { + __setHandler("get_investigation_overview", () => + makeOverview({ + recent_revelations: [ + { + revelation_id: "rev-nowiki", + occurred_at: "2026-03-17T12:05:00Z", + title: "Opaque source ref", + summary: "This source ref should remain informational only.", + provenance: { + source: "agent_step", + source_refs: ["nowiki/file.md"], + }, + }, + ], + }), + ); + + const pane = createOverviewPane(); + document.body.appendChild(pane); + + await vi.waitFor(() => { + expect(pane.textContent).toContain("Opaque source ref"); + }); + + expect(pane.querySelector('button[title="nowiki/file.md"]')).toBeNull(); + expect(pane.querySelector('span[title="nowiki/file.md"]')).not.toBeNull(); + }); + + it("focuses the matching gap card from revelation evidence chips", async () => { + __setHandler("get_investigation_overview", () => + makeOverview({ + recent_revelations: [ + { + revelation_id: "rev-gap", + occurred_at: "2026-03-17T12:05:00Z", + title: "Gap-linked evidence", + summary: "This should focus the existing gap card.", + provenance: { + source: "agent_step", + evidence_refs: ["gap:claim:c1:missing_evidence"], + }, + }, + ], + }), + ); + + const pane = createOverviewPane(); + document.body.appendChild(pane); + + await vi.waitFor(() => { + expect(pane.textContent).toContain("Gap-linked evidence"); + expect(pane.textContent).toContain("Claim c1 needs more evidence"); + }); + + const gapChip = pane.querySelector( + 'button[title="gap:claim:c1:missing_evidence"]', + ) as HTMLButtonElement | null; + expect(gapChip).not.toBeNull(); + + gapChip!.click(); + + const gapCard = pane.querySelector( + '[data-gap-id="gap:claim:c1:missing_evidence"]', + ) as HTMLElement | null; + expect(gapCard).not.toBeNull(); + expect(gapCard?.style.outline).toContain("var(--accent)"); + }); }); diff --git a/openplanter-desktop/frontend/src/components/OverviewPane.ts b/openplanter-desktop/frontend/src/components/OverviewPane.ts index cae40dcc..f5d55efd 100644 --- a/openplanter-desktop/frontend/src/components/OverviewPane.ts +++ b/openplanter-desktop/frontend/src/components/OverviewPane.ts @@ -1,16 +1,22 @@ import MarkdownIt from "markdown-it"; import hljs from "highlight.js"; -import { getInvestigationOverview, readWikiFile } from "../api/invoke"; +import { + getInvestigationOverview, + getSessionHistory, + readWikiFile, +} from "../api/invoke"; import type { InvestigationOverviewView, OverviewActionView, OverviewGapView, OverviewQuestionView, OverviewRevelationView, + ReplayEntry, WikiNavSourceView, } from "../api/types"; import { appState } from "../state/store"; +import { OPEN_WIKI_DRAWER_EVENT, type OpenWikiDrawerDetail } from "../wiki/drawerEvents"; import { resolveWikiMarkdownHref } from "../wiki/linkResolution"; const md = new MarkdownIt({ @@ -30,6 +36,48 @@ const md = new MarkdownIt({ }); type DocumentStatus = "idle" | "loading" | "ready" | "error"; +type ReplayStatus = "idle" | "loading" | "ready" | "error"; +type EvidenceLocatorKind = + | "anchor" + | "source_ref" + | "evidence_ref" + | "step" + | "turn" + | "event" + | "replay_seq" + | "replay_line"; + +interface EvidenceLocator { + kind: EvidenceLocatorKind; + value: string; +} + +interface ChipLink { + label: string; + title?: string; + onClick?: () => void; +} + +interface ReplaySummary { + continuityLabel: string; + continuityDetail: string; + healthLabel: string; + healthDetail: string; + failures: number; + recoveries: number; + entryCount: number; + activeState: string; + activeDetail: string; +} + +const CURATED_REPLAY_LIMIT = 14; +const CURATED_REPLAY_ROLES = new Set([ + "assistant", + "assistant-cancelled", + "curator", + "step-summary", + "user", +]); export function createOverviewPane(): HTMLElement { const pane = document.createElement("div"); @@ -47,12 +95,14 @@ export function createOverviewPane(): HTMLElement { const main = document.createElement("div"); main.className = "overview-main"; + const replaySection = createSection("Curated Replay"); const snapshotSection = createSection("Investigation Snapshot"); const gapsSection = createSection("Outstanding Gaps"); const actionsSection = createSection("Candidate Actions"); const revelationsSection = createSection("Recent Revelations"); const detailSection = createSection("Wiki Navigation"); detailSection.body.classList.add("overview-document"); + const documentControls = document.createElement("div"); documentControls.className = "overview-document-controls"; @@ -84,7 +134,9 @@ export function createOverviewPane(): HTMLElement { documentViewport.append(documentStatusEl, documentContentEl); detailSection.body.append(documentControls, documentTitleEl, documentViewport); + main.append( + replaySection.section, snapshotSection.section, gapsSection.section, actionsSection.section, @@ -97,17 +149,25 @@ export function createOverviewPane(): HTMLElement { let refreshTimer: number | null = null; let refreshSeq = 0; let docSeq = 0; + let replaySeq = 0; let documentStatus: DocumentStatus = "idle"; + let replayStatus: ReplayStatus = "idle"; let documentHtml = ""; let documentTitle = "Wiki document"; let documentError = ""; + let replayError = ""; let loadedDocumentPath: string | null = null; + let replayEntries: ReplayEntry[] = []; + let selectedReplaySeq: number | null = null; const initialState = appState.get(); let lastOverviewData = initialState.overviewData; let lastOverviewStatus = initialState.overviewStatus; let lastOverviewError = initialState.overviewError; let lastOverviewSelectedWikiPath = initialState.overviewSelectedWikiPath; + let lastContinuityMode = initialState.continuityMode; + let lastLoopHealth = initialState.loopHealth; + let lastLastCompletion = initialState.lastCompletion; function createCardList( items: T[], @@ -154,6 +214,172 @@ export function createOverviewPane(): HTMLElement { }); } + function truncate(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength - 1)}…`; + } + + function titleCase(value: string): string { + return value + .replace(/[_-]+/g, " ") + .trim() + .replace(/\b\w/g, (match) => match.toUpperCase()); + } + + function normalizeReplayRole(role: string): string { + return role.trim().toLowerCase(); + } + + function replayRoleLabel(role: string): string { + const normalized = normalizeReplayRole(role); + if (normalized === "step-summary") return "Step"; + if (normalized === "assistant-cancelled") return "Cancelled"; + if (!normalized) return "Entry"; + return titleCase(normalized); + } + + function replayEntryTitle(entry: ReplayEntry): string { + const normalized = normalizeReplayRole(entry.role); + if (normalized === "user") return "Objective"; + if (normalized === "step-summary") { + return entry.step_number != null ? `Step ${entry.step_number}` : "Step Summary"; + } + if (normalized === "curator") return "Curator Update"; + if (normalized === "assistant-cancelled") return "Cancelled Run"; + if (normalized === "assistant") return "Assistant Response"; + return replayRoleLabel(entry.role); + } + + function replayPreview(entry: ReplayEntry): string { + const normalized = normalizeReplayRole(entry.role); + if (normalized === "step-summary") { + const preview = (entry.step_model_preview || entry.content || "").trim(); + return preview || "Step summary pending."; + } + const text = (entry.content || "").trim(); + if (!text) return "(no content)"; + return truncate(text, 280); + } + + function looksLikeFailurePreview(text: string): boolean { + const normalized = text.trim().toLowerCase(); + if (!normalized) return false; + if (normalized.includes("rate limit")) return true; + return [ + "error:", + "request failed", + "run failed", + "solve failed", + "operation failed", + "failed to", + "timed out", + "timeout", + "degraded", + "task cancelled", + "cancelled", + "cancellation requested", + ].some((prefix) => normalized.startsWith(prefix)); + } + + function isFailureEntry(entry: ReplayEntry): boolean { + const normalized = normalizeReplayRole(entry.role); + if (normalized === "user") return false; + if (normalized === "assistant-cancelled") return true; + if (normalized === "assistant") { + return looksLikeFailurePreview(replayPreview(entry)); + } + return normalized.includes("error"); + } + + function isRecoveryEntry(entry: ReplayEntry): boolean { + const normalized = normalizeReplayRole(entry.role); + return ( + normalized === "assistant" || + normalized === "curator" || + normalized === "step-summary" + ); + } + + function summarizeReplay(entries: ReplayEntry[]): ReplaySummary { + const state = appState.get(); + const objectiveTurns = entries.filter( + (entry) => normalizeReplayRole(entry.role) === "user", + ).length; + let failures = 0; + let recoveries = 0; + let pendingFailure = false; + + for (const entry of entries) { + if (isFailureEntry(entry)) { + failures += 1; + pendingFailure = true; + continue; + } + if (pendingFailure && isRecoveryEntry(entry)) { + recoveries += 1; + pendingFailure = false; + } + } + + const continuityMode = (state.continuityMode || "auto").toLowerCase(); + const continuityLabel = + continuityMode === "continue" + ? "Resume Mode" + : continuityMode === "fresh" + ? "Fresh Mode" + : objectiveTurns > 1 + ? "Auto Context" + : "Auto Mode"; + + const continuityDetail = + objectiveTurns > 1 + ? `Replay spans ${objectiveTurns} objective turns, so prior work remains part of the current investigation thread.` + : continuityMode === "fresh" + ? "Each run starts from a fresh prompt context, while the replay stays available for review and handoff." + : "This session has a single recorded objective so far; continuity will deepen as follow-up turns accumulate."; + + const healthLabel = + failures === 0 ? "Stable" : recoveries > 0 ? "Recovered" : "Degraded"; + + const healthDetail = + failures === 0 + ? "No failure markers were detected in the curated replay." + : recoveries > 0 + ? `${failures} failure signal${failures === 1 ? "" : "s"} appeared in replay, followed by ${recoveries} recovery point${recoveries === 1 ? "" : "s"}.` + : `${failures} failure signal${failures === 1 ? "" : "s"} appeared in replay without a later recovery point yet.`; + + let activeState = "Waiting"; + let activeDetail = "Start an objective to build a replay trail."; + if (state.isRunning && state.loopHealth) { + activeState = `Live ${titleCase(state.loopHealth.phase)}`; + activeDetail = `Step ${state.loopHealth.step} at depth ${state.loopHealth.depth} is currently in progress.`; + } else if (state.lastCompletion?.kind === "partial") { + activeState = "Partial Result"; + activeDetail = + "The last run stopped cleanly at its bounded step budget. Resume to continue from the saved state."; + } else if (state.lastCompletion) { + activeState = titleCase(state.lastCompletion.kind || "completed"); + activeDetail = state.lastCompletion.reason + ? `Last completion reason: ${titleCase(state.lastCompletion.reason)}.` + : "The last run completed without an active recovery condition."; + } else if (entries.length > 0) { + activeState = "Idle"; + activeDetail = "No agent run is active right now, but the replay context is preserved."; + } + + return { + continuityLabel, + continuityDetail, + healthLabel, + healthDetail, + failures, + recoveries, + entryCount: entries.length, + activeState, + activeDetail, + }; + } + function findSourceByPath( overview: InvestigationOverviewView | null, path: string | null, @@ -172,6 +398,346 @@ export function createOverviewPane(): HTMLElement { return overview.wiki_nav.sources[0]?.file_path ?? null; } + function findElementsByData( + root: ParentNode, + attribute: string, + value: string, + ): HTMLElement[] { + const datasetKey = attribute.replace(/-([a-z])/g, (_, letter: string) => + letter.toUpperCase(), + ); + return Array.from(root.querySelectorAll(`[data-${attribute}]`)).filter( + (element) => element.dataset[datasetKey] === value, + ); + } + + function focusElement(element: HTMLElement | null): boolean { + if (!element) return false; + element.scrollIntoView({ behavior: "smooth", block: "center" }); + const previousOutline = element.style.outline; + const previousOutlineOffset = element.style.outlineOffset; + element.style.outline = "1px solid var(--accent)"; + element.style.outlineOffset = "2px"; + window.setTimeout(() => { + element.style.outline = previousOutline; + element.style.outlineOffset = previousOutlineOffset; + }, 1400); + return true; + } + + function focusOverviewCard( + root: ParentNode, + attribute: string, + value: string, + ): boolean { + return focusElement(findElementsByData(root, attribute, value)[0] ?? null); + } + + function decodeLocatorValue(value: string): string { + const withPipes = value.replace(/%7C/gi, "|"); + try { + return decodeURIComponent(withPipes); + } catch { + return withPipes; + } + } + + function dedupeLocators(locators: EvidenceLocator[]): EvidenceLocator[] { + const seen = new Set(); + const deduped: EvidenceLocator[] = []; + for (const locator of locators) { + const key = `${locator.kind}:${locator.value}`; + if (seen.has(key)) continue; + seen.add(key); + deduped.push(locator); + } + return deduped; + } + + function parseRevelationLocators(revelation: OverviewRevelationView): EvidenceLocator[] { + const locators: EvidenceLocator[] = []; + if (revelation.revelation_id.startsWith("openplanter.revelation|")) { + const parts = revelation.revelation_id.slice("openplanter.revelation|".length).split("|"); + for (const part of parts) { + const separatorIndex = part.indexOf(":"); + if (separatorIndex <= 0) continue; + const key = part.slice(0, separatorIndex) as EvidenceLocatorKind; + const value = decodeLocatorValue(part.slice(separatorIndex + 1).trim()); + if (!value) continue; + if ( + key === "anchor" || + key === "source_ref" || + key === "evidence_ref" || + key === "step" || + key === "turn" || + key === "event" || + key === "replay_seq" || + key === "replay_line" + ) { + locators.push({ kind: key, value }); + } + } + } + + if (revelation.provenance.step_index != null) { + locators.push({ kind: "step", value: String(revelation.provenance.step_index) }); + } + if (revelation.provenance.turn_id) { + locators.push({ kind: "turn", value: revelation.provenance.turn_id }); + } + if (revelation.provenance.event_id) { + locators.push({ kind: "event", value: revelation.provenance.event_id }); + } + if (revelation.provenance.replay_seq != null) { + locators.push({ + kind: "replay_seq", + value: String(revelation.provenance.replay_seq), + }); + } + if (revelation.provenance.replay_line != null) { + locators.push({ + kind: "replay_line", + value: String(revelation.provenance.replay_line), + }); + } + for (const value of revelation.provenance.source_refs ?? []) { + locators.push({ kind: "source_ref", value }); + } + for (const value of revelation.provenance.evidence_refs ?? []) { + locators.push({ kind: "evidence_ref", value }); + } + + return dedupeLocators(locators); + } + + function findReplayEntryByLineNumber(lineNumber: number): ReplayEntry | null { + if (!Number.isFinite(lineNumber) || lineNumber < 1) { + return null; + } + return replayEntries[lineNumber - 1] ?? null; + } + + function findReplayEntryForLocator(locator: EvidenceLocator): ReplayEntry | null { + if (locator.kind === "replay_seq") { + const parsed = Number.parseInt(locator.value, 10); + if (Number.isFinite(parsed)) { + return replayEntries.find((entry) => entry.seq === parsed) ?? null; + } + return null; + } + + if (locator.kind === "replay_line") { + const parsed = Number.parseInt(locator.value, 10); + return findReplayEntryByLineNumber(parsed); + } + + if (locator.kind === "step") { + const step = Number.parseInt(locator.value, 10); + if (Number.isFinite(step)) { + return [...replayEntries] + .reverse() + .find((entry) => entry.step_number === step && isCuratedReplayEntry(entry)) ?? null; + } + return null; + } + + const importMatch = locator.value.match( + /(?:import:replay\.jsonl:|jsonl_record:replay\.jsonl:|replay_event:)(\d+)/, + ); + if (!importMatch) { + return null; + } + + const parsed = Number.parseInt(importMatch[1], 10); + if (!Number.isFinite(parsed)) { + return null; + } + + if ( + locator.value.startsWith("import:replay.jsonl:") || + locator.value.startsWith("jsonl_record:replay.jsonl:") + ) { + return findReplayEntryByLineNumber(parsed); + } + + return replayEntries.find((entry) => entry.seq === parsed) ?? null; + } + + function findReplaySeqForLocator(locator: EvidenceLocator): number | null { + return findReplayEntryForLocator(locator)?.seq ?? null; + } + + function extractWikiPath(locatorValue: string): string | null { + const trimmed = locatorValue.trim(); + if (!trimmed) return null; + if (trimmed.startsWith("wiki:")) { + const wikiPath = trimmed.slice("wiki:".length).trim().replace(/^\/+/, ""); + if (!wikiPath) return null; + return wikiPath.startsWith("wiki/") ? wikiPath : `wiki/${wikiPath}`; + } + if (trimmed.startsWith("source:wiki/")) return trimmed.slice("source:".length); + if (trimmed.startsWith("import:wiki/")) return trimmed.slice("import:".length); + if (trimmed.startsWith("wiki/")) return trimmed; + + const wikiMatch = trimmed.match(/(?:^|[^A-Za-z0-9_])(wiki\/\S+)/); + if (wikiMatch) { + return wikiMatch[1]; + } + return null; + } + + function openWikiEvidence(wikiPath: string, requestedTitle?: string): boolean { + const normalized = wikiPath.trim(); + if (!normalized) return false; + setSelectedPath(normalized); + const detail: OpenWikiDrawerDetail = { + wikiPath: normalized, + source: "chat", + requestedTitle, + }; + window.dispatchEvent(new CustomEvent(OPEN_WIKI_DRAWER_EVENT, { detail })); + return true; + } + + function focusReplay(seq: number): boolean { + if (!replayEntries.some((entry) => entry.seq === seq)) { + return false; + } + selectedReplaySeq = seq; + render(); + window.setTimeout(() => { + focusElement( + replaySection.body.querySelector(`[data-replay-seq="${seq}"]`), + ); + }, 0); + return true; + } + + function navigateLocator(locator: EvidenceLocator, requestedTitle?: string): boolean { + const wikiPath = extractWikiPath(locator.value); + if (wikiPath) { + return openWikiEvidence(wikiPath, requestedTitle); + } + + if (locator.value.startsWith("gap:")) { + return focusOverviewCard( + gapsSection.body, + "gap-id", + locator.value, + ); + } + + if (locator.value.startsWith("action:")) { + return focusOverviewCard( + actionsSection.body, + "action-id", + locator.value.slice("action:".length), + ); + } + + const replayEntrySeq = findReplaySeqForLocator(locator); + if (replayEntrySeq != null) { + return focusReplay(replayEntrySeq); + } + + return false; + } + + function locatorLabel(locator: EvidenceLocator): string { + switch (locator.kind) { + case "step": + return `step ${locator.value}`; + case "turn": + return `turn ${truncate(locator.value, 18)}`; + case "event": + return `event ${truncate(locator.value, 18)}`; + case "replay_seq": + return `replay #${locator.value}`; + case "replay_line": + return `line ${locator.value}`; + case "source_ref": { + const wikiPath = extractWikiPath(locator.value); + return wikiPath ? truncate(wikiPath.replace(/^wiki\//, ""), 24) : truncate(locator.value, 24); + } + case "evidence_ref": + if (locator.value.startsWith("gap:")) { + return truncate(locator.value.slice("gap:".length), 24); + } + return truncate(locator.value, 24); + default: + return truncate(locator.value, 24); + } + } + + function isActionableLocator(locator: EvidenceLocator): boolean { + if (extractWikiPath(locator.value)) return true; + if (locator.value.startsWith("gap:") || locator.value.startsWith("action:")) return true; + return findReplaySeqForLocator(locator) != null; + } + + function appendChipRow( + host: HTMLElement, + labelText: string, + chips: ChipLink[], + ): void { + if (chips.length === 0) return; + + const label = document.createElement("div"); + label.className = "overview-card-meta"; + label.textContent = labelText; + host.appendChild(label); + + const row = document.createElement("div"); + row.className = "overview-card-meta"; + row.style.display = "flex"; + row.style.flexWrap = "wrap"; + row.style.gap = "6px"; + row.style.marginTop = "6px"; + + for (const chip of chips) { + if (chip.onClick) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "overview-pill"; + button.style.cursor = "pointer"; + button.textContent = chip.label; + if (chip.title) { + button.title = chip.title; + } + button.addEventListener("click", chip.onClick); + row.appendChild(button); + } else { + const pill = document.createElement("span"); + pill.className = "overview-pill"; + pill.textContent = chip.label; + if (chip.title) { + pill.title = chip.title; + } + row.appendChild(pill); + } + } + + host.appendChild(row); + } + + function buildLocatorChips( + locators: EvidenceLocator[], + requestedTitle?: string, + ): ChipLink[] { + return locators.map((locator) => { + const actionable = isActionableLocator(locator); + return { + label: locatorLabel(locator), + title: locator.value, + onClick: actionable + ? () => { + navigateLocator(locator, requestedTitle); + } + : undefined, + }; + }); + } + async function loadDocument( path: string | null, overview: InvestigationOverviewView | null = appState.get().overviewData, @@ -216,6 +782,35 @@ export function createOverviewPane(): HTMLElement { } } + async function loadReplay(sessionId: string | null): Promise { + replaySeq += 1; + const seq = replaySeq; + replayStatus = sessionId ? "loading" : "idle"; + replayError = ""; + replayEntries = sessionId ? replayEntries : []; + selectedReplaySeq = null; + render(); + + if (!sessionId) { + return; + } + + try { + const history = await getSessionHistory(sessionId); + if (seq !== replaySeq) return; + replayEntries = history; + replayStatus = "ready"; + replayError = ""; + render(); + } catch (error) { + if (seq !== replaySeq) return; + replayEntries = []; + replayStatus = "error"; + replayError = String(error); + render(); + } + } + function setSelectedPath(path: string): void { const { overviewSelectedWikiPath } = appState.get(); if ( @@ -276,6 +871,8 @@ export function createOverviewPane(): HTMLElement { overviewSelectedWikiPath: selectedPath, })); + void loadReplay(overview.session_id ?? appState.get().sessionId); + if (selectedPath !== loadedDocumentPath || documentStatus !== "ready") { void loadDocument(selectedPath, overview); } @@ -286,9 +883,16 @@ export function createOverviewPane(): HTMLElement { overviewStatus: "error", overviewError: String(error), })); + void loadReplay(appState.get().sessionId); } } + function invalidatePendingLoads(): void { + refreshSeq += 1; + docSeq += 1; + replaySeq += 1; + } + function scheduleRefresh(delayMs: number): void { if (!pane.isConnected) { return; @@ -325,6 +929,34 @@ export function createOverviewPane(): HTMLElement { } } + function renderQuestion(question: OverviewQuestionView): HTMLElement { + const item = document.createElement("div"); + item.className = "overview-card"; + + const top = document.createElement("div"); + top.className = "overview-card-top"; + + const title = document.createElement("div"); + title.className = "overview-card-title"; + title.textContent = question.text; + + const badge = document.createElement("span"); + badge.className = "overview-pill"; + badge.textContent = question.priority; + + top.append(title, badge); + item.appendChild(top); + + if (question.updated_at) { + const meta = document.createElement("div"); + meta.className = "overview-card-meta"; + meta.textContent = `Updated ${formatTimestamp(question.updated_at)}`; + item.appendChild(meta); + } + + return item; + } + function renderSnapshot( overview: InvestigationOverviewView | null, questions: OverviewQuestionView[], @@ -344,8 +976,14 @@ export function createOverviewPane(): HTMLElement { sectionCard("Focus Questions", String(overview.snapshot.focus_question_count)), sectionCard("Supported", String(overview.snapshot.supported_count)), sectionCard("Contested", String(overview.snapshot.contested_count)), - sectionCard("Outstanding Gaps", String(overview.snapshot.outstanding_gap_count)), - sectionCard("Candidate Actions", String(overview.snapshot.candidate_action_count)), + sectionCard( + "Outstanding Gaps", + String(overview.snapshot.outstanding_gap_count), + ), + sectionCard( + "Candidate Actions", + String(overview.snapshot.candidate_action_count), + ), sectionCard("Last Updated", formatTimestamp(overview.generated_at)), ); snapshotSection.body.appendChild(stats); @@ -366,37 +1004,13 @@ export function createOverviewPane(): HTMLElement { snapshotSection.body.appendChild(questionBlock); } - function renderQuestion(question: OverviewQuestionView): HTMLElement { - const item = document.createElement("div"); - item.className = "overview-card"; - - const top = document.createElement("div"); - top.className = "overview-card-top"; - - const title = document.createElement("div"); - title.className = "overview-card-title"; - title.textContent = question.text; - - const badge = document.createElement("span"); - badge.className = "overview-pill"; - badge.textContent = question.priority; - - top.append(title, badge); - item.appendChild(top); - - if (question.updated_at) { - const meta = document.createElement("div"); - meta.className = "overview-card-meta"; - meta.textContent = `Updated ${formatTimestamp(question.updated_at)}`; - item.appendChild(meta); - } - - return item; - } - - function renderGap(gap: OverviewGapView): HTMLElement { + function renderGap( + gap: OverviewGapView, + actionLookup: Map, + ): HTMLElement { const item = document.createElement("div"); item.className = "overview-card"; + item.dataset.gapId = gap.gap_id; const top = document.createElement("div"); top.className = "overview-card-top"; @@ -417,12 +1031,30 @@ export function createOverviewPane(): HTMLElement { meta.textContent = `${gap.scope} gap${gap.related_action_ids.length > 0 ? ` • ${gap.related_action_ids.length} linked action${gap.related_action_ids.length === 1 ? "" : "s"}` : ""}`; item.appendChild(meta); + if (gap.related_action_ids.length > 0) { + appendChipRow( + item, + "Evidence Links", + gap.related_action_ids.map((actionId) => ({ + label: truncate(actionLookup.get(actionId)?.label ?? actionId, 28), + title: actionLookup.get(actionId)?.label ?? actionId, + onClick: () => { + focusOverviewCard(actionsSection.body, "action-id", actionId); + }, + })), + ); + } + return item; } - function renderAction(action: OverviewActionView): HTMLElement { + function renderAction( + action: OverviewActionView, + gapLookup: Map, + ): HTMLElement { const item = document.createElement("div"); item.className = "overview-card"; + item.dataset.actionId = action.action_id; const top = document.createElement("div"); top.className = "overview-card-top"; @@ -450,6 +1082,18 @@ export function createOverviewPane(): HTMLElement { meta.className = "overview-card-meta"; meta.textContent = `Depends on ${action.evidence_gap_refs.length} gap${action.evidence_gap_refs.length === 1 ? "" : "s"}`; item.appendChild(meta); + + appendChipRow( + item, + "Evidence Links", + action.evidence_gap_refs.map((gapId) => ({ + label: truncate(gapLookup.get(gapId)?.label ?? gapId, 28), + title: gapLookup.get(gapId)?.label ?? gapId, + onClick: () => { + focusOverviewCard(gapsSection.body, "gap-id", gapId); + }, + })), + ); } return item; @@ -471,12 +1115,191 @@ export function createOverviewPane(): HTMLElement { const meta = document.createElement("div"); meta.className = "overview-card-meta"; - meta.textContent = `${formatTimestamp(revelation.occurred_at)} • ${revelation.provenance.source}${revelation.provenance.step_index ? ` • step ${revelation.provenance.step_index}` : ""}`; + meta.textContent = `${formatTimestamp(revelation.occurred_at)} • ${revelation.provenance.source}${revelation.provenance.step_index != null ? ` • step ${revelation.provenance.step_index}` : ""}`; item.appendChild(meta); + const locators = parseRevelationLocators(revelation); + appendChipRow(item, "Evidence Trail", buildLocatorChips(locators, revelation.title)); + return item; } + function isCuratedReplayEntry(entry: ReplayEntry): boolean { + const normalized = normalizeReplayRole(entry.role); + return CURATED_REPLAY_ROLES.has(normalized) || normalized.includes("error"); + } + + function buildReplayRevelationIndex( + overview: InvestigationOverviewView | null, + ): Map { + const index = new Map(); + for (const revelation of overview?.recent_revelations ?? []) { + const locators = parseRevelationLocators(revelation); + const matchedReplaySeq = + locators + .map((locator) => findReplaySeqForLocator(locator)) + .find((value): value is number => value != null) ?? null; + if (matchedReplaySeq != null && !index.has(matchedReplaySeq)) { + index.set(matchedReplaySeq, revelation); + } + } + return index; + } + + function renderReplayEntry( + entry: ReplayEntry, + linkedRevelation: OverviewRevelationView | null, + ): HTMLElement { + const card = document.createElement("div"); + card.className = "overview-card"; + card.dataset.replaySeq = String(entry.seq); + if (selectedReplaySeq === entry.seq) { + card.style.outline = "1px solid var(--accent)"; + card.style.outlineOffset = "2px"; + } + + const top = document.createElement("div"); + top.className = "overview-card-top"; + + const title = document.createElement("div"); + title.className = "overview-card-title"; + title.textContent = replayEntryTitle(entry); + + const badge = document.createElement("span"); + badge.className = "overview-pill"; + badge.textContent = replayRoleLabel(entry.role); + + top.append(title, badge); + card.appendChild(top); + + const preview = document.createElement("div"); + preview.className = "overview-card-body"; + preview.textContent = replayPreview(entry); + card.appendChild(preview); + + const metaParts = [formatTimestamp(entry.timestamp)]; + if (entry.step_number != null) { + metaParts.push(`step ${entry.step_number}`); + } + if (entry.step_depth != null) { + metaParts.push(`depth ${entry.step_depth}`); + } + if (entry.step_tool_calls?.length) { + metaParts.push( + `${entry.step_tool_calls.length} tool${entry.step_tool_calls.length === 1 ? "" : "s"}`, + ); + } + + const meta = document.createElement("div"); + meta.className = "overview-card-meta"; + meta.textContent = metaParts.join(" • "); + card.appendChild(meta); + + if (linkedRevelation) { + const linkedMeta = document.createElement("div"); + linkedMeta.className = "overview-card-meta"; + linkedMeta.textContent = `Surfaced as revelation: ${linkedRevelation.title}`; + card.appendChild(linkedMeta); + + const revelationLocators = parseRevelationLocators(linkedRevelation).filter( + (locator) => { + if (locator.kind === "replay_seq") { + return locator.value !== String(entry.seq); + } + return true; + }, + ); + appendChipRow( + card, + "Evidence Links", + buildLocatorChips(revelationLocators, linkedRevelation.title), + ); + } + + return card; + } + + function renderCuratedReplay(overview: InvestigationOverviewView | null): void { + replaySection.body.innerHTML = ""; + + const summary = summarizeReplay(replayEntries); + const stats = document.createElement("div"); + stats.className = "overview-stats"; + stats.append( + sectionCard("Continuity", summary.continuityLabel), + sectionCard("Replay Health", summary.healthLabel), + sectionCard("Failures", String(summary.failures)), + sectionCard("Recoveries", String(summary.recoveries)), + sectionCard("Entries", String(summary.entryCount)), + sectionCard("State", summary.activeState), + ); + replaySection.body.appendChild(stats); + + const continuityDetail = document.createElement("div"); + continuityDetail.className = "overview-card-meta"; + continuityDetail.textContent = summary.continuityDetail; + replaySection.body.appendChild(continuityDetail); + + const healthDetail = document.createElement("div"); + healthDetail.className = "overview-card-meta"; + healthDetail.textContent = summary.healthDetail; + replaySection.body.appendChild(healthDetail); + + const activeDetail = document.createElement("div"); + activeDetail.className = "overview-card-meta"; + activeDetail.textContent = summary.activeDetail; + replaySection.body.appendChild(activeDetail); + + if (appState.get().lastCompletion?.kind === "partial") { + const partial = document.createElement("div"); + partial.className = "overview-alert"; + partial.textContent = + "Partial completion recorded. Resume to continue from the saved investigation state."; + replaySection.body.appendChild(partial); + } + + if (replayStatus === "loading") { + const loading = document.createElement("div"); + loading.className = "overview-empty"; + loading.textContent = "Loading curated replay..."; + replaySection.body.appendChild(loading); + return; + } + + if (replayStatus === "error") { + const error = document.createElement("div"); + error.className = "overview-alert overview-alert-error"; + error.textContent = `Replay unavailable: ${replayError}`; + replaySection.body.appendChild(error); + return; + } + + const revelationByReplaySeq = buildReplayRevelationIndex(overview); + const curatedCandidates = replayEntries.filter((entry) => isCuratedReplayEntry(entry)); + const curatedWindow = curatedCandidates.slice(-CURATED_REPLAY_LIMIT); + if ( + selectedReplaySeq != null && + !curatedWindow.some((entry) => entry.seq === selectedReplaySeq) + ) { + const selectedEntry = + replayEntries.find((entry) => entry.seq === selectedReplaySeq) ?? null; + if (selectedEntry) { + curatedWindow.unshift(selectedEntry); + } + } + const curated = curatedWindow.reverse(); + + replaySection.body.appendChild( + createCardList( + curated, + (entry) => renderReplayEntry(entry, revelationByReplaySeq.get(entry.seq) ?? null), + appState.get().sessionId + ? "Run an objective to populate the replay timeline." + : "Open a session to view replay highlights.", + ), + ); + } + function renderDocumentNav(overview: InvestigationOverviewView | null): void { const selectedPath = appState.get().overviewSelectedWikiPath; @@ -516,7 +1339,8 @@ export function createOverviewPane(): HTMLElement { documentTitleEl.textContent = documentTitle; if (!overview || !appState.get().overviewSelectedWikiPath) { - documentStatusEl.textContent = "Select a wiki page to inspect the underlying document."; + documentStatusEl.textContent = + "Select a wiki page to inspect the underlying document."; documentStatusEl.hidden = false; documentContentEl.hidden = true; documentContentEl.innerHTML = ""; @@ -557,6 +1381,12 @@ export function createOverviewPane(): HTMLElement { function render(): void { const state = appState.get(); const overview = state.overviewData; + const actionLookup = new Map( + (overview?.candidate_actions ?? []).map((action) => [action.action_id, action] as const), + ); + const gapLookup = new Map( + (overview?.outstanding_gaps ?? []).map((gap) => [gap.gap_id, gap] as const), + ); header.innerHTML = ""; const heading = document.createElement("div"); @@ -565,13 +1395,14 @@ export function createOverviewPane(): HTMLElement { header.appendChild(heading); renderAlerts(); + renderCuratedReplay(overview); renderSnapshot(overview, overview?.focus_questions ?? []); gapsSection.body.innerHTML = ""; gapsSection.body.appendChild( createCardList( overview?.outstanding_gaps ?? [], - (gap) => renderGap(gap), + (gap) => renderGap(gap, actionLookup), "No outstanding gaps right now.", ), ); @@ -580,7 +1411,7 @@ export function createOverviewPane(): HTMLElement { actionsSection.body.appendChild( createCardList( overview?.candidate_actions ?? [], - (action) => renderAction(action), + (action) => renderAction(action, gapLookup), "No candidate actions available.", ), ); @@ -603,12 +1434,18 @@ export function createOverviewPane(): HTMLElement { state.overviewData !== lastOverviewData || state.overviewStatus !== lastOverviewStatus || state.overviewError !== lastOverviewError || - state.overviewSelectedWikiPath !== lastOverviewSelectedWikiPath; + state.overviewSelectedWikiPath !== lastOverviewSelectedWikiPath || + state.continuityMode !== lastContinuityMode || + state.loopHealth !== lastLoopHealth || + state.lastCompletion !== lastLastCompletion; lastOverviewData = state.overviewData; lastOverviewStatus = state.overviewStatus; lastOverviewError = state.overviewError; lastOverviewSelectedWikiPath = state.overviewSelectedWikiPath; + lastContinuityMode = state.continuityMode; + lastLoopHealth = state.loopHealth; + lastLastCompletion = state.lastCompletion; if (shouldRender) { render(); @@ -616,11 +1453,16 @@ export function createOverviewPane(): HTMLElement { }); window.addEventListener("session-changed", () => { + invalidatePendingLoads(); loadedDocumentPath = null; documentStatus = "idle"; documentHtml = ""; documentTitle = "Wiki document"; documentError = ""; + replayEntries = []; + replayStatus = "idle"; + replayError = ""; + selectedReplaySeq = null; render(); scheduleRefresh(0); });