From e794035449d4a6d18f40f86a9d19fa75746574b0 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Sun, 4 Oct 2026 23:25:29 +0800 Subject: [PATCH] [fix] a stopped meeting's cockpit never shows the tree, and one with no report goes Home v0.32.1 held the tree back only while the save was in flight. A meeting that ended without a report (too short for Rust to keep, or nothing transcribed, e.g. a 2-second start/stop whose only line was still partial) cleared the finalizing flag and stayed on its cockpit, so the tree came back beside the coach: the four-column frame again. - cockpitHold replaces finalizingHold: the tree stays hidden for as long as a stopped meeting's cockpit is on screen, whatever the save is doing. Cmd+B still peeks. - leaveStoppedCockpit: a discarded recording (Rust) or a skipped save (no transcript) goes Home with a toast saying why, unless the user already moved on or started another meeting. - A failed save stays on the cockpit (its transcript is the only copy on screen) and now shows an error toast instead of looking like a slow save. --- src/components/shell/AppShell.tsx | 22 +++++++-------- src/i18n/messages.ts | 6 +++++ src/lib/commands/bind.ts | 7 +++-- src/lib/history/history.test.ts | 29 +++++++++++++++++++- src/lib/history/history.ts | 28 +++++++++++++++++-- src/lib/shell/sidebar.test.ts | 45 +++++++++++++------------------ src/lib/shell/sidebar.ts | 44 +++++++++++++++--------------- 7 files changed, 116 insertions(+), 65 deletions(-) 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); }