diff --git a/src/feed-state.ts b/src/feed-state.ts index 2d2f6d2..122028e 100644 --- a/src/feed-state.ts +++ b/src/feed-state.ts @@ -43,6 +43,22 @@ export class FeedState { return this.evicted; } + /** + * Clear ALL state so a new session starts empty (#171). The feed is a + * long-lived webview singleton; without this, a second session in the same app + * run inherits the first's blocks, ids, live partials, keys, and eviction + * count. Nothing is lost — every finalized caption is already durable in the + * session archive (#11). Mutates the `blocks` array in place so existing + * references (shimmer cap, heartbeat) observe the empty feed. + */ + reset(): void { + this.blocks.length = 0; + this.byId.clear(); + this.partials.clear(); + this.keyCounter = 0; + this.evicted = 0; + } + /** * Enforce the render window (#57): while more than `windowSize` blocks are * held, drop the oldest ones — skipping pinned blocks and live partials, diff --git a/src/main.ts b/src/main.ts index ebb756c..3e61f7e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -25,6 +25,7 @@ import { import { LANGUAGES, SOURCE_AUTO_CODE, SOURCE_LANGUAGES, languageByCode } from "./languages"; import { FeedState, type CaptionBlock } from "./feed-state"; import { FeedCoalescer, applyShimmerCap } from "./feed-coalescer"; +import { SessionScope } from "./session-scope"; import { buildReview, type CoachingCard, @@ -33,7 +34,6 @@ import { } from "./review"; import { startOnboarding } from "./onboarding"; import type { - BoardWire, Capabilities, CaptionBridgeEvent, HostInbound, @@ -80,16 +80,14 @@ let phase: SessionStatus["phase"] = "idle"; // #53: desired capture channels for the running session (both-on while idle). let channels: SessionChannels = { system: true, mic: true }; let statusDetail = ""; -let summaryLine = ""; let requestCounter = 0; -// Latest full summary/board (#81) — retained so the post-meeting review screen -// can render them; the live strip only shows the first summary line. -let latestSummary: string[] = []; -let latestBoard: BoardWire = { decisions: [], actionItems: [], openQuestions: [] }; -// Latest archive path for the review screen's "open saved file" action. -let latestArchivePath: string | null = null; +// The two long-lived per-session singletons (#171): the caption `feed` and the +// session-scoped model values (summary/board/archive path/metrics) that feed the +// summary strip + post-meeting review. Both are cleared at a new session start +// (resetSessionState) so it never inherits the previous meeting's state. const feed = new FeedState(); +const scope = new SessionScope(); document.body.innerHTML = `
@@ -393,7 +391,7 @@ function render(): void { } function renderSummaryStrip(): void { - const { label, line, live } = summaryStripContent(phase, statusDetail, summaryLine); + const { label, line, live } = summaryStripContent(phase, statusDetail, scope.summaryLine); summaryLabel.textContent = label; summaryLineEl.textContent = line; summaryDot.classList.toggle("on", live); @@ -849,10 +847,9 @@ function speakBetter(text: string): void { window.speechSynthesis.speak(utterance); } -// Metrics arrive (HostOutbound "metrics") just before the archive path -// ("archived"); retain them so the review opens once the session has stopped. -let pendingMetrics: { talkRatioMic: number; smoothScore: number; micMs: number; systemMs: number } | null = - null; +// `scope.pendingMetrics` (session-scoped, in SessionScope): metrics arrive +// (HostOutbound "metrics") just before the archive path ("archived"); retained +// so the review opens once the session has stopped. /** Open the post-meeting review screen (#81) with the retained summary/board + * metrics + the session's own (mic) utterances. Called on session end. */ @@ -863,14 +860,14 @@ function openReview(talkRatioMic: number, smoothScore: number, micMs: number, sy time: block.epochMs !== null ? clockLabel(block.epochMs) : "", })); review.show({ - summary: latestSummary, - board: latestBoard, + summary: scope.latestSummary, + board: scope.latestBoard, talkRatioMic, smoothScore, micMs, systemMs, utterances, - archivePath: latestArchivePath, + archivePath: scope.latestArchivePath, }); } @@ -988,9 +985,9 @@ void listen("host://event", (event) => { break; } case "summary": - summaryLine = message.summary[0] ?? ""; - latestSummary = message.summary; - latestBoard = message.board; + scope.summaryLine = message.summary[0] ?? ""; + scope.latestSummary = message.summary; + scope.latestBoard = message.board; renderSummaryStrip(); break; case "engineSwitch": @@ -1007,7 +1004,7 @@ void listen("host://event", (event) => { break; } case "metrics": - pendingMetrics = { + scope.pendingMetrics = { talkRatioMic: message.talkRatioMic, smoothScore: message.smoothScore, micMs: message.micMs, @@ -1031,7 +1028,7 @@ void listen("host://event", (event) => { void promptSilenceStop(message.sinceMs); break; case "archived": - latestArchivePath = message.path; + scope.latestArchivePath = message.path; showToast(`Saved — ${message.path.split("/").pop() ?? message.path}`); break; case "status": @@ -1048,14 +1045,14 @@ void listen("host://event", (event) => { case "stopped": // Session end (#81): show the review screen with the retained summary/board, // the metrics that just arrived, and the session's own (mic) utterances. - if (pendingMetrics) { + if (scope.pendingMetrics) { openReview( - pendingMetrics.talkRatioMic, - pendingMetrics.smoothScore, - pendingMetrics.micMs, - pendingMetrics.systemMs, + scope.pendingMetrics.talkRatioMic, + scope.pendingMetrics.smoothScore, + scope.pendingMetrics.micMs, + scope.pendingMetrics.systemMs, ); - pendingMetrics = null; + scope.pendingMetrics = null; } break; case "ready": @@ -1077,9 +1074,47 @@ async function promptSilenceStop(sinceMs: number): Promise { else void hostRequest({ type: "silenceSnooze" }); } +/** #171: clear every session-scoped webview singleton so a new session never + * inherits the previous one's feed, cards, summary/board, or archive path. Run + * at the transition INTO a new session (not on Stop), so the just-ended + * session's review screen keeps the state it already captured. Session-scoped + * state audited: `feed` (FeedState) + its `blockEls` DOM, `feedCoalescer`, + * `shimmerEl`, `atBottom`, the `pendingCards`/`analysisCards` result cards, and + * the `scope` model values (summary line / summary / board / archive path / + * metrics). (`coachingCards` lives on the review surface, dismissed below; + * `channels` is re-seeded each start by `session://channels`; `requestCounter` + * stays monotonic to keep card ids unique.) */ +function resetSessionState(): void { + // Caption feed: model + its DOM nodes (the permanent #feed-note stays). + feed.reset(); + for (const el of blockEls.values()) el.remove(); + blockEls.clear(); + feedCoalescer.drain(); // discard block writes queued for the old feed + shimmerEl = null; + atBottom = true; + feedNote.classList.remove("visible"); + renderPinned(); // pinned dock rebuilds empty from the reset feed + + // Inline result / analysis cards (§8.5 / #80) + their DOM. + cardsEl.replaceChildren(); + pendingCards.clear(); + analysisCards.clear(); + + // Summary strip + post-meeting review inputs (#81) — the unit-tested seam. + scope.reset(); +} + void listen("session://status", (event) => { + const prevPhase = phase; phase = event.payload.phase; statusDetail = event.payload.detail ?? ""; + // #171: a new session is beginning (a non-active phase → starting|live). Reset + // all session-scoped state BEFORE its events arrive so it never inherits the + // previous session's feed/cards/summary/board/archive path. Guarded to the + // idle→start edge so pause/resume and the starting→live progression don't + // re-clear a live session. + const wasActive = prevPhase === "starting" || prevPhase === "live" || prevPhase === "paused"; + if (!wasActive && (phase === "starting" || phase === "live")) resetSessionState(); // A new session is starting — dismiss the previous review screen so it never // overlaps the live feed. if ((phase === "starting" || phase === "live") && review.isOpen()) review.hide(); diff --git a/src/session-scope.ts b/src/session-scope.ts new file mode 100644 index 0000000..e40ec48 --- /dev/null +++ b/src/session-scope.ts @@ -0,0 +1,41 @@ +// #171: the session-scoped MODEL values that live OUTSIDE the caption `feed` — +// the summary line, the latest summary/board, the saved archive path, and the +// pending review metrics. They feed the summary strip and the post-meeting +// review screen (#81), and all belong to ONE meeting. +// +// Before this they were scattered module-level singletons in main.ts that +// survived Stop → Start in the same app run, so a second session's review read +// the first session's summary/board/archive path. Grouping them here gives a +// single `reset()` seam that a new session clears alongside `FeedState.reset()` +// — and, unlike main.ts, it is DOM/Tauri-free and unit-testable. + +import type { BoardWire } from "./protocol"; + +/** Post-meeting metrics, retained until the `stopped` event opens the review. */ +export interface SessionMetrics { + talkRatioMic: number; + smoothScore: number; + micMs: number; + systemMs: number; +} + +function emptyBoard(): BoardWire { + return { decisions: [], actionItems: [], openQuestions: [] }; +} + +export class SessionScope { + summaryLine = ""; + latestSummary: string[] = []; + latestBoard: BoardWire = emptyBoard(); + latestArchivePath: string | null = null; + pendingMetrics: SessionMetrics | null = null; + + /** Clear every session-scoped value so a new session starts blank (#171). */ + reset(): void { + this.summaryLine = ""; + this.latestSummary = []; + this.latestBoard = emptyBoard(); + this.latestArchivePath = null; + this.pendingMetrics = null; + } +} diff --git a/test/session-reset.test.ts b/test/session-reset.test.ts new file mode 100644 index 0000000..9880651 --- /dev/null +++ b/test/session-reset.test.ts @@ -0,0 +1,160 @@ +// #171 regression: session-scoped webview state must NOT survive a Stop → Start +// in the same app run. Before this fix a second session inherited the first's +// caption blocks and its own mic utterances (session 2's post-meeting coaching +// reads feed.micUtterances(), so it could rewrite lines spoken in session 1), +// AND the first session's summary / board / archive path (openReview reads them). +// +// main.ts clears that state at the new-session boundary via two testable seams: +// FeedState.reset() (the caption feed) and SessionScope.reset() (the summary/ +// board/archive-path/metrics model values). Both are DOM/Tauri-free; this drives +// the exact start → content → stop → start sequence against them. (The block/card +// DOM clearing stays in main.ts's resetSessionState — DOM-coupled, run in the app.) + +import { describe, expect, it } from "vitest"; + +import { FeedState } from "../src/feed-state"; +import { SessionScope } from "../src/session-scope"; + +function finalized(id: number, channel: "them" | "me", text: string) { + return { + type: "finalized" as const, + id, + channel, + text, + lang: "en", + lowConfidence: false, + epochMs: 1_000 + id, + durationMs: 900, + }; +} + +describe("FeedState.reset (#171 session-scoped reset)", () => { + it("clears all state so a second session starts empty", () => { + const feed = new FeedState(); + + // --- Session 1: real content across both channels, a pin, a live partial --- + feed.applyCaption(finalized(1, "me", "I think we should ship it")); + feed.applyCaption(finalized(2, "them", "agreed, let us do it")); + feed.applyCaption(finalized(3, "me", "great, I will start today")); + feed.applyTranslation([{ id: 2, text: "동의합니다" }], true); + feed.setPinned(2, true); + feed.applyCaption({ type: "partial", channel: "them", text: "one more thing" }); + + // Sanity: session 1 has content the review/coaching path would read. + expect(feed.blocks.length).toBeGreaterThan(0); + expect(feed.micUtterances().map((b) => b.source)).toEqual([ + "I think we should ship it", + "great, I will start today", + ]); + expect(feed.get(2)?.pinned).toBe(true); + expect(feed.latest()).not.toBeNull(); + + // --- Stop → Start: the new-session reset boundary --- + feed.reset(); + + // Session 2 starts with a clean slate: no blocks, no ids, no pins, no live + // partials, no history counter, and crucially no leftover mic utterances. + expect(feed.blocks).toHaveLength(0); + expect(feed.micUtterances()).toEqual([]); + expect(feed.pinnedBlocks()).toEqual([]); + expect(feed.latest()).toBeNull(); + expect(feed.get(1)).toBeNull(); + expect(feed.get(2)).toBeNull(); + expect(feed.evictedCount).toBe(0); + }); + + it("resets the render-key counter so session 2 keys do not collide with session 1", () => { + const feed = new FeedState(); + const first = feed.applyCaption({ type: "partial", channel: "me", text: "hello" }); + + feed.reset(); + + // A fresh session's first block reuses the initial key — no stale-key reuse + // that could alias a session-1 DOM node still keyed the same way. + const afterReset = feed.applyCaption({ type: "partial", channel: "me", text: "new session" }); + expect(afterReset.key).toBe(first.key); + expect(feed.blocks).toHaveLength(1); + }); + + it("drops live partials so a mid-utterance stop does not linger into the next session", () => { + const feed = new FeedState(); + // A partial with no finalized event (e.g. a session stopped mid-sentence). + feed.applyCaption({ type: "partial", channel: "me", text: "I was saying" }); + expect(feed.blocks).toHaveLength(1); + + feed.reset(); + + // The next session's first partial is genuinely new, not a reused partial. + expect(feed.blocks).toHaveLength(0); + const fresh = feed.applyCaption({ type: "partial", channel: "me", text: "brand new" }); + expect(fresh.source).toBe("brand new"); + expect(feed.blocks).toHaveLength(1); + }); +}); + +describe("SessionScope.reset (#171 summary/board/archive reset)", () => { + it("clears the summary, board, archive path, and metrics a new session must not inherit", () => { + const scope = new SessionScope(); + + // --- Session 1: the host filled the summary/board, saved the archive, and + // reported end-of-meeting metrics (exactly what openReview would read) --- + scope.summaryLine = "we agreed to ship on Friday"; + scope.latestSummary = ["we agreed to ship on Friday", "two follow-ups remain"]; + scope.latestBoard = { + decisions: ["ship Friday"], + actionItems: ["send the release notes"], + openQuestions: ["who signs off?"], + }; + scope.latestArchivePath = "/Users/me/LiveCap/2026-07-14 Standup.md"; + scope.pendingMetrics = { talkRatioMic: 0.42, smoothScore: 0.9, micMs: 120_000, systemMs: 60_000 }; + + // --- Stop → Start: the new-session reset boundary --- + scope.reset(); + + // Session 2 must NOT read session 1's review data: the archive path in + // particular could otherwise open the previous meeting's saved file. + expect(scope.summaryLine).toBe(""); + expect(scope.latestSummary).toEqual([]); + expect(scope.latestBoard).toEqual({ decisions: [], actionItems: [], openQuestions: [] }); + expect(scope.latestArchivePath).toBeNull(); + expect(scope.pendingMetrics).toBeNull(); + }); + + it("gives each reset a fresh board object (no shared-reference bleed)", () => { + const scope = new SessionScope(); + const board1 = scope.latestBoard; + scope.reset(); + // A new empty board, not the same instance mutated — so a later push into + // one session's board can never surface in another's. + expect(scope.latestBoard).not.toBe(board1); + expect(scope.latestBoard).toEqual({ decisions: [], actionItems: [], openQuestions: [] }); + }); +}); + +describe("session model reset (#171 start → content → stop → start)", () => { + it("clears the feed AND the summary/board/archive together, as resetSessionState does", () => { + // Model the two seams main.ts resets as a unit at a new session. + const feed = new FeedState(); + const scope = new SessionScope(); + + // Session 1 produced a transcript, a summary/board, and a saved archive. + feed.applyCaption(finalized(1, "me", "let us start")); + feed.applyTranslation([{ id: 1, text: "시작합시다" }], true); + scope.summaryLine = "kickoff"; + scope.latestBoard = { decisions: ["go"], actionItems: [], openQuestions: [] }; + scope.latestArchivePath = "/tmp/session-1.md"; + + // New session start → reset both seams (the model half of resetSessionState). + feed.reset(); + scope.reset(); + + // A completely clean slate for session 2: no transcript, no review data. + expect(feed.blocks).toEqual([]); + expect(feed.micUtterances()).toEqual([]); + expect(scope.summaryLine).toBe(""); + expect(scope.latestSummary).toEqual([]); + expect(scope.latestBoard).toEqual({ decisions: [], actionItems: [], openQuestions: [] }); + expect(scope.latestArchivePath).toBeNull(); + expect(scope.pendingMetrics).toBeNull(); + }); +});