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();
+ });
+});