diff --git a/crates/livecap-core/src/audio/mic.rs b/crates/livecap-core/src/audio/mic.rs index aba9645..6ea6b8f 100644 --- a/crates/livecap-core/src/audio/mic.rs +++ b/crates/livecap-core/src/audio/mic.rs @@ -18,8 +18,6 @@ use super::AudioChunk; pub struct MicCapture { stop_tx: std::sync::mpsc::Sender<()>, join: Option>, - device: AudioDevice, - sample_rate: u32, } impl MicCapture { @@ -32,7 +30,7 @@ impl MicCapture { }; info!("Starting microphone capture on '{}'", device.name); - let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); let (stop_tx, stop_rx) = std::sync::mpsc::channel::<()>(); let thread_device = device.clone(); @@ -52,7 +50,7 @@ impl MicCapture { ); let stream = build_input_stream(&cpal_device, &config, out)?; stream.play()?; - let _ = ready_tx.send(Ok(sample_rate)); + let _ = ready_tx.send(Ok(())); Ok(stream) })(); @@ -69,27 +67,19 @@ impl MicCapture { } })?; - let sample_rate = ready_rx + // The ready channel propagates the capture thread's setup result (and + // blocks until it reports): the payload is unit — the native rate is not + // consumed by the pipeline, which resamples every AudioChunk by its own + // reported rate. + ready_rx .recv() .map_err(|_| anyhow!("Microphone capture thread exited before reporting status"))??; Ok(Self { stop_tx, join: Some(join), - device, - sample_rate, }) } - - /// The device this capture is reading from. - pub fn device(&self) -> &AudioDevice { - &self.device - } - - /// Native sample rate of the capture stream. - pub fn sample_rate(&self) -> u32 { - self.sample_rate - } } impl Drop for MicCapture { diff --git a/crates/livecap-core/src/audio/system.rs b/crates/livecap-core/src/audio/system.rs index 7e7e77b..82d6ec9 100644 --- a/crates/livecap-core/src/audio/system.rs +++ b/crates/livecap-core/src/audio/system.rs @@ -26,7 +26,11 @@ use crate::error::CoreError; /// Handle to a running system-audio capture. Dropping it stops the capture. pub struct SystemAudioCapture { + // Held only as an RAII guard: its `Drop` stops the tap thread when this + // handle is dropped. Never read directly (the initial rate it used to expose + // is no longer consumed), so `dead_code` is allowed for this field. #[cfg(target_os = "macos")] + #[allow(dead_code)] inner: macos::MacSystemAudioCapture, } @@ -68,18 +72,6 @@ impl SystemAudioCapture { .into()) } } - - /// Initial sample rate reported by the capture device. - pub fn sample_rate(&self) -> u32 { - #[cfg(target_os = "macos")] - { - self.inner.sample_rate() - } - #[cfg(not(target_os = "macos"))] - { - 0 - } - } } #[cfg(target_os = "macos")] @@ -140,14 +132,13 @@ mod macos { pub(super) struct MacSystemAudioCapture { stop: Arc, join: Option>, - sample_rate: u32, } impl MacSystemAudioCapture { pub(super) fn start(out: mpsc::UnboundedSender) -> Result { let stop = Arc::new(AtomicBool::new(false)); let thread_stop = stop.clone(); - let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); // All Core Audio objects (tap, aggregate device, IO proc context) // are created, used, and destroyed on this thread. @@ -155,20 +146,18 @@ mod macos { .name("livecap-system-audio".into()) .spawn(move || run_capture(thread_stop, out, ready_tx))?; - let sample_rate = ready_rx.recv().map_err(|_| { + // The ready channel propagates the capture thread's setup result (and + // blocks until it reports); the payload is unit — the tap's initial + // rate is not consumed, each AudioChunk carries its own current rate. + ready_rx.recv().map_err(|_| { anyhow!("System-audio capture thread exited before reporting status") })??; Ok(Self { stop, join: Some(join), - sample_rate, }) } - - pub(super) fn sample_rate(&self) -> u32 { - self.sample_rate - } } impl Drop for MacSystemAudioCapture { @@ -244,7 +233,7 @@ mod macos { fn run_capture( stop: Arc, out: mpsc::UnboundedSender, - ready_tx: std::sync::mpsc::Sender>, + ready_tx: std::sync::mpsc::Sender>, ) { struct Devices { tap_id: AudioObjectID, @@ -444,7 +433,7 @@ mod macos { "CoreAudio: system-audio capture running at {} Hz", current_rate.load(Ordering::Acquire) ); - let _ = ready_tx.send(Ok(current_rate.load(Ordering::Acquire))); + let _ = ready_tx.send(Ok(())); // Pump loop: drain the ring buffer into the pipeline channel and // re-poll the device rate periodically (it changes when the default diff --git a/crates/livecap-core/src/pipeline.rs b/crates/livecap-core/src/pipeline.rs index 9b53bcd..ce4c701 100644 --- a/crates/livecap-core/src/pipeline.rs +++ b/crates/livecap-core/src/pipeline.rs @@ -185,27 +185,6 @@ impl CaptionPipeline { )) } - /// Convenience: start both captures. `mic`/`system`: `Some(device)` - /// captures from that device, `None` skips the channel entirely. Use - /// [`Self::start_mic`] / [`Self::start_system`] for default-device - /// capture of a single channel. - pub fn start_capture( - &mut self, - mic: Option, - system: Option, - ) -> Result<()> { - if mic.is_none() && system.is_none() { - return Err(anyhow!("At least one of mic/system must be provided")); - } - if let Some(mic_device) = mic { - self.start_mic(Some(mic_device))?; - } - if let Some(system_device) = system { - self.start_system(Some(&system_device))?; - } - Ok(()) - } - /// Start microphone capture (`None` = system default input device) on /// the [`Channel::Mic`] channel. pub fn start_mic(&mut self, device: Option) -> Result<()> { diff --git a/crates/livecap-core/src/vad.rs b/crates/livecap-core/src/vad.rs index 87005b2..27e3007 100644 --- a/crates/livecap-core/src/vad.rs +++ b/crates/livecap-core/src/vad.rs @@ -15,6 +15,15 @@ use std::time::Duration; /// Silero VAD requires 16 kHz input. pub const VAD_SAMPLE_RATE: u32 = 16000; +/// Estimated confidence for a segment we cut/ended ourselves (force-cut on a +/// very long utterance, or a flush at capture stop) rather than one Silero +/// completed on its own. Named so the two forced sites stay in lockstep if these +/// heuristics are ever retuned. +const FORCED_SEGMENT_CONFIDENCE: f32 = 0.8; + +/// Estimated confidence for a segment Silero completed via a natural SpeechEnd. +const VAD_SEGMENT_CONFIDENCE: f32 = 0.9; + /// Represents a complete speech segment detected by VAD. #[derive(Debug, Clone)] pub struct SpeechSegment { @@ -130,11 +139,6 @@ impl ContinuousVadProcessor { &self.current_speech } - /// Milliseconds of (16 kHz) audio processed so far. - pub fn processed_ms(&self) -> f64 { - self.processed_samples as f64 / (VAD_SAMPLE_RATE as f64 / 1000.0) - } - /// Force-cut the utterance currently in progress and return it as a /// segment, keeping the session in speech state. Used to bound utterance /// length when someone talks for a very long time without pausing. @@ -159,7 +163,7 @@ impl ContinuousVadProcessor { samples: std::mem::take(&mut self.current_speech), start_timestamp_ms: start_ms, end_timestamp_ms: end_ms, - confidence: 0.8, // estimated confidence for a forced cut + confidence: FORCED_SEGMENT_CONFIDENCE, }; self.speech_start_sample = self.processed_samples; Some(segment) @@ -240,7 +244,7 @@ impl ContinuousVadProcessor { samples: std::mem::take(&mut self.current_speech), start_timestamp_ms: start_ms, end_timestamp_ms: end_ms, - confidence: 0.8, // estimated confidence for a forced end + confidence: FORCED_SEGMENT_CONFIDENCE, }); self.in_speech = false; } @@ -332,7 +336,7 @@ impl ContinuousVadProcessor { samples: speech_samples, start_timestamp_ms: start_ms, end_timestamp_ms: end_timestamp_ms as f64, - confidence: 0.9, // VAD confidence + confidence: VAD_SEGMENT_CONFIDENCE, }; info!( diff --git a/index.html b/index.html index 3030031..7fba4c6 100644 --- a/index.html +++ b/index.html @@ -16,7 +16,7 @@ window.__lcBootError = kind + ": " + msg; var el = document.getElementById("boot-error") || document.createElement("pre"); el.id = "boot-error"; - el.style.cssText = "color:#e8b84b;font:11px/1.5 ui-monospace,monospace;padding:14px;white-space:pre-wrap;margin:0"; + el.style.cssText = "color:var(--accent-live);font:11px/1.5 ui-monospace,monospace;padding:14px;white-space:pre-wrap;margin:0"; el.textContent = "LiveCap UI failed to start\n" + window.__lcBootError; document.body.appendChild(el); } diff --git a/packages/archive/src/parse.ts b/packages/archive/src/parse.ts index 5510649..7052691 100644 --- a/packages/archive/src/parse.ts +++ b/packages/archive/src/parse.ts @@ -13,6 +13,7 @@ // dashboard aggregations stay NaN-free (cf. #88). import { COACHING_ARROW, COACHING_CHANGE_SEP } from "./render"; +import { WORKING_TITLE } from "./sanitize"; import type { BoardData, CaptionEntry, CoachingData, MetricsData, Speaker } from "./types"; /** Header metadata recovered from the `> …` meta line + the H1 title. */ @@ -52,8 +53,6 @@ export interface ParsedSession { isRecording: boolean; } -/** Working title the writer uses until finalize (mirrors writer.ts WORKING_TITLE). */ -const WORKING_TITLE = "(recording)"; // The exact separators the renderer emits (render.ts) — kept as named constants // so the inversion can never silently drift from the format it mirrors. diff --git a/packages/engine/src/claude-cli-engine.ts b/packages/engine/src/claude-cli-engine.ts index ec54cbd..16db768 100644 --- a/packages/engine/src/claude-cli-engine.ts +++ b/packages/engine/src/claude-cli-engine.ts @@ -12,9 +12,10 @@ import { randomUUID } from "node:crypto"; import { buildClaudeArgs } from "./args"; import { sanitizeChildEnv } from "./env"; +import { parseBrief } from "./internal/brief"; import { AsyncChannel } from "./internal/channel"; import { Mutex } from "./internal/mutex"; -import { stderrDigest } from "./internal/redact"; +import { MAX_STDERR_TAIL, stderrDigest } from "./internal/redact"; import { asTaskMessage, buildGlossarySetupMessage, @@ -93,7 +94,6 @@ export interface ClaudeCliEngineConfig { } /** Cap on the retained stderr tail used only to derive a non-content hash (chars). */ -const MAX_STDERR_TAIL = 2000; /** Default per-turn idle watchdog window — mirrors the local tier's 30s (#135). */ const DEFAULT_TURN_TIMEOUT = 30_000; /** Consecutive turn failures before a `degraded` event drives auto-fallback (#135). */ @@ -649,14 +649,3 @@ export class ClaudeCliEngine implements TranslationEngine { } } } - -/** Split a summary response into the running paragraph and board lines. */ -function parseBrief(text: string): { summary: string; board: string[] } { - const lines = text - .split("\n") - .map((l) => l.trim()) - .filter((l) => l !== ""); - const summary = lines.find((l) => !l.startsWith("[")) ?? ""; - const board = lines.filter((l) => l.startsWith("[")); - return { summary, board }; -} diff --git a/packages/engine/src/credit-ledger.ts b/packages/engine/src/credit-ledger.ts index 1a26b72..e923014 100644 --- a/packages/engine/src/credit-ledger.ts +++ b/packages/engine/src/credit-ledger.ts @@ -9,9 +9,6 @@ import type { Usage } from "./types"; /** Agent SDK monthly pool presets (PROPOSAL §6). */ -export const POOL_PRESETS = { pro: 20, max5x: 100, max20x: 200 } as const; -export type PlanId = keyof typeof POOL_PRESETS; - /** Atomic-write filesystem surface (injected). */ export interface LedgerFs { exists(path: string): boolean; diff --git a/packages/engine/src/detect.ts b/packages/engine/src/detect.ts index 52e93a3..7a5b114 100644 --- a/packages/engine/src/detect.ts +++ b/packages/engine/src/detect.ts @@ -2,15 +2,17 @@ // access are injected so this stays pure and Linux-headless testable — the // package never resolves a binary path on its own (consumer injects it). -/** Default binary names to look for, in preference order. */ -export const DEFAULT_CLI_NAMES: readonly string[] = ["claude", "codex"]; +/** Default binary names to look for. Only the Claude CLI is supported — + * probeCapabilities/buildClaudeArgs emit Claude-CLI-specific argv, so handing a + * different vendor's binary to ClaudeCliEngine would fail unpredictably. */ +export const DEFAULT_CLI_NAMES: readonly string[] = ["claude"]; export interface FindCliOptions { /** The PATH string to scan (e.g. process.env.PATH). */ path: string; /** Predicate: is this absolute path an executable file? */ isExecutable: (candidate: string) => boolean; - /** Binary names to look for; defaults to claude then codex. */ + /** Binary names to look for; defaults to just "claude". */ names?: readonly string[]; /** Path entry separator; defaults to ":" (POSIX). */ pathSeparator?: string; diff --git a/packages/engine/src/env.ts b/packages/engine/src/env.ts index 32e9f59..b0c62a0 100644 --- a/packages/engine/src/env.ts +++ b/packages/engine/src/env.ts @@ -25,11 +25,19 @@ const BILLING_REDIRECT = * per-token API billing (the #25 class), so it strips with the credentials * under the same intentional-custom-endpoint exception (#145). */ -export function sanitizeChildEnv(base: Record): Record { +/** Copy an env map, dropping keys whose value is `undefined`, so `spawn` gets a + * clean string→string env. The shared primitive under both the Claude-specific + * {@link sanitizeChildEnv} and the local engine's plain drop-undefined pass. */ +export function dropUndefinedEnv(base: Record): Record { const env: Record = {}; for (const [key, value] of Object.entries(base)) { if (typeof value === "string") env[key] = value; } + return env; +} + +export function sanitizeChildEnv(base: Record): Record { + const env = dropUndefinedEnv(base); env.MAX_THINKING_TOKENS = "0"; if (!env.ANTHROPIC_BASE_URL) { for (const key of Object.keys(env)) { diff --git a/packages/engine/src/extras-pipeline.ts b/packages/engine/src/extras-pipeline.ts index 3f13f32..884b333 100644 --- a/packages/engine/src/extras-pipeline.ts +++ b/packages/engine/src/extras-pipeline.ts @@ -12,6 +12,7 @@ import { buildQuickTranslatePrompt, buildReplyPrompt, buildSummaryBoardPrompt, + DEFAULT_CONTEXT_CAPTIONS, parseAnalyzeRespond, parseCoachBatch, parseCoachResult, @@ -149,7 +150,7 @@ export class ExtrasPipeline { this.engine = config.engine; this.summaryLanguage = config.summaryLanguage; this.meetingLanguage = config.meetingLanguage; - this.contextCaptions = config.contextCaptions ?? 10; + this.contextCaptions = config.contextCaptions ?? DEFAULT_CONTEXT_CAPTIONS; this.budget = config.budget; } diff --git a/packages/engine/src/extras-prompts.ts b/packages/engine/src/extras-prompts.ts index 8da02c7..337c77c 100644 --- a/packages/engine/src/extras-prompts.ts +++ b/packages/engine/src/extras-prompts.ts @@ -24,7 +24,7 @@ const INTENT_INSTRUCTION: Record = { suggest: "offer a concrete suggestion", }; -const DEFAULT_CONTEXT_CAPTIONS = 10; +export const DEFAULT_CONTEXT_CAPTIONS = 10; /** Build the summary+board request (one call feeds both — §8.4). */ export function buildSummaryBoardPrompt( diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index e32944f..d128b7e 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -17,37 +17,21 @@ export type { ParsedEvent, } from "./types"; -export { StreamJsonParser } from "./stream-parser"; -export { sanitizeChildEnv, detectProxy, detectCustomEndpoint } from "./env"; -export { buildClaudeArgs, ISOLATION_ARGS, DEFAULT_MODEL } from "./args"; +export { detectProxy, detectCustomEndpoint } from "./env"; export type { ClaudeArgsOptions } from "./args"; -export { - buildSystemPrompt, - buildTranslateMessage, - buildSummaryMessage, - buildGlossarySetupMessage, - buildReseedMessage, - formatUserMessageLine, -} from "./prompt"; export type { PromptOptions } from "./prompt"; export { findCliBins, probeCapabilities, DEFAULT_CLI_NAMES } from "./detect"; export type { FindCliOptions, CommandRunner, CommandResult, Capabilities } from "./detect"; export { TranslationQueue } from "./queue"; export type { QueueOptions } from "./queue"; -export { ClaudeCliEngine, EngineTurnError, EngineTimeoutError } from "./claude-cli-engine"; +export { ClaudeCliEngine } from "./claude-cli-engine"; export type { ClaudeCliEngineConfig, EngineHealthEvent } from "./claude-cli-engine"; // Issue #6 — local LLM fallback tier (PROPOSAL §4 tier 2). export { LocalLlmEngine } from "./local-llm-engine"; export type { LocalLlmEngineConfig } from "./local-llm-engine"; export { stripNonTranslation } from "./translation-guard"; -export { - ensureModel, - ModelChecksumError, - ModelDownloadStallError, - nodeDownloadFs, - nodeRangeFetcher, -} from "./model-download"; +export { ensureModel, nodeDownloadFs, nodeRangeFetcher } from "./model-download"; export type { DownloadFs, RangeFetcher, @@ -58,13 +42,12 @@ export { QWEN3_4B_Q4_K_M, LLAMA_CPP_RELEASE } from "./pins"; export type { ModelArtifact, LlamaCppAsset, LlamaCppReleasePin } from "./pins"; // Issue #7 — credit accounting + auto-fallback policy (PROPOSAL §6/§8.7). -export { CreditAccountant, periodKeyFor, POOL_PRESETS } from "./credit-ledger"; +export { CreditAccountant } from "./credit-ledger"; export type { CreditConfig, CreditEvent, GaugeState, LedgerFs, - PlanId, } from "./credit-ledger"; export { nodeLedgerFs } from "./credit-fs"; export { FallbackRouter } from "./fallback-router"; @@ -87,10 +70,6 @@ export { buildReplyPrompt, buildAnalyzeRespondPrompt, buildQuickTranslatePrompt, - buildCoachPrompt, - parseSummaryBoard, - parseAnalyzeRespond, - parseCoachResult, } from "./extras-prompts"; export type { MeetingBoard, diff --git a/packages/engine/src/internal/brief.ts b/packages/engine/src/internal/brief.ts new file mode 100644 index 0000000..aedac5c --- /dev/null +++ b/packages/engine/src/internal/brief.ts @@ -0,0 +1,15 @@ +// Shared meeting-brief parsing for the engine adapters. Both the CLI and local +// tiers return a summarize() response as one running paragraph plus `[Label]` +// board lines; this splits them the same way so the two tiers can't drift. + +/** Split a summary response into the running paragraph and board lines: the + * first non-`[` line is the summary, every `[`-prefixed line is a board item. */ +export function parseBrief(text: string): { summary: string; board: string[] } { + const lines = text + .split("\n") + .map((line) => line.trim()) + .filter((line) => line !== ""); + const summary = lines.find((line) => !line.startsWith("[")) ?? ""; + const board = lines.filter((line) => line.startsWith("[")); + return { summary, board }; +} diff --git a/packages/engine/src/internal/redact.ts b/packages/engine/src/internal/redact.ts index 1f3e70c..8904025 100644 --- a/packages/engine/src/internal/redact.ts +++ b/packages/engine/src/internal/redact.ts @@ -5,6 +5,10 @@ import { createHash } from "node:crypto"; +/** Cap on retained child-stderr tail (bytes) kept for the digest — one value + * shared by both engine adapters so their stderr redaction can't drift. */ +export const MAX_STDERR_TAIL = 2000; + /** A content-free summary of captured child stderr. */ export function stderrDigest(byteCount: number, tail: string): string { if (byteCount === 0) return "no stderr"; diff --git a/packages/engine/src/local-llm-engine.ts b/packages/engine/src/local-llm-engine.ts index dcc6126..cf74c2f 100644 --- a/packages/engine/src/local-llm-engine.ts +++ b/packages/engine/src/local-llm-engine.ts @@ -7,7 +7,9 @@ import { spawn } from "node:child_process"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; -import { stderrDigest } from "./internal/redact"; +import { dropUndefinedEnv } from "./env"; +import { parseBrief } from "./internal/brief"; +import { MAX_STDERR_TAIL, stderrDigest } from "./internal/redact"; import { asTaskMessage, buildSummaryMessage, buildSystemPrompt, buildTranslateMessage } from "./prompt"; import { stripNonTranslation, stripThinking } from "./translation-guard"; import type { @@ -48,7 +50,6 @@ export interface LocalLlmEngineConfig { fetchImpl?: typeof fetch; } -const MAX_STDERR_TAIL = 2000; const DEFAULT_HOST = "127.0.0.1"; const DEFAULT_CTX = 4096; const DEFAULT_STARTUP_TIMEOUT = 60_000; @@ -122,7 +123,7 @@ export class LocalLlmEngine implements TranslationEngine { ]; const child = spawn(this.config.bin, args, { - env: this.config.env ? sanitize(this.config.env) : undefined, + env: this.config.env ? dropUndefinedEnv(this.config.env) : undefined, stdio: ["pipe", "pipe", "pipe"], }); this.child = child; @@ -302,21 +303,3 @@ export class LocalLlmEngine implements TranslationEngine { function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } - -/** Drop undefined values so spawn gets a clean string env. */ -function sanitize(env: Record): Record { - const out: Record = {}; - for (const [key, value] of Object.entries(env)) if (typeof value === "string") out[key] = value; - return out; -} - -/** Split a summary response into the running paragraph and board lines. */ -function parseBrief(text: string): { summary: string; board: string[] } { - const lines = text - .split("\n") - .map((line) => line.trim()) - .filter((line) => line !== ""); - const summary = lines.find((line) => !line.startsWith("[")) ?? ""; - const board = lines.filter((line) => line.startsWith("[")); - return { summary, board }; -} diff --git a/packages/engine/test/detect.test.ts b/packages/engine/test/detect.test.ts index 8792e35..abc104d 100644 --- a/packages/engine/test/detect.test.ts +++ b/packages/engine/test/detect.test.ts @@ -6,9 +6,13 @@ import type { CommandResult } from "../src/detect"; describe("findCliBins", () => { it("returns matches in (name, PATH-entry) preference order", () => { const present = new Set(["/opt/bin/claude", "/usr/local/bin/codex", "/usr/local/bin/claude"]); + // Pass names explicitly: the default is claude-only (the engine can only + // drive the Claude CLI), so a second name is supplied here purely to exercise + // the name-then-PATH ordering. const found = findCliBins({ path: "/opt/bin:/usr/local/bin", isExecutable: (c) => present.has(c), + names: ["claude", "codex"], }); // claude before codex; within claude, /opt/bin before /usr/local/bin. expect(found).toEqual(["/opt/bin/claude", "/usr/local/bin/claude", "/usr/local/bin/codex"]); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d2810d8..b519229 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,6 +15,7 @@ mod settings; mod snap; mod tray; mod ui_state; +mod util; use std::time::Duration; @@ -39,6 +40,13 @@ pub const CAPABILITIES: Capabilities = Capabilities { settings: true, }; +/// Human-readable accelerator hints for the global shortcuts. Single source so +/// the tray menu's displayed hint (`tray.rs`) and the registration error log +/// below cannot drift from each other if `shortcut_toggle`/`shortcut_cycle` +/// change (nothing else ties the label strings to the `Shortcut` values). +pub(crate) const TOGGLE_LABEL: &str = "Alt+Space"; +pub(crate) const CYCLE_LABEL: &str = "Alt+Shift+Space"; + fn shortcut_toggle() -> Shortcut { Shortcut::new(Some(Modifiers::ALT), Code::Space) } @@ -434,7 +442,7 @@ pub fn run() { }); } - for (name, shortcut) in [("Alt+Space", shortcut_toggle()), ("Alt+Shift+Space", shortcut_cycle())] + for (name, shortcut) in [(TOGGLE_LABEL, shortcut_toggle()), (CYCLE_LABEL, shortcut_cycle())] { if let Err(e) = app.global_shortcut().register(shortcut) { eprintln!("livecap: could not register global hotkey {name}: {e}"); diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs index d357b7f..843abaa 100644 --- a/src-tauri/src/session.rs +++ b/src-tauri/src/session.rs @@ -15,7 +15,7 @@ use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering}; use std::sync::{Arc, Mutex as StdMutex}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; use livecap_core::model::DEFAULT_MODEL; use livecap_core::{CaptionKind, CaptionPipeline, ModelManager, PipelineConfig}; @@ -26,11 +26,15 @@ use crate::bridge::BridgeCaption; use crate::settings::SettingsState; use crate::tray; -const HOST_EXIT_TIMEOUT: Duration = Duration::from_secs(60); +/// How long `stop()` waits for the host's `stopped` JSONL event (archive +/// finalized) after sending `stop`. Since #82 the host deliberately does NOT +/// exit on stop — it stays alive to serve the review screen's Coaching tab — so +/// this bounds the wait for that event, not a process exit. +const HOST_STOPPED_EVENT_TIMEOUT: Duration = Duration::from_secs(60); /// Bound on the graceful host stop during process teardown (#66). Shorter than -/// [`HOST_EXIT_TIMEOUT`] because the user is quitting / the process got a -/// SIGTERM and is expected to disappear promptly; the host's own stop (engine +/// [`HOST_STOPPED_EVENT_TIMEOUT`] because the user is quitting / the process got +/// a SIGTERM and is expected to disappear promptly; the host's own stop (engine /// SIGTERM + a 2 s grace, then SIGKILL) fits comfortably inside this. const SHUTDOWN_HOST_TIMEOUT: Duration = Duration::from_secs(8); @@ -188,13 +192,6 @@ fn emit_status(app: &AppHandle, phase: Phase, detail: Option) { ); } -fn now_epoch_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0) -} - fn is_executable(path: &Path) -> bool { #[cfg(unix)] { @@ -356,7 +353,7 @@ fn spawn_host(app: &AppHandle, start_message: &serde_json::Value) -> Result Result, String> { let Some(mapped) = BridgeCaption::from_event( event, || session.next_caption_id.fetch_add(1, Ordering::Relaxed) + 1, - now_epoch_ms(), + crate::util::epoch_ms(), ) else { continue; }; @@ -681,7 +678,7 @@ pub async fn stop(app: AppHandle) -> Result<(), String> { } let _ = write_host_line(&host.stdin, &serde_json::json!({ "type": "stop" })); let waited = - tauri::async_runtime::spawn_blocking(move || rx.recv_timeout(HOST_EXIT_TIMEOUT)).await; + tauri::async_runtime::spawn_blocking(move || rx.recv_timeout(HOST_STOPPED_EVENT_TIMEOUT)).await; if !matches!(waited, Ok(Ok(()))) { // The host did not confirm in time; the archive working file is still // on disk (incremental writes) — nothing is lost. Reap it rather than @@ -1268,7 +1265,7 @@ mod tests { #[test] fn crashed_host_unblocks_the_stop_waiter_immediately() { - // #169: stop() waits on `stopped_signal` up to HOST_EXIT_TIMEOUT (60s). If + // #169: stop() waits on `stopped_signal` up to HOST_STOPPED_EVENT_TIMEOUT (60s). If // the host crashes inside the stop window it never emits `stopped`; the // reader thread's stdout-closed handler drops the waiter's sender so the // wait fails FAST (channel disconnected) instead of idling the full @@ -1285,7 +1282,7 @@ mod tests { // The waiter (stop()'s bounded wait) returns at once, and NOT with Ok(()), // so stop() takes its reap / fast-fail branch, not "stopped cleanly". let start = std::time::Instant::now(); - let waited = rx.recv_timeout(HOST_EXIT_TIMEOUT); + let waited = rx.recv_timeout(HOST_STOPPED_EVENT_TIMEOUT); assert!(matches!( waited, Err(std::sync::mpsc::RecvTimeoutError::Disconnected) @@ -1293,7 +1290,7 @@ mod tests { assert!(!matches!(waited, Ok(()))); assert!( start.elapsed() < Duration::from_secs(1), - "a crashed host must not idle the full HOST_EXIT_TIMEOUT" + "a crashed host must not idle the full HOST_STOPPED_EVENT_TIMEOUT" ); } @@ -1311,7 +1308,7 @@ mod tests { if let Some(tx) = slot.lock().unwrap().take() { let _ = tx.send(()); } - assert!(matches!(rx.recv_timeout(HOST_EXIT_TIMEOUT), Ok(()))); + assert!(matches!(rx.recv_timeout(HOST_STOPPED_EVENT_TIMEOUT), Ok(()))); // Post-stop EOF: slot already None → take() is a harmless no-op. assert!(slot.lock().unwrap().take().is_none()); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index e919b71..6882fd8 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -28,7 +28,10 @@ fn default_source_language() -> String { "auto".into() } fn default_stt_model() -> String { - "small".into() + // Single source of truth with the engine's download-failure fallback + // (`session.rs` uses `livecap_core::model::DEFAULT_MODEL`): the Settings + // default and the fallback must not diverge (#110). + livecap_core::model::DEFAULT_MODEL.into() } fn default_pool() -> f64 { 20.0 @@ -282,6 +285,18 @@ mod tests { std::fs::remove_dir_all(path.parent().unwrap()).ok(); } + #[test] + fn default_stt_model_is_a_curated_pick() { + // The Settings default now derives from livecap_core's DEFAULT_MODEL; if + // the crate ever changes it to a value outside the curated STT_MODELS + // list, sanitize() would silently clamp the default away — catch that here. + assert!( + STT_MODELS.contains(&livecap_core::model::DEFAULT_MODEL), + "livecap_core::model::DEFAULT_MODEL ({}) must be one of the curated STT_MODELS", + livecap_core::model::DEFAULT_MODEL + ); + } + #[test] fn defaults_match_the_product_contract() { let d = AppSettings::default(); diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index b7a9eea..b05f318 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -29,7 +29,7 @@ fn icon(live: bool) -> Image<'static> { pub fn create(app: &AppHandle, initial_mode: Mode, initial_pinned: bool) -> tauri::Result<()> { let caps = crate::CAPABILITIES; - let toggle = MenuItem::with_id(app, "toggle", "Show/Hide LiveCap", true, Some("Alt+Space"))?; + let toggle = MenuItem::with_id(app, "toggle", "Show/Hide LiveCap", true, Some(crate::TOGGLE_LABEL))?; let mode_items: Vec<(Mode, CheckMenuItem)> = Mode::ALL .iter() diff --git a/src-tauri/src/ui_state.rs b/src-tauri/src/ui_state.rs index 25aa29e..9499e3b 100644 --- a/src-tauri/src/ui_state.rs +++ b/src-tauri/src/ui_state.rs @@ -36,13 +36,6 @@ pub struct UiSnapshot { pub age_ms: Option, } -fn now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0) -} - /// Disk-mirror view of a beat (#147): the liveness/wedge detector only needs /// the counts and mode, never caption text. `latestSource`/`latestTranslation` /// AND `capsuleText` all carry caption content (the capsule line is the latest @@ -134,6 +127,6 @@ pub fn ui_beat(app: tauri::AppHandle, state: tauri::State<'_, UiState>, beat: Ui #[tauri::command] pub fn ui_snapshot(state: tauri::State<'_, UiState>) -> UiSnapshot { let beat = state.0.lock().expect("ui beat lock").clone(); - let age_ms = beat.as_ref().map(|b| now_ms().saturating_sub(b.ts)); + let age_ms = beat.as_ref().map(|b| crate::util::epoch_ms().saturating_sub(b.ts)); UiSnapshot { beat, age_ms } } diff --git a/src-tauri/src/util.rs b/src-tauri/src/util.rs new file mode 100644 index 0000000..b8d7958 --- /dev/null +++ b/src-tauri/src/util.rs @@ -0,0 +1,13 @@ +//! Small crate-internal helpers shared across modules. + +/// Milliseconds since the Unix epoch (wall clock), or 0 if the system clock is +/// before the epoch. The single source for the timestamps stamped on captions +/// (`session.rs`) and on UI heartbeats (`ui_state.rs`), which are compared +/// against each other during verification — keeping one helper stops the two +/// from drifting apart. +pub(crate) fn epoch_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} diff --git a/src/app-settings.ts b/src/app-settings.ts index 973acc6..c083c71 100644 --- a/src/app-settings.ts +++ b/src/app-settings.ts @@ -1,7 +1,10 @@ // Webview mirror of the Rust AppSettings (src-tauri/src/settings.rs) plus // small pure helpers shared by onboarding and the Settings sheet (#12). -export type EnginePref = "cli" | "local"; +// EnginePref is defined once, in the wire protocol; imported for local use and +// re-exported so the settings-sheet/onboarding consumers keep a single import site. +import type { EnginePref } from "./protocol"; +export type { EnginePref }; export type CaptionSize = "s" | "m" | "l"; /** What the one-line Capsule shows (#97). */ export type CapsuleContent = "caption" | "translation" | "both"; @@ -49,7 +52,8 @@ export function sanitizedSttModel(value: string | null | undefined): string { return STT_MODELS.some((m) => m.value === value) ? (value as string) : "small"; } -/** Pool presets (PROPOSAL §6); mirrors the engine's POOL_PRESETS. */ +/** Pool presets (PROPOSAL §6) — the single source for the plan dollar amounts + * (the engine takes a plain `poolUsd` number, so nothing mirrors these). */ export const POOL_PRESETS: { id: string; label: string; usd: number }[] = [ { id: "pro", label: "Pro · $20", usd: 20 }, { id: "max5x", label: "Max 5x · $100", usd: 100 }, diff --git a/src/clock.ts b/src/clock.ts new file mode 100644 index 0000000..82d7ce1 --- /dev/null +++ b/src/clock.ts @@ -0,0 +1,13 @@ +// Wall-clock HH:MM label for a caption timestamp. Dependency-free shared module +// (like protocol.ts) so the live feed (main.ts) and the host's archive/coaching +// timestamps (session.ts) format the SAME string — the (timestamp, occurrence) +// coaching-amend keys in coaching-keys.ts depend on this exact format, so the two +// sides must never drift. + +/** Local-time "HH:MM" for an epoch-ms timestamp, zero-padded. */ +export function clockLabel(epochMs: number): string { + const date = new Date(epochMs); + const hh = String(date.getHours()).padStart(2, "0"); + const mm = String(date.getMinutes()).padStart(2, "0"); + return `${hh}:${mm}`; +} diff --git a/src/dashboard.ts b/src/dashboard.ts index cb328bf..3d02611 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -26,9 +26,11 @@ import { parseSession, type ParsedSession } from "@livecap/archive/src/parse.ts" import type { SessionIndexEntry } from "@livecap/archive/src/dashboard.ts"; import type { CaptionEntry } from "@livecap/archive/src/types.ts"; -// The review tab's change-highlighting (#82), reused verbatim so persisted -// rewrites look the same here as they did when generated (#114). -import { renderBetter } from "./review"; +import { CLOSE_ICON } from "./icons"; +// The review tab's change-highlighting (#82) and shared board renderer (#179), +// reused verbatim so persisted rewrites and the Board look the same here as on +// the review surface (#114). +import { renderBetter, renderBoardInto } from "./review"; /** One saved session's FULL document as handed over by the Rust * `list_archived_sessions` / `read_archived_session` commands: a file name + @@ -74,8 +76,6 @@ export interface DashboardModel { sessions: IndexedSession[]; } -const CLOSE_ICON = - ''; const BACK_ICON = ''; @@ -677,29 +677,7 @@ export function buildDashboard(callbacks: DashboardCallbacks): DashboardSurface function renderBoard(session: ParsedSession): HTMLElement { const boardEl = document.createElement("div"); boardEl.className = "dash-board"; - const sections: [string, string[]][] = [ - ["Decisions", session.board.decisions], - ["Action items", session.board.actionItems], - ["Open questions", session.board.openQuestions], - ]; - for (const [label, items] of sections) { - if (items.length === 0) continue; - const rowEl = document.createElement("div"); - rowEl.className = "board-row"; - const head = document.createElement("span"); - head.className = "board-head"; - head.textContent = label; - rowEl.appendChild(head); - const ul = document.createElement("ul"); - for (const item of items) { - const li = document.createElement("li"); - li.textContent = item; - ul.appendChild(li); - } - rowEl.appendChild(ul); - boardEl.appendChild(rowEl); - } - if (boardEl.childElementCount === 0) boardEl.textContent = "—"; + renderBoardInto(boardEl, session.board); return boardEl; } diff --git a/src/defaults.ts b/src/defaults.ts new file mode 100644 index 0000000..9fff31c --- /dev/null +++ b/src/defaults.ts @@ -0,0 +1,11 @@ +// Shared billing-settings defaults. Imported by both the webview's first-run +// DEFAULT_SETTINGS (main.ts) and the host's start-config fallback path +// (host/start-config.ts) so the two can't drift: a product change to the default +// pool amount or reset day now lands in one place. Dependency-free so both +// tsconfigs (webview / host) resolve it. + +/** Default Agent-SDK monthly pool in USD (PROPOSAL §6; Pro preset). */ +export const DEFAULT_POOL_USD = 20; + +/** Default billing reset day of month (1–28). */ +export const DEFAULT_RESET_DAY = 1; diff --git a/src/feed-state.ts b/src/feed-state.ts index 122028e..669a22e 100644 --- a/src/feed-state.ts +++ b/src/feed-state.ts @@ -207,14 +207,6 @@ export class FeedState { ); } - /** Most recent finalized source lines, oldest first (reply-chip context). */ - recentSources(count: number): string[] { - return this.blocks - .filter((block) => block.id !== null) - .slice(-count) - .map((block) => block.source); - } - private newBlock(channel: Channel): CaptionBlock { this.keyCounter += 1; return { diff --git a/src/host/session.ts b/src/host/session.ts index faf6756..d6782be 100644 --- a/src/host/session.ts +++ b/src/host/session.ts @@ -37,6 +37,7 @@ import { } from "@livecap/archive"; import type { BoardData, CaptionEntry, MetricsData } from "@livecap/archive"; +import { clockLabel } from "../clock.ts"; import type { Channel, CoachingItemWire, GaugeWire, HostInbound, HostOutbound } from "../protocol.ts"; import { coachingAmendKeys } from "./coaching-keys.ts"; import { detectClaudeCli } from "./detect-cli.ts"; @@ -59,8 +60,10 @@ const CLI_CONTEXT_PAIRS = 1; * window, far below the ~2h "prompt too long" cliff, with ample headroom. */ const CLI_ROLLOVER_CACHE_READ_TOKENS = 120_000; const SUMMARY_TICK_MS = 5_000; -/** Recent transcript lines fed as context to a targeted analysis (#80). */ -const ANALYZE_CONTEXT_LINES = 10; +/** Recent transcript lines fed as context to the on-demand extras (reply + * suggestions #79 and targeted analysis #80) — one window so the two can't + * drift. */ +const EXTRAS_CONTEXT_LINES = 10; const WATCHDOG_TICK_MS = 15_000; const DRAIN_TIMEOUT_MS = 20_000; /** Liveness heartbeat for the in-progress recording (#69): the writer touches @@ -88,13 +91,6 @@ interface CaptionMeta { durationMs: number; } -function clockLabel(epochMs: number): string { - const date = new Date(epochMs); - const hh = String(date.getHours()).padStart(2, "0"); - const mm = String(date.getMinutes()).padStart(2, "0"); - return `${hh}:${mm}`; -} - function fileNamePrefix(epochMs: number): string { const date = new Date(epochMs); const y = date.getFullYear(); @@ -631,7 +627,7 @@ export class HostSession { private async onReply(id: number, intent: ReplyIntent): Promise { if (!this.extras) return; try { - const result = await this.extras.suggestReply(intent, this.transcriptLines.slice(-10)); + const result = await this.extras.suggestReply(intent, this.transcriptLines.slice(-EXTRAS_CONTEXT_LINES)); this.emit({ type: "replyResult", id, intent, text: result.text }); this.emitGauge(); // extras spend changed → refresh the gauge } catch (error) { @@ -654,7 +650,7 @@ export class HostSession { try { const result = await this.extras.analyzeAndRespond( meta.text, - this.transcriptLines.slice(-ANALYZE_CONTEXT_LINES), + this.transcriptLines.slice(-EXTRAS_CONTEXT_LINES), ); this.emit({ type: "analysis", cardId, analysis: result.analysis, reply: result.reply }); this.emitGauge(); // extras spend changed → refresh the gauge diff --git a/src/host/start-config.ts b/src/host/start-config.ts index 019484d..3a2ec82 100644 --- a/src/host/start-config.ts +++ b/src/host/start-config.ts @@ -6,6 +6,7 @@ import { DEFAULT_EXTRAS_BUDGET_USD } from "@livecap/engine"; +import { DEFAULT_POOL_USD, DEFAULT_RESET_DAY } from "../defaults.ts"; import { languageByCode, SOURCE_AUTO_CODE } from "../languages.ts"; import type { EnginePref, HostInbound } from "../protocol.ts"; @@ -15,7 +16,9 @@ type StartMessage = Extract; * language (§8.5), and the archive header's source label reflects it. #94 lets * the user pick that spoken language; when they leave it on "auto" (per-utterance * whisper detection, the default) there is no single spoken language, so reply/ - * quick-translate falls back to English — the pre-#94 behavior. */ + * quick-translate falls back to English — the pre-#94 behavior. Cross-ref: the + * webview's coaching TTS voice (`MEETING_VOICE_LANG` in src/main.ts) mirrors this + * as a BCP-47 tag; the two are conceptually coupled, so keep them in step. */ const AUTO_MEETING_LANGUAGE = "English"; /** Archive header source label (§8.9) for auto-detect (#94/#175): a neutral * "AUTO" — detection is per-utterance, so no one language is the source. A @@ -67,7 +70,7 @@ export function resolveStartConfig(message: StartMessage): ResolvedStartConfig { sourceLangCode: source ? source.archiveLabel : AUTO_SOURCE_LABEL, targetLangCode: language.archiveLabel, enginePref: message.enginePref === "local" ? "local" : "cli", - poolUsd: Number.isFinite(message.poolUsd) && message.poolUsd > 0 ? message.poolUsd : 20, + poolUsd: Number.isFinite(message.poolUsd) && message.poolUsd > 0 ? message.poolUsd : DEFAULT_POOL_USD, resetDay: clampResetDay(message.resetDay), autoSwitch: message.autoSwitch !== false, archiveAutoSave: message.archiveAutoSave !== false, @@ -89,6 +92,6 @@ function resolveChannelsNote(captureSystem: boolean, captureMic: boolean): strin } function clampResetDay(day: number): number { - if (!Number.isFinite(day)) return 1; + if (!Number.isFinite(day)) return DEFAULT_RESET_DAY; return Math.min(28, Math.max(1, Math.floor(day))); } diff --git a/src/icons.ts b/src/icons.ts new file mode 100644 index 0000000..1d773af --- /dev/null +++ b/src/icons.ts @@ -0,0 +1,8 @@ +// Shared inline-SVG glyph strings. Extracted so the same ✕ renders identically +// across the inline feed cards (main.ts), the review surface (review.ts), and the +// dashboard (dashboard.ts) — adjusting the stroke/path in one place now updates +// all three instead of leaving two surfaces with a visually different close icon. + +/** Close/dismiss ✕ glyph. */ +export const CLOSE_ICON = + ''; diff --git a/src/main.ts b/src/main.ts index 3e61f7e..f7ea249 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,6 +15,7 @@ import { type AppSettings, } from "./app-settings"; import { bootstrap } from "./bootstrap"; +import { clockLabel } from "./clock"; import { buildDashboard, loadArchivedSession, @@ -22,6 +23,8 @@ import { loadSessionIndex, type DashboardSurface, } from "./dashboard"; +import { DEFAULT_POOL_USD, DEFAULT_RESET_DAY } from "./defaults"; +import { CLOSE_ICON } from "./icons"; import { LANGUAGES, SOURCE_AUTO_CODE, SOURCE_LANGUAGES, languageByCode } from "./languages"; import { FeedState, type CaptionBlock } from "./feed-state"; import { FeedCoalescer, applyShimmerCap } from "./feed-coalescer"; @@ -53,9 +56,13 @@ interface ChromePayload { const CHROME_HIDE_MS = 3000; const TOAST_MS = 4000; -/** TTS voice language for the coaching playback (#82). The meeting language is - * English (src/host/start-config.ts MEETING_LANGUAGE); the rewrite is in that - * language, so the voice is English. */ +/** TTS voice language for the coaching playback (#82). Conceptually coupled to + * the meeting language: the coaching rewrite is spoken in whatever language + * reply/quick-translate uses, which for the auto-detect default is English + * (`AUTO_MEETING_LANGUAGE` in src/host/start-config.ts). This BCP-47 tag mirrors + * that as a webview-side constant — the two are linked by this cross-reference, + * not a shared source; if the meeting language ever stops being English, update + * both. */ const MEETING_VOICE_LANG = "en-US"; const ICONS = { @@ -70,8 +77,7 @@ const ICONS = { '', pin: '', mode: '', - close: - '', + close: CLOSE_ICON, }; const state: ShellState = { mode: "panel", clickThrough: false, pinned: true, live: false }; @@ -201,8 +207,8 @@ let appSettings: AppSettings = { targetLanguage: "ko", sourceLanguage: "auto", // #94: per-utterance auto-detect until the user picks sttModel: "small", // #110: default whisper model until the user picks - poolUsd: 20, - resetDay: 1, + poolUsd: DEFAULT_POOL_USD, + resetDay: DEFAULT_RESET_DAY, autoSwitch: true, captionSize: "m", capsuleContent: "translation", @@ -458,11 +464,6 @@ function updateBlockEl(block: CaptionBlock): void { } } -function clockLabel(epochMs: number): string { - const date = new Date(epochMs); - return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; -} - /* ---- scroll: history with a "↓ live" snap-back chip ---- */ let atBottom = true; @@ -1251,7 +1252,8 @@ let bootCapabilities: Capabilities | null = null; let bootSettings: AppSettings | null = null; let onboardingDecided = false; -// First run (§8.6): the three onboarding cards, then straight into a session. +// First run (§8.6): the two onboarding cards, then the idle Start screen (#1 — +// no auto-start; the user presses Start when ready). // Needs BOTH capabilities and settings, so it runs once both have arrived. function maybeStartOnboarding(): void { if (onboardingDecided || bootCapabilities === null || bootSettings === null) return; diff --git a/src/review.ts b/src/review.ts index 8740ab0..7ff3ca6 100644 --- a/src/review.ts +++ b/src/review.ts @@ -14,13 +14,9 @@ // exposes a small imperative API the orchestrator (src/main.ts) drives. The host // round-trips stay in main.ts; this module only renders and raises callbacks. +import { CLOSE_ICON } from "./icons"; import type { BoardWire, CoachingItemWire } from "./protocol"; -/** Shared close glyph (matches main.ts ICONS.close) so review cards get the same - * clear ✕ as the inline feed cards (#1/#3). */ -const CLOSE_ICON = - ''; - /** One of the user's own utterances, as listed in the coaching tab. */ export interface MicUtterance { /** Caption id (the host resolves its text by this). */ @@ -72,8 +68,6 @@ export interface ReviewSurface { hide: () => void; /** Whether the surface is currently shown. */ isOpen: () => boolean; - /** Register a coaching card so a later "coaching" result can fill it. */ - registerCoachingCard: (id: number) => CoachingCard; /** Look up a coaching card by id (for routing results / failures). */ coachingCard: (id: number) => CoachingCard | undefined; } @@ -118,6 +112,41 @@ export function renderBetter(parent: HTMLElement, better: string, changes: { fro } } +/** + * Populate `target` with the meeting board: one `board-row` (label + `
    `) per + * non-empty section — Decisions / Action items / Open questions — or a "—" + * dash when every section is empty. Clears `target` first, so it is safe + * to re-run. Exported and shared by the review surface and the dashboard so the + * two Board renderings can't drift; each caller owns its own container element + * (the review reuses its board node; the dashboard wraps this in `.dash-board`). + */ +export function renderBoardInto(target: HTMLElement, board: BoardWire): void { + target.replaceChildren(); + const sections: [string, string[]][] = [ + ["Decisions", board.decisions], + ["Action items", board.actionItems], + ["Open questions", board.openQuestions], + ]; + for (const [label, items] of sections) { + if (items.length === 0) continue; + const row = document.createElement("div"); + row.className = "board-row"; + const head = document.createElement("span"); + head.className = "board-head"; + head.textContent = label; + row.appendChild(head); + const ul = document.createElement("ul"); + for (const item of items) { + const li = document.createElement("li"); + li.textContent = item; + ul.appendChild(li); + } + row.appendChild(ul); + target.appendChild(row); + } + if (target.childElementCount === 0) target.textContent = "—"; +} + function pct(fraction: number): number { return Math.round(fraction * 100); } @@ -310,35 +339,6 @@ export function buildReview(callbacks: ReviewCallbacks): ReviewSurface { } } - function renderBoard(board: BoardWire): void { - boardEl.replaceChildren(); - const sections: [string, string[]][] = [ - ["Decisions", board.decisions], - ["Action items", board.actionItems], - ["Open questions", board.openQuestions], - ]; - for (const [label, items] of sections) { - if (items.length === 0) continue; - const row = document.createElement("div"); - row.className = "board-row"; - const head = document.createElement("span"); - head.className = "board-head"; - head.textContent = label; - row.appendChild(head); - const ul = document.createElement("ul"); - for (const item of items) { - const li = document.createElement("li"); - li.textContent = item; - ul.appendChild(li); - } - row.appendChild(ul); - boardEl.appendChild(row); - } - if (boardEl.childElementCount === 0) { - boardEl.textContent = "—"; - } - } - return { el: root, isOpen: () => open, @@ -361,7 +361,7 @@ export function buildReview(callbacks: ReviewCallbacks): ReviewSurface { li.textContent = "No summary was generated."; summaryEl.appendChild(li); } - renderBoard(data.board); + renderBoardInto(boardEl, data.board); renderUtteranceList(); coachCardsEl.replaceChildren(); openBtn.disabled = data.archivePath === null; @@ -372,7 +372,6 @@ export function buildReview(callbacks: ReviewCallbacks): ReviewSurface { open = false; root.classList.remove("open"); }, - registerCoachingCard, coachingCard: (id) => coachingCards.get(id), }; } diff --git a/src/styles.css b/src/styles.css index c25b634..080332a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -923,17 +923,6 @@ body[data-capsize="l"] #capsule-view .txt { font-size: 15.5px; } gap: 12px; margin-top: 8px; } -.ob-select { - width: 100%; - margin: 2px 0 4px; - padding: 8px 10px; - border: none; - border-radius: 8px; - background: var(--surface-3); - color: var(--text-original); - font-size: 13.5px; - font-family: var(--font); -} .ob-actions { display: flex; flex-direction: column;