Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/feed-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
89 changes: 62 additions & 27 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -33,7 +34,6 @@ import {
} from "./review";
import { startOnboarding } from "./onboarding";
import type {
BoardWire,
Capabilities,
CaptionBridgeEvent,
HostInbound,
Expand Down Expand Up @@ -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 = `
<div id="glass">
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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. */
Expand All @@ -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,
});
}

Expand Down Expand Up @@ -988,9 +985,9 @@ void listen<HostOutbound>("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":
Expand All @@ -1007,7 +1004,7 @@ void listen<HostOutbound>("host://event", (event) => {
break;
}
case "metrics":
pendingMetrics = {
scope.pendingMetrics = {
talkRatioMic: message.talkRatioMic,
smoothScore: message.smoothScore,
micMs: message.micMs,
Expand All @@ -1031,7 +1028,7 @@ void listen<HostOutbound>("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":
Expand All @@ -1048,14 +1045,14 @@ void listen<HostOutbound>("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":
Expand All @@ -1077,9 +1074,47 @@ async function promptSilenceStop(sinceMs: number): Promise<void> {
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<SessionStatus>("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();
Expand Down
41 changes: 41 additions & 0 deletions src/session-scope.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading