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
22 changes: 11 additions & 11 deletions src/components/shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();
Expand All @@ -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
Expand Down Expand Up @@ -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 && <CommandPalette tree={tree} />}
{/* Not gated on the meeting, unlike ⌘K: a question already asked has to
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 才知道哪邊是我方。",

Expand Down Expand Up @@ -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.",

Expand Down
7 changes: 3 additions & 4 deletions src/lib/commands/bind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -74,16 +74,15 @@ const HANDLERS: Partial<Record<CommandId, () => 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();
Expand Down
29 changes: 28 additions & 1 deletion src/lib/history/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
});
});
28 changes: 26 additions & 2 deletions src/lib/history/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ export async function saveLiveToHistory(audioTempPath: string, durationMs: numbe
error: String(error),
}),
);
leaveStoppedCockpit("meeting.notSaved.noTranscript");
return;
}
const s = useStore.getState();
Expand Down Expand Up @@ -1253,6 +1254,20 @@ export async function loadOrgEntry(orgId: string, id: string): Promise<void> {
// 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
Expand All @@ -1263,10 +1278,19 @@ export async function listenForRecordingSaved(): Promise<UnlistenFn> {
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();
Expand Down
45 changes: 19 additions & 26 deletions src/lib/shell/sidebar.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): ShellTreeFacts {
return {
meetingActive: false,
finalizing: false,
liveRoute: true,
collapsed: false,
peek: false,
Expand All @@ -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);
});
});
44 changes: 23 additions & 21 deletions src/lib/shell/sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ShellTreeFacts, "meetingActive" | "finalizing" | "liveRoute">): 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<ShellTreeFacts, "meetingActive" | "liveRoute">): 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;
}

Expand All @@ -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);
}
Expand Down
Loading