diff --git a/src/components/shell/AppShell.tsx b/src/components/shell/AppShell.tsx
index 7f2d4194..c2d512e5 100644
--- a/src/components/shell/AppShell.tsx
+++ b/src/components/shell/AppShell.tsx
@@ -16,7 +16,7 @@ import { useI18n } from "../../i18n";
import { useNavShortcuts } from "../../lib/nav/useNavShortcuts";
import { useCommandScope } from "../../lib/commands/bind";
import {
- finalizingHold,
+ cockpitHold,
resetSidebarPeek,
shellTreeVisible,
useSidebarCollapsed,
@@ -33,20 +33,20 @@ const LibraryScreen = lazy(() =>
* The tree goes away for two unrelated reasons. A RUNNING meeting takes it,
* because there the screen belongs to something else — the live coach owns the
* window. ⌘B takes it because the user asked for the room back. Everything else
- * — live idle, a loaded recording, the library — keeps the tree on screen, so
+ * — a loaded recording, the library, Home — keeps the tree on screen, so
* nothing is a mode you have to exit.
*
- * One bridge between the two: after End, while the recording is still being
- * written, the cockpit stays up and so does the focus — the tree returns with
- * the report, not before it, so the window re-lays out once instead of flashing
- * a four-column frame in between (see finalizingHold in lib/shell/sidebar.ts;
- * ⌘B can still peek at the tree during a long save). (Settings is not a route here: it opens as
+ * One bridge between the two: once a meeting stops, its cockpit stays up only
+ * on the way out (to the report, or Home when nothing was kept), and so does
+ * the focus — the tree returns with the next screen, not before it, so the
+ * window re-lays out once instead of flashing a four-column frame in between
+ * (see cockpitHold in lib/shell/sidebar.ts; ⌘B can still peek at the tree
+ * during a long or failed save). (Settings is not a route here: it opens as
* its own OS window, see lib/nav/settings.ts.)
*/
export function AppShell() {
const appMode = useStore((s) => s.appMode);
const meetingActive = useStore((s) => isMeetingActive(s.meetingStatus));
- const finalizing = useStore((s) => s.isFinalizingMeeting);
const collapsed = useSidebarCollapsed();
const peek = useSidebarPeek();
const tree = useLibraryTree();
@@ -60,8 +60,8 @@ export function AppShell() {
useCommandScope("main");
useNavShortcuts();
- const shellFacts = { meetingActive, finalizing, liveRoute: appMode === "live", collapsed, peek };
- const hold = finalizingHold(shellFacts);
+ const shellFacts = { meetingActive, liveRoute: appMode === "live", collapsed, peek };
+ const hold = cockpitHold(shellFacts);
const treeVisible = shellTreeVisible(shellFacts);
// A peek belongs to one save. Dropping it whenever no hold is in force (the
@@ -91,7 +91,7 @@ export function AppShell() {
{/* ⌘K outlives a collapsed tree, and matters more there: with no rows
left to aim at, naming the place is the only way to reach it. It
stays absent only while a meeting is RUNNING — see below. The
- finalizing hold keeps it: the meeting is over, and a long save must
+ cockpit hold keeps it: the meeting is over, and a long save must
not lock the user onto the cockpit. */}
{!meetingActive && }
{/* Not gated on the meeting, unlike ⌘K: a question already asked has to
diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts
index fe96cf86..3b1a23dd 100644
--- a/src/i18n/messages.ts
+++ b/src/i18n/messages.ts
@@ -950,6 +950,9 @@ export const zhTW = {
"speakers.voiceFound": "辨識出 {speakers} 位說話者,幫他們命名:",
"speakers.voiceDone": "完成",
"speakers.postRefined": "已依錄音聲紋重新校正說話者",
+ "meeting.notSaved.tooShort": "會議太短,沒有存下來",
+ "meeting.notSaved.noTranscript": "沒有轉錄到任何內容,這場沒有存下來",
+ "meeting.notSaved.failed": "這場會議沒能存下來:{error}",
"speakers.voiceBusyPostSave": "正在自動依聲紋校正說話者,完成後即可再手動辨識",
"speakers.hintWhoAmI": "哪一位是你?把那一欄改成你的名字,AI 才知道哪邊是我方。",
@@ -2180,6 +2183,9 @@ export const en = {
"speakers.voiceFound": "Found {speakers} speakers — name them:",
"speakers.voiceDone": "Done",
"speakers.postRefined": "Speaker labels refined from the recording",
+ "meeting.notSaved.tooShort": "The meeting was too short to save",
+ "meeting.notSaved.noTranscript": "Nothing was transcribed, so this meeting wasn't saved",
+ "meeting.notSaved.failed": "This meeting couldn't be saved: {error}",
"speakers.voiceBusyPostSave": "Speakers are being corrected from the recording — try again once that finishes",
"speakers.hintWhoAmI": "Which one is you? Rename that column to your name so the AI knows which side is yours.",
diff --git a/src/lib/commands/bind.ts b/src/lib/commands/bind.ts
index ddaaa6dc..b8656c13 100644
--- a/src/lib/commands/bind.ts
+++ b/src/lib/commands/bind.ts
@@ -5,7 +5,7 @@ import { log } from "../log";
import { openSettings } from "../nav/settings";
import { navigateTo } from "../nav/navigate";
import { isMeetingActive, useStore } from "../store";
-import { finalizingHold, toggleSidebar, toggleSidebarPeek } from "../shell/sidebar";
+import { cockpitHold, toggleSidebar, toggleSidebarPeek } from "../shell/sidebar";
import { zoomIn, zoomOut, zoomReset } from "../zoom";
import { toggleShortcutSheet } from "./sheet";
import {
@@ -74,16 +74,15 @@ const HANDLERS: Partial void>> = {
if (!s.replay || isMeetingActive(s.meetingStatus)) return;
s.showReplay();
},
- // During a finalizing hold (End pressed, report not open yet) the tree is
+ // During the cockpit hold (meeting stopped, its cockpit still up) the tree is
// held back so the layout changes once; ⌘B then peeks for this hold only and
// leaves the saved preference alone. Otherwise — including a RUNNING meeting,
// where the tree stays hidden whatever the preference says — it is the plain
// persisted toggle it has always been.
"view.toggleSidebar": () => {
const s = useStore.getState();
- const hold = finalizingHold({
+ const hold = cockpitHold({
meetingActive: isMeetingActive(s.meetingStatus),
- finalizing: s.isFinalizingMeeting,
liveRoute: s.appMode === "live",
});
if (hold) toggleSidebarPeek();
diff --git a/src/lib/history/history.test.ts b/src/lib/history/history.test.ts
index 782c2c0d..f507abcf 100644
--- a/src/lib/history/history.test.ts
+++ b/src/lib/history/history.test.ts
@@ -5,16 +5,21 @@ import { describe, it, expect, vi } from "vitest";
vi.mock("../log", () => ({
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
+vi.mock("sonner", () => ({
+ toast: Object.assign(vi.fn(), { message: vi.fn(), error: vi.fn(), success: vi.fn() }),
+}));
import {
applyCorrectedSpeakers,
buildSummary,
+ leaveStoppedCockpit,
mergeAnalysisSnapshot,
mergeStageOutputs,
shouldKeepLiveRecording,
type AnalysisSnapshot,
} from "./history";
-import { speakerKey } from "../store";
+import { speakerKey, useStore } from "../store";
+import { toast } from "sonner";
import type { HistoryEntry } from "./types";
import type { DeliveryAssessment } from "../types";
import { seg } from "../test/fixtures";
@@ -273,3 +278,25 @@ describe("shouldKeepLiveRecording (the live save's keep/discard gate)", () => {
expect(shouldKeepLiveRecording({ segments: [], transcriptionDropped: true })).toBe(true);
});
});
+
+describe("leaveStoppedCockpit (a meeting that ended without a report)", () => {
+ it("leaves the stopped cockpit for Home and says why", () => {
+ vi.mocked(toast.message).mockClear();
+ useStore.setState({ appMode: "live", meetingStatus: "stopped" });
+ leaveStoppedCockpit("meeting.notSaved.tooShort");
+ expect(useStore.getState().appMode).toBe("home");
+ expect(toast.message).toHaveBeenCalledTimes(1);
+ });
+
+ it("does nothing once the user has moved on or started another meeting", () => {
+ vi.mocked(toast.message).mockClear();
+ useStore.setState({ appMode: "library", meetingStatus: "stopped" });
+ leaveStoppedCockpit("meeting.notSaved.noTranscript");
+ expect(useStore.getState().appMode).toBe("library");
+
+ useStore.setState({ appMode: "live", meetingStatus: "recording" });
+ leaveStoppedCockpit("meeting.notSaved.noTranscript");
+ expect(useStore.getState().appMode).toBe("live");
+ expect(toast.message).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/history/history.ts b/src/lib/history/history.ts
index bee2f79c..948a9e3d 100644
--- a/src/lib/history/history.ts
+++ b/src/lib/history/history.ts
@@ -343,6 +343,7 @@ export async function saveLiveToHistory(audioTempPath: string, durationMs: numbe
error: String(error),
}),
);
+ leaveStoppedCockpit("meeting.notSaved.noTranscript");
return;
}
const s = useStore.getState();
@@ -1253,6 +1254,20 @@ export async function loadOrgEntry(orgId: string, id: string): Promise {
// call and needs no `history://open` round trip. What remains here is the
// broadcast that a saved entry CHANGED, which several surfaces still listen to.
+/**
+ * A stopped meeting that produced no report — too short for Rust to keep, or
+ * nothing transcribed — has nowhere to go but its own cockpit, which is a dead
+ * screen once the meeting is over (and, before the cockpit hold, a four-column
+ * one). Go Home and say why, unless the user has already moved on: started
+ * another meeting, or opened something else while the stop settled.
+ */
+export function leaveStoppedCockpit(reasonKey: "meeting.notSaved.tooShort" | "meeting.notSaved.noTranscript"): void {
+ const s = useStore.getState();
+ if (s.appMode !== "live" || isMeetingActive(s.meetingStatus)) return;
+ s.openHome();
+ toast.message(translate(s.settings.language, reasonKey));
+}
+
/** Main-window listener: auto-save the meeting once Rust finishes encoding it,
* and release the titlebar "finalizing" state. saveLiveToHistory clears it
* itself the moment the report opens (its speaker correction and org share
@@ -1263,10 +1278,19 @@ export async function listenForRecordingSaved(): Promise {
if (!isTauri()) return () => {};
const unlistenSaved = await listen<{ path: string; durationMs: number }>(RECORDING_SAVED_EVENT, (e) => {
saveLiveToHistory(e.payload.path, e.payload.durationMs)
- .catch((err) => log.error("history: live save failed", { error: String(err) }))
+ .catch((err) => {
+ log.error("history: live save failed", { error: String(err) });
+ // Stay on the cockpit (its transcript is the only copy left on screen)
+ // but say so — a failed save used to look exactly like a slow one.
+ const lang = useStore.getState().settings.language;
+ toast.error(translate(lang, "meeting.notSaved.failed", { error: String(err) }));
+ })
.finally(clearFinalizing);
});
- const unlistenDiscarded = await listen(RECORDING_DISCARDED_EVENT, clearFinalizing);
+ const unlistenDiscarded = await listen(RECORDING_DISCARDED_EVENT, () => {
+ clearFinalizing();
+ leaveStoppedCockpit("meeting.notSaved.tooShort");
+ });
return () => {
unlistenSaved();
unlistenDiscarded();
diff --git a/src/lib/shell/sidebar.test.ts b/src/lib/shell/sidebar.test.ts
index fb913ab3..21f9bc4e 100644
--- a/src/lib/shell/sidebar.test.ts
+++ b/src/lib/shell/sidebar.test.ts
@@ -1,11 +1,10 @@
import { describe, it, expect } from "vitest";
-import { finalizingHold, shellTreeVisible, type ShellTreeFacts } from "./sidebar";
+import { cockpitHold, shellTreeVisible, type ShellTreeFacts } from "./sidebar";
-/** Live idle, tree expanded, no peek — the plain case. */
+/** A stopped meeting's cockpit, tree expanded, no peek. */
function facts(patch: Partial = {}): ShellTreeFacts {
return {
meetingActive: false,
- finalizing: false,
liveRoute: true,
collapsed: false,
peek: false,
@@ -20,38 +19,32 @@ describe("shellTreeVisible (the shell's one tree decision)", () => {
expect(shellTreeVisible(facts({ meetingActive: true, collapsed: true }))).toBe(false);
});
- it("after End, the tree stays hidden on the cockpit until the save opens the report", () => {
- // The exact frame that used to go four-column: stopped, finalizing, still live.
- expect(shellTreeVisible(facts({ finalizing: true }))).toBe(false);
+ it("a stopped meeting's cockpit never shows the tree — saving, saved, discarded or failed alike", () => {
+ // The four-column frame: the meeting is over, the cockpit is still up. It
+ // used to be held back only while the save was in flight, so a meeting too
+ // short to keep (no report ever opens) left the tree beside the coach.
+ expect(shellTreeVisible(facts())).toBe(false);
});
- it("⌘B during the save peeks: the peek alone decides, the saved preference is ignored", () => {
- expect(shellTreeVisible(facts({ finalizing: true, peek: true }))).toBe(true);
- // An expanded preference doesn't leak the tree in early...
- expect(shellTreeVisible(facts({ finalizing: true, collapsed: false }))).toBe(false);
- // ...and a collapsed one doesn't stop a peek from showing it.
- expect(shellTreeVisible(facts({ finalizing: true, collapsed: true, peek: true }))).toBe(true);
+ it("⌘B on the stopped cockpit peeks: the peek alone decides, the saved preference is ignored", () => {
+ expect(shellTreeVisible(facts({ peek: true }))).toBe(true);
+ expect(shellTreeVisible(facts({ collapsed: false }))).toBe(false);
+ expect(shellTreeVisible(facts({ collapsed: true, peek: true }))).toBe(true);
});
- it("once the report opens (study route) the saved preference is back in charge", () => {
- expect(shellTreeVisible(facts({ finalizing: true, liveRoute: false }))).toBe(true);
+ it("off the cockpit (report, Home, library) the saved preference is in charge", () => {
+ expect(shellTreeVisible(facts({ liveRoute: false }))).toBe(true);
expect(shellTreeVisible(facts({ liveRoute: false, collapsed: true }))).toBe(false);
// A stale peek can't override the preference outside the hold.
- expect(shellTreeVisible(facts({ collapsed: true, peek: true }))).toBe(false);
- });
-
- it("outside any meeting it is simply the ⌘B preference", () => {
- expect(shellTreeVisible(facts())).toBe(true);
- expect(shellTreeVisible(facts({ collapsed: true }))).toBe(false);
+ expect(shellTreeVisible(facts({ liveRoute: false, collapsed: true, peek: true }))).toBe(false);
});
});
-describe("finalizingHold", () => {
- it("holds only between End and the report: stopped, saving, still on the cockpit", () => {
- expect(finalizingHold(facts({ finalizing: true }))).toBe(true);
- expect(finalizingHold(facts({ finalizing: false }))).toBe(false);
- expect(finalizingHold(facts({ finalizing: true, liveRoute: false }))).toBe(false);
+describe("cockpitHold", () => {
+ it("holds exactly while a stopped meeting's cockpit is on screen", () => {
+ expect(cockpitHold(facts())).toBe(true);
+ expect(cockpitHold(facts({ liveRoute: false }))).toBe(false);
// A running meeting is its own focus, not the hold — ⌘B keeps its old meaning there.
- expect(finalizingHold(facts({ finalizing: true, meetingActive: true }))).toBe(false);
+ expect(cockpitHold(facts({ meetingActive: true }))).toBe(false);
});
});
diff --git a/src/lib/shell/sidebar.ts b/src/lib/shell/sidebar.ts
index 531ac8b7..1827bf3b 100644
--- a/src/lib/shell/sidebar.ts
+++ b/src/lib/shell/sidebar.ts
@@ -46,45 +46,47 @@ export function useSidebarCollapsed(): boolean {
);
}
-// ── The finalizing hold ──────────────────────────────────────────────────────
+// ── The cockpit hold ─────────────────────────────────────────────────────────
//
-// Between End and the report opening (the recording is being encoded and
-// written), the live cockpit is still on screen but the meeting is no longer
-// running. Bringing the tree back right then turned the three-column coach into
-// a four-column frame for the whole save, only to re-lay everything out again a
-// few seconds later when the report replaced it. So the tree stays away until
-// the report arrives: the layout changes exactly once.
+// The live cockpit is never shown beside the tree. While a meeting runs the
+// coach owns the window; once it stops, the cockpit is only ever on screen on
+// its way out — to the report (the save opens it), or Home (a meeting too short
+// or too empty to keep). Bringing the tree back in between turned the
+// three-column coach into a four-column frame, then re-laid everything out
+// again when the next screen arrived. It used to be held back only while the
+// save was in flight, which left the four-column frame up whenever a meeting
+// ended without a report; now it is held for as long as the stopped cockpit is
+// on screen, whatever the save is doing.
//
-// A save can still take minutes, and a long one must not trap the user, so ⌘B
-// during the hold PEEKS: it shows/hides the tree for this hold only, without
-// touching the saved collapsed preference above. The peek resets when the hold
-// ends (AppShell), so the next meeting starts hidden again.
+// That can last (a long save, a failed one left up so its transcript is still
+// readable), and it must not trap the user, so ⌘B during the hold PEEKS: it
+// shows/hides the tree for this hold only, without touching the saved collapsed
+// preference above. The peek resets when the hold ends (AppShell), so the next
+// meeting starts hidden again.
/** The facts the shell's tree decision depends on, as plain values. */
export interface ShellTreeFacts {
/** A meeting is recording or paused — the coach owns the window. */
meetingActive: boolean;
- /** End was pressed and the recording's first write hasn't landed yet. */
- finalizing: boolean;
/** The live route (the cockpit) is what's on screen. */
liveRoute: boolean;
/** The saved ⌘B preference. */
collapsed: boolean;
- /** The session-only ⌘B reveal during a finalizing hold. */
+ /** The session-only ⌘B reveal during the cockpit hold. */
peek: boolean;
}
-/** Whether the finalizing hold applies: the cockpit is still up, the meeting
- * has stopped, and its save hasn't opened the report yet. Leaving the live
- * route (⌘K, ⌘1…) ends the hold — there is no cockpit left to protect. */
-export function finalizingHold(f: Pick): boolean {
- return !f.meetingActive && f.finalizing && f.liveRoute;
+/** Whether the cockpit hold applies: the meeting has stopped but its cockpit
+ * is still on screen. Leaving the live route (the report or Home opening, ⌘K,
+ * ⌘1…) ends the hold — there is no cockpit left to protect. */
+export function cockpitHold(f: Pick): boolean {
+ return !f.meetingActive && f.liveRoute;
}
/** Whether the left tree is on screen. Pure + exported for testing. */
export function shellTreeVisible(f: ShellTreeFacts): boolean {
if (f.meetingActive) return false;
- if (finalizingHold(f)) return f.peek;
+ if (cockpitHold(f)) return f.peek;
return !f.collapsed;
}
@@ -97,7 +99,7 @@ function setPeek(next: boolean): void {
for (const l of peekListeners) l();
}
-/** ⌘B during a finalizing hold: show/hide the tree without saving anything. */
+/** ⌘B during the cockpit hold: show/hide the tree without saving anything. */
export function toggleSidebarPeek(): void {
setPeek(!peek);
}