From f02a7bb5c6f35b955803abb01d72b449018c31b5 Mon Sep 17 00:00:00 2001 From: Kelvin Poon <35914355+kwp-lab@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:07:54 +0800 Subject: [PATCH] feat: redesign recording detail workspace --- CHANGELOG.md | 20 ++ docs/ai-documents.md | 4 +- docs/architecture.md | 53 +++++ docs/design-system.md | 44 ++++ docs/testing.md | 48 +++- src-tauri/src/controller.rs | 153 ++++++++++-- src/App.test.tsx | 22 ++ src/App.tsx | 17 +- src/assets/icons/chevron-right.svg | 15 ++ src/components/AiDocumentReader.test.tsx | 21 ++ src/components/AiDocumentReader.tsx | 38 +++ src/components/AiDocumentToolbar.tsx | 69 ++++++ src/components/AiDocumentsPanel.test.tsx | 115 ++++++++- src/components/AiDocumentsPanel.tsx | 221 ++++-------------- .../AiGenerationDetailsDrawer.test.tsx | 46 ++++ src/components/AiGenerationDetailsDrawer.tsx | 32 +++ src/components/DetailActionPopover.tsx | 34 +++ src/components/DetailSelect.tsx | 6 + src/components/JsonTreeView.test.tsx | 21 ++ src/components/JsonTreeView.tsx | 2 + src/components/RecordingsWorkspace.test.tsx | 69 +++++- src/components/RecordingsWorkspace.tsx | 154 +++++++----- src/components/useBackdropDismiss.ts | 28 +++ src/main.tsx | 1 + src/recording-detail.css | 73 ++++++ src/styles.css | 11 +- src/test/setup.ts | 4 + src/vite-env.d.ts | 1 + 28 files changed, 1039 insertions(+), 283 deletions(-) create mode 100644 src/assets/icons/chevron-right.svg create mode 100644 src/components/AiDocumentReader.test.tsx create mode 100644 src/components/AiDocumentReader.tsx create mode 100644 src/components/AiDocumentToolbar.tsx create mode 100644 src/components/AiGenerationDetailsDrawer.test.tsx create mode 100644 src/components/AiGenerationDetailsDrawer.tsx create mode 100644 src/components/DetailActionPopover.tsx create mode 100644 src/components/DetailSelect.tsx create mode 100644 src/components/JsonTreeView.test.tsx create mode 100644 src/components/useBackdropDismiss.ts create mode 100644 src/recording-detail.css create mode 100644 src/vite-env.d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f8b6de..03d028f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +### Changed + +- Reorganized recording details into a full-height reading workspace with a + compact contextual toolbar, document/version selectors, bottom audio player, + a collapsible recording list controlled from the left of the detail title, + and an on-demand generation-details drawer. Existing + generation, regeneration, AI revision and transcription dialogs retain their + fields and behavior. + +### Fixed + +- Unified the sidebar and tray with Nota's packaged application icon. Idle + shows the app mark; recording adds a lower-right red badge, while pause and + capture warnings retain distinct status badges and existing tray controls. +- Unified AI generation close and JSON disclosure icons with Lucide. Generation + dialogs and the details drawer now support backdrop dismissal without closing + on text-selection drags; generation submission consistently blocks dismissal. +- Kept AI generation form scrolling inside the rounded dialog shell, with the + header and actions visible and no scrollbar covering the corners. + ## [0.9.0] - 2026-09-02 ### Changed diff --git a/docs/ai-documents.md b/docs/ai-documents.md index cd707e8..f88b2d3 100644 --- a/docs/ai-documents.md +++ b/docs/ai-documents.md @@ -110,8 +110,8 @@ contain credentials, prompt bodies, transcript content, or model output. Provider-controlled JSON verbatim but Nota never adds runtime credentials to it. Both snapshots are loaded by React only when the user opens **Generation details**. -- The document preview has top-level **Document** and **Generation details** - tabs. Generation details shows normalized usage plus separate request and +- The document preview keeps the body visible and opens **Generation details** + in a right-hand drawer. Generation details shows normalized usage plus separate request and response JSON trees. Historical versions created before snapshot persistence explicitly report that their original JSON is unavailable. diff --git a/docs/architecture.md b/docs/architecture.md index 62e5303..34bb3a0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,6 +76,39 @@ flowchart LR | Configured ASR service | Model inference and server-side processing | Local recording ownership | | Configured LLM provider | Explicitly requested text generation | Local Markdown, SQLite, or recording ownership | +### Recording Detail Presentation + +`RecordingsWorkspace` owns selected-record presentation, native audio playback, +transcription actions, focus mode, and the existing speaker/transcription +dialogs. `AiDocumentsPanel` remains the sole owner of AI workspace reads, +generation drafts, request estimates, version events, file actions, and all +three generation-dialog modes. Its toolbar, Markdown reader, and generation +details drawer are presentation components, not additional IPC owners. + +The AI panel mounts on first visit and remains mounted while its tab is hidden. +The audio element, record list, and transcript scroller also retain their DOM +identity across tab/focus changes. Markdown reading positions are keyed by +version in transient component state; they are not stored in SQLite or settings. +Leaving the workspace clears this presentation state. Recording changes retain +the existing AI reset behavior and asynchronous identity guards. + +```mermaid +flowchart LR + A[App: workspace shell] --> B[RecordingsWorkspace: audio and detail tabs] + B --> C[AiDocumentsPanel: selected document and version] + C --> D[Toolbar: user intent callbacks] + C --> E[Reader: Markdown and transient scroll positions] + D --> F[Open generation details drawer] + F --> G[Read persisted details for selected version] + G --> H[Apply only if request and version are current] + D --> I[Existing create / regenerate / revise dialog] + I --> J[Existing preview, validation and explicit generation IPC] +``` + +Opening tabs, reading documents, and inspecting persisted details do not invoke +LLM generation. No new backend commands, schemas, credentials, or network paths +are introduced by the workspace layout. + ### Settings Ownership and Persistence `App.tsx` owns the canonical settings and Provider summaries shared with the @@ -253,6 +286,26 @@ right corner. Content beyond the upper bound remains scrollable, so DPI, accessibility text scaling, long target titles, and error details cannot hide the decision buttons. +## Application Identity and Tray Rendering + +The frontend sidebar imports the packaged app-icon PNG as a build asset, and +Rust embeds the 32 px variant with `tauri::include_image!`. Neither path needs +runtime filesystem access, IPC image payloads, or an extra image dependency. +The tray compositor always starts from the original app mark, adding only a +lower-right anti-aliased badge when the state needs one. Returning to idle +restores the original pixels; badges never accumulate across transitions. +Recording preparation/finalization, pause and capture-warning distinctions +remain visible. Existing state deduplication, main-thread tray updates, menus, +tooltips and recording control ownership are unchanged. + +```mermaid +flowchart LR + S["Recording state + pending capture decision"] --> B["Choose no badge / recording / paused / warning"] + I["Packaged 32 px application icon"] --> C["Compose corner badge from original pixels"] + B --> C + C --> T["Update tray icon on main thread"] +``` + ## Live Microphone Switching An active recording may replace or disable only its microphone source. The diff --git a/docs/design-system.md b/docs/design-system.md index 2d7dfd7..1a96cd1 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -126,6 +126,17 @@ Lucide remains Nota's product icon set. Use 14 px icons in compact controls, buttons need an accessible name and an `AppTooltip`; visible text buttons do not need a duplicate tooltip. +Application identity is not a Lucide action icon. The sidebar imports the +packaged `src-tauri/icons/128x128.png` directly, with no replacement glyph, +extra background or clipped silhouette. Installer, window and tray icons use +the same packaged microphone artwork; the tray embeds `icons/32x32.png`. +Idle/completed/recovering tray states show the unmodified app mark. Recording, +preparing and finalizing add a lower-right recording-red badge; paused adds +a paused-amber badge. Interrupted/error states and pending capture decisions +show a warning-amber badge, with pending decisions taking priority. Badges have +an anti-aliased light separator and use native equivalents of the existing +status color tokens. Existing tooltip/menu text supplies the precise state. + | Control | Standard | |---|---| | Compact icon button | 28 px minimum visual target, 14 px icon | @@ -186,6 +197,39 @@ are removed so the settings hierarchy owns the available height. Settings uses existing semantic surface, border, status, spacing, and focus roles. The workspace geometry does not introduce a new visual token role. +### Recording Detail Workspace + +The recording library uses the full application height with the main sidebar +retained and the redundant global header/footer removed. The record list is a +280 px track (240 px at widths up to 1100 px). The icon button before the detail +title toggles only that track, with matching tooltip and accessible labels +“折叠录音列表” / “展开录音列表”. It exposes the list's expanded state and remains +available when the list is hidden. +The compact title, detail tabs, contextual toolbar, reading region, and native +audio player follow DOM order. The list and reading region scroll independently; +the outer detail pane must not scroll. Playback remains mounted across tabs. + +AI documents use shared select styling for document/version selection instead +of a permanent nested document sidebar. AI revision and copying stay directly +available; low-frequency file and generation actions use a keyboard-accessible +disclosure. Generation details open in a right-hand native dialog drawer with +focus containment and restoration. Generation configuration dialogs retain +their original fields and controls. Their rounded outer shell clips overflow; +only the active form/request panel scrolls, leaving the title, tabs and footer +visible. Scrollbars stay inside the shell padding, clear of its rounded corners. +Close controls use Lucide `X`; JSON tree disclosure slots use the bundled Lucide +chevron SVG as a semantic-color CSS mask without replacing tree keyboard behavior. +Both generation overlays dismiss on primary clicks that start and end on their +backdrop. Inside clicks and text-selection drags must not dismiss them; generation +submission disables close, cancel and backdrop dismissal together. + +At 1280×800 and 980×640, normal completed-document content targets at least +70% and 60% of workspace height respectively. Necessary warnings may reduce +that area. Controls must not wrap into multiple toolbar rows, and long titles, +models, and paths must not cause page-level horizontal overflow. Use existing +semantic tokens; do not shrink body type to achieve density. The playback +footer retains the local-recording/network-use reminder. + ## Change and Review Checklist For every visual change: diff --git a/docs/testing.md b/docs/testing.md index e9ae65b..bb4a654 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -122,8 +122,8 @@ Automated client coverage must include: - recording-list context menus exposing **Open containing folder** only for list items while preserving the three-action detail overflow menu; -- one sticky recording-detail control region containing the audio player and - transcription actions, with the transcript body outside that region; +- compact recording-detail controls above an independently scrolling transcript, + with a stable native audio player below the body; - transcript segment timestamps rendered as zero-padded `HH:MM:SS`, including meetings longer than one hour; - valid batch protocol v1 capability parsing; @@ -267,7 +267,7 @@ Automated coverage for AI documents must include: - credential-free request JSON matching the submitted body, successful raw response JSON and normalized usage persistence, nullable-column migration for historical databases, and on-demand IPC reads; -- keyboard-operable Document/Generation details and Request/Response tabs, +- a keyboard-operable Generation details drawer and Request/Response tabs, collapsible syntax-highlighted JSON, copy actions, long-value wrapping, and explicit unavailable states for historical versions; - recording deletion preserving Markdown by default and deleting exact linked @@ -374,6 +374,48 @@ Real-model and hardware acceptance results should record software versions, model id, device type, audio duration, and pass/fail observations. They must not include the recording or transcript in ordinary logs or committed artifacts. +## Recording Detail Layout Acceptance + +### Application Icon Acceptance + +- Confirm the sidebar microphone artwork matches the installed Start menu and + window icon. It must not render a replacement Lucide action glyph. +- Check the Windows tray on light/dark taskbars at 100%, 125%, 150% and 200% + scaling: idle uses the app mark only; preparing/recording/finalizing add a red + lower-right badge. Pause uses amber; capture reminders/errors use warning + amber. Check tooltip/menu distinctions and pending-decision priority. +- Finish recording and confirm the unmodified idle icon returns. Tray show, + pause/resume, stop/save and capture-prompt re-entry must still work. +- Pixel tests verify the badge leaves the rest of the icon unchanged and that + idle states exactly match the embedded asset. Browser/pixel tests do not + replace this Windows system-tray check. + +### Layout and Dialogs + +- Verify the full-height recording workspace at 1280×800, 980×640 and Windows + 125% scaling: persistent app navigation, independent list/body scrolling, + bottom playback controls, and no horizontal page overflow. +- Check focus mode, keyboard tab switching, overflow actions, drawer Escape and + focus restoration. Tab/focus changes must not replace audio or reset playback + and the current record's reading positions. Recording-active warnings and the + return-to-recorder action remain available. +- Compare create, regenerate and revise dialogs with the pre-layout baseline: + all fields, settings/request tabs, Provider/model linkage, token estimates, + budget errors and submit/cancel states must remain unchanged. Assert complete + generation payloads, including the revise source-version ID. +- Regress generation progress/cancel, missing/externally modified Markdown, + file relinking, stale responses, and on-demand request/response inspection. + Retain all existing playback, speaker, export and transcription tests. +- Verify generation dialogs and the details drawer close on backdrop clicks, + but not inside clicks, cancelled pointers, secondary buttons, or text-selection + drags ending outside. Submission blocks close/cancel/backdrop dismissal. Check + drawer focus restoration and JSON keyboard expansion after the Lucide icon + adaptation. At both viewport sizes, scroll the complete form and confirm + header/footer remain visible and scrollbars never cover the shell's corners. +- Visual fixtures must be synthetic and must not call real model services. + Browser viewport checks do not replace real Windows/WebView2 scaling and + audio-device acceptance. + ## Documentation Verification Documentation-only changes do not require rebuilding the application unless diff --git a/src-tauri/src/controller.rs b/src-tauri/src/controller.rs index 9be0608..952b2c6 100644 --- a/src-tauri/src/controller.rs +++ b/src-tauri/src/controller.rs @@ -2927,30 +2927,59 @@ fn tray_controls(state: RecordingState) -> TrayControls { } fn status_icon(state: RecordingState, capture_prompt_pending: bool) -> Image<'static> { - let color = if capture_prompt_pending { - [218, 145, 45, 255] - } else { - match state { - RecordingState::Recording | RecordingState::Preparing | RecordingState::Finalizing => { - [202, 69, 69, 255] - } - RecordingState::Paused => [198, 145, 62, 255], - RecordingState::Interrupted | RecordingState::Error => [164, 61, 61, 255], - _ => [58, 128, 116, 255], - } + // Compile-time decoding reuses the installer's icon family without a runtime + // image decoder or filesystem access. Keep the full-resolution app mark intact. + let base = tauri::include_image!("icons/32x32.png"); + let Some(color) = tray_badge_color(state, capture_prompt_pending) else { + return base; }; - let mut pixels = vec![0u8; 16 * 16 * 4]; - for y in 1..15 { - for x in 1..15 { - let dx = x as i32 - 7; - let dy = y as i32 - 7; - if dx * dx + dy * dy <= 42 { - let offset = (y * 16 + x) * 4; - pixels[offset..offset + 4].copy_from_slice(&color); + let (width, height) = (base.width(), base.height()); + let mut pixels = base.rgba().to_vec(); + let radius = width.min(height) as f32 * 0.1875; + let center_x = width as f32 - radius - 1.0; + let center_y = height as f32 - radius - 1.0; + // A light separator keeps the small badge readable over the app mark and + // both taskbar themes. Source-over coverage anti-aliases the circle edges. + for (radius, color) in [(radius, [255, 255, 255]), (radius * 0.75, color)] { + for y in 0..height { + for x in 0..width { + let distance = ((x as f32 + 0.5 - center_x).powi(2) + + (y as f32 + 0.5 - center_y).powi(2)) + .sqrt(); + let coverage = (radius + 0.5 - distance).clamp(0.0, 1.0); + if coverage == 0.0 { + continue; + } + let offset = ((y * width + x) * 4) as usize; + let base_alpha = pixels[offset + 3] as f32 / 255.0; + let alpha = coverage + base_alpha * (1.0 - coverage); + for channel in 0..3 { + pixels[offset + channel] = ((color[channel] as f32 * coverage + + pixels[offset + channel] as f32 * base_alpha * (1.0 - coverage)) + / alpha) + .round() as u8; + } + pixels[offset + 3] = (alpha * 255.0).round() as u8; } } } - Image::new_owned(pixels, 16, 16) + Image::new_owned(pixels, width, height) +} + +fn tray_badge_color(state: RecordingState, capture_prompt_pending: bool) -> Option<[u8; 3]> { + // Native counterparts of --color-status-{recording,paused,warning-accent} + // in design-tokens.css. Pending capture decisions retain highest priority. + if capture_prompt_pending { + return Some([217, 154, 66]); + } + match state { + RecordingState::Recording | RecordingState::Preparing | RecordingState::Finalizing => { + Some([200, 79, 69]) + } + RecordingState::Paused => Some([189, 139, 53]), + RecordingState::Interrupted | RecordingState::Error => Some([217, 154, 66]), + RecordingState::Idle | RecordingState::Completed | RecordingState::Recovering => None, + } } pub fn run_app() { @@ -3316,6 +3345,90 @@ mod transcript_export_tests { mod tray_tests { use super::*; + #[test] + fn idle_tray_uses_unmodified_packaged_app_icon() { + let base = tauri::include_image!("icons/32x32.png"); + for state in [ + RecordingState::Idle, + RecordingState::Completed, + RecordingState::Recovering, + ] { + let icon = status_icon(state, false); + assert_eq!((icon.width(), icon.height()), (32, 32)); + assert_eq!(icon.rgba(), base.rgba()); + } + } + + #[test] + fn recording_badge_changes_only_the_bottom_right_corner() { + let base = tauri::include_image!("icons/32x32.png"); + let recording = status_icon(RecordingState::Recording, false); + let mut changed = 0; + for (index, (before, after)) in base + .rgba() + .chunks_exact(4) + .zip(recording.rgba().chunks_exact(4)) + .enumerate() + { + if before != after { + let (x, y) = (index % 32, index / 32); + assert!(x >= 18 && y >= 18, "badge must not replace the app mark"); + changed += 1; + } + } + assert!(changed > 0 && changed < 32 * 32 / 4); + let center = (25 * 32 + 25) * 4; + assert_eq!(&recording.rgba()[center..center + 4], &[200, 79, 69, 255]); + assert!( + recording + .rgba() + .chunks_exact(4) + .any(|pixel| pixel == [255, 255, 255, 255]) + ); + assert!( + recording + .rgba() + .chunks_exact(4) + .any(|pixel| pixel[3] > 0 && pixel[3] < 255) + ); + // Every new state is composed from the base, never from a previous badge. + assert_eq!(status_icon(RecordingState::Idle, false).rgba(), base.rgba()); + } + + #[test] + fn tray_badges_preserve_busy_pause_and_capture_warning_states() { + for state in [ + RecordingState::Preparing, + RecordingState::Recording, + RecordingState::Finalizing, + ] { + assert_eq!(tray_badge_color(state, false), Some([200, 79, 69])); + } + assert_eq!( + tray_badge_color(RecordingState::Paused, false), + Some([189, 139, 53]) + ); + for state in [RecordingState::Interrupted, RecordingState::Error] { + assert_eq!(tray_badge_color(state, false), Some([217, 154, 66])); + } + for state in [ + RecordingState::Idle, + RecordingState::Preparing, + RecordingState::Recording, + RecordingState::Paused, + RecordingState::Finalizing, + RecordingState::Completed, + RecordingState::Interrupted, + RecordingState::Error, + RecordingState::Recovering, + ] { + assert_eq!(tray_badge_color(state, true), Some([217, 154, 66])); + let icon = status_icon(state, true); + let center = (25 * 32 + 25) * 4; + assert_eq!(&icon.rgba()[center..center + 4], &[217, 154, 66, 255]); + } + } + #[test] fn capture_prompt_height_is_bounded_and_rejects_invalid_values() { assert_eq!( diff --git a/src/App.test.tsx b/src/App.test.tsx index a60bd01..48d7207 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,6 +1,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import App from "./App"; +import appIconUrl from "../src-tauri/icons/128x128.png"; import { api } from "./api"; import type { AudioImportBatchSnapshot, @@ -292,6 +293,27 @@ const snapshot = (state: RecordingSnapshot["state"]): RecordingSnapshot => ({ }); describe("Nota UI states", () => { + it("uses the packaged application icon for the sidebar brand", async () => { + render(); + const logo = await screen.findByRole("img", { name: "Nota" }); + expect(logo).toHaveAttribute("src", appIconUrl); + expect(logo.parentElement).toHaveClass("sidebar-brand"); + expect(logo.parentElement?.querySelector("svg")).toBeNull(); + }); + + it("gives recordings the full-height shell and retains the active-recording return action", async () => { + testState.snapshot = snapshot("recording"); + render(); + fireEvent.click(await screen.findByRole("button", { name: "录音记录" })); + await screen.findByText("录音仍在进行"); + expect(document.querySelector(".app-content")).toHaveClass("recordings-mode"); + expect(document.querySelector(".topbar")).toBeNull(); + expect(document.querySelector(".app-footer")).toBeNull(); + expect(document.querySelector(".app-sidebar")).not.toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "返回录音控制" })); + expect(document.querySelector(".topbar")).not.toBeNull(); + expect(document.querySelector(".app-footer")).not.toBeNull(); + }); beforeEach(() => { testState.snapshot = snapshot("idle"); testState.devicesError = false; diff --git a/src/App.tsx b/src/App.tsx index 555b20c..b563dd7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { open, save } from "@tauri-apps/plugin-dialog"; +import appIconUrl from "../src-tauri/icons/128x128.png"; import { AlertTriangle, ChevronDown, @@ -882,7 +883,7 @@ export default function App() {
-
- {page !== "settings" &&
+
+ {page !== "settings" && page !== "recordings" &&
{page === "recorder" ? "录音" - : page === "recordings" - ? "录音记录" - : page === "hotwords" + : page === "hotwords" ? "热词库" : page === "voiceprints" ? "声纹管理" @@ -940,9 +939,7 @@ export default function App() { {page === "recorder" ? "捕捉会议声音与麦克风" - : page === "recordings" - ? "播放录音并查看文字转写" - : page === "hotwords" + : page === "hotwords" ? "为不同会议场景管理本地热词列表" : page === "voiceprints" ? "管理本地参会人姓名与声纹样本" @@ -1461,7 +1458,7 @@ export default function App() {
)} - {page !== "settings" &&
+ {page !== "settings" && page !== "recordings" &&
本地录音;仅在转写或手动生成 AI 文档时连接所选服务 Ctrl + Alt + F9 开始/暂停 · F10 停止
} diff --git a/src/assets/icons/chevron-right.svg b/src/assets/icons/chevron-right.svg new file mode 100644 index 0000000..1401db7 --- /dev/null +++ b/src/assets/icons/chevron-right.svg @@ -0,0 +1,15 @@ + + diff --git a/src/components/AiDocumentReader.test.tsx b/src/components/AiDocumentReader.test.tsx new file mode 100644 index 0000000..5b39340 --- /dev/null +++ b/src/components/AiDocumentReader.test.tsx @@ -0,0 +1,21 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; +import { AiDocumentReader } from "./AiDocumentReader"; + +afterEach(cleanup); + +it("restores reading positions per version after a loading transition", () => { + const { rerender } = render(); + const reader = screen.getByRole("region", { name: "AI 文档正文" }); + reader.scrollTop = 180; + fireEvent.scroll(reader); + rerender(); + fireEvent.scroll(reader, { target: { scrollTop: 0 } }); + rerender(); + expect(reader.scrollTop).toBe(0); + reader.scrollTop = 80; + fireEvent.scroll(reader); + rerender(); + rerender(); + expect(reader.scrollTop).toBe(180); +}); diff --git a/src/components/AiDocumentReader.tsx b/src/components/AiDocumentReader.tsx new file mode 100644 index 0000000..99b0d69 --- /dev/null +++ b/src/components/AiDocumentReader.tsx @@ -0,0 +1,38 @@ +import { useLayoutEffect, useRef } from "react"; +import { FileText, LoaderCircle } from "lucide-react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import type { AiDocumentContent } from "../types"; + +/** Reading positions are local UI state, never persisted with meeting content. */ +export function AiDocumentReader(props: { + versionId: string | null; + loading: boolean; + content: AiDocumentContent | null; +}) { + const ref = useRef(null); + const positions = useRef(new Map()); + useLayoutEffect(() => { + if (ref.current && props.versionId && !props.loading) { + ref.current.scrollTop = positions.current.get(props.versionId) ?? 0; + } + }, [props.versionId, props.loading]); + return ( +
{ + if (props.versionId && !props.loading) positions.current.set(props.versionId, event.currentTarget.scrollTop); + }}> + {props.loading ? ( +
读取 Markdown…
+ ) : props.content ? ( +
+ [图片未自动加载:{alt || "无标题"}], + }}>{props.content.markdown} +
+ ) : ( +

选择一个已完成版本查看内容。

+ )} +
+ ); +} diff --git a/src/components/AiDocumentToolbar.tsx b/src/components/AiDocumentToolbar.tsx new file mode 100644 index 0000000..39b0634 --- /dev/null +++ b/src/components/AiDocumentToolbar.tsx @@ -0,0 +1,69 @@ +import { Clipboard, FileText, FolderOpen, Info, Link2, Plus, RefreshCw, RotateCcw, WandSparkles } from "lucide-react"; +import { AppTooltip } from "./AppTooltip"; +import { DetailActionPopover } from "./DetailActionPopover"; +import { DetailSelect } from "./DetailSelect"; + +interface ToolbarModel { + documents: { id: string; title: string; templateName: string }[]; + versions: { id: string; label: string }[]; + documentId: string | null; + versionId: string | null; + canCreate: boolean; + createHint: string; + canRegenerate: boolean; + canRevise: boolean; + canRead: boolean; +} + +interface ToolbarActions { + selectDocument: (id: string) => void; + selectVersion: (id: string) => void; + create: () => void; + regenerate: () => void; + revise: () => void; + details: () => void; + refresh: () => void; + copy: () => void; + copyPath: () => void; + open: () => void; + reveal: () => void; +} + +export function AiDocumentToolbar({ model, actions }: { model: ToolbarModel; actions: ToolbarActions }) { + return ( +
+ actions.selectDocument(event.target.value)}> + {!model.documents.length && } + {model.documents.map((document) => )} + + + + + {model.documentId && <> + actions.selectVersion(event.target.value)}> + {model.versions.map((version) => )} + + + + + {model.canRead && } + + + + + + {model.canRead && <> + + + + + } + + } +
+ ); +} diff --git a/src/components/AiDocumentsPanel.test.tsx b/src/components/AiDocumentsPanel.test.tsx index 4054d54..475cb77 100644 --- a/src/components/AiDocumentsPanel.test.tsx +++ b/src/components/AiDocumentsPanel.test.tsx @@ -149,6 +149,107 @@ const version = ( }); describe("AI documents panel", () => { + it.each(["create", "regenerate", "revise"] as const)("preserves the full %s form and generation payload", async (mode) => { + const summary = template("summary", "meeting_summary"); + const current = version("v1", 1, "completed"); + testState.workspace!.profile.meetingContext = "existing meeting context"; + testState.workspace!.templates = [summary, template("extra", null)]; + testState.workspace!.documents = [{ + id: "document-1", recordingId: recording.id, templateId: summary.id, + title: "Existing summary", requirements: "existing document requirements", templateName: summary.name, + templateBuiltinKey: summary.builtinKey, latestVersion: current, createdAt: "", updatedAt: "", + }]; + testState.versions = [current]; + testState.contents.set("v1", { version: current, markdown: "# Existing summary" }); + vi.mocked(api.generateAiDocument).mockResolvedValue(current); + const onMessage = vi.fn(); + const secondProvider = { ...provider, id: "provider-2", name: "Second provider", modelId: "second-model" }; + render(); + await screen.findByRole("heading", { name: "Existing summary" }); + if (mode === "regenerate") fireEvent.click(screen.getByLabelText("更多文档操作")); + fireEvent.click(screen.getByRole("button", { name: mode === "create" ? "生成新文档" : mode === "revise" ? "AI修改" : "重新生成" })); + let dialog = screen.getByRole("dialog"); + const backdrop = dialog.parentElement!; + const close = within(dialog).getByRole("button", { name: "关闭生成窗口" }); + expect(close.querySelector("svg.lucide-x")).not.toBeNull(); + fireEvent.click(within(dialog).getByLabelText("文档标题")); + expect(dialog).toBeInTheDocument(); + fireEvent(within(dialog).getByLabelText("文档标题"), new MouseEvent("pointerdown", { bubbles: true, button: 0 })); + fireEvent.click(backdrop); + expect(dialog).toBeInTheDocument(); + fireEvent(backdrop, new MouseEvent("pointerdown", { bubbles: true, button: 0 })); + fireEvent.pointerCancel(backdrop); + fireEvent.click(backdrop); + expect(dialog).toBeInTheDocument(); + fireEvent(backdrop, new MouseEvent("pointerdown", { bubbles: true, button: 0 })); + fireEvent.click(backdrop); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(api.generateAiDocument).not.toHaveBeenCalled(); + if (mode === "regenerate") fireEvent.click(screen.getByLabelText("更多文档操作")); + fireEvent.click(screen.getByRole("button", { name: mode === "create" ? "生成新文档" : mode === "revise" ? "AI修改" : "重新生成" })); + dialog = screen.getByRole("dialog"); + expect(within(dialog).getByRole("tab", { name: "生成设置" })).toBeInTheDocument(); + expect(within(dialog).getByRole("tab", { name: "请求预览" })).toBeInTheDocument(); + expect(within(dialog).queryByLabelText("场景模板") !== null).toBe(mode === "create"); + expect(within(dialog).getByLabelText(/会议级上下文/)).toHaveValue("existing meeting context"); + expect(within(dialog).getByLabelText(/文档要求/)).toHaveValue(mode === "create" ? "" : "existing document requirements"); + if (mode === "revise") { + await waitFor(() => expect(within(dialog).getByRole("button", { name: "生成新版本" })).toBeEnabled()); + fireEvent.click(within(dialog).getByRole("button", { name: "生成新版本" })); + expect(api.generateAiDocument).not.toHaveBeenCalled(); + expect(onMessage).toHaveBeenCalledWith("error", "请填写希望如何修改这个版本"); + } + fireEvent.change(within(dialog).getByLabelText("文档标题"), { target: { value: "Updated title" } }); + fireEvent.change(within(dialog).getByLabelText(/会议级上下文/), { target: { value: "Updated context" } }); + fireEvent.change(within(dialog).getByLabelText(/文档要求/), { target: { value: "Updated requirements" } }); + fireEvent.change(within(dialog).getByLabelText(mode === "revise" ? "修改意见" : "本次附加要求"), { target: { value: "Updated request" } }); + fireEvent.change(within(dialog).getByLabelText("Provider"), { target: { value: secondProvider.id } }); + expect(within(dialog).getByLabelText("模型")).toHaveValue("second-model"); + fireEvent.change(within(dialog).getByLabelText("模型"), { target: { value: "manual-model" } }); + fireEvent.click(within(dialog).getByRole("tab", { name: "请求预览" })); + await waitFor(() => expect(within(dialog).getByRole("button", { name: "生成新版本" })).toBeEnabled()); + fireEvent.click(within(dialog).getByRole("button", { name: "生成新版本" })); + await waitFor(() => expect(api.generateAiDocument).toHaveBeenCalledWith(expect.objectContaining({ + mode, recordingId: recording.id, documentId: mode === "create" ? null : "document-1", + templateId: mode === "create" ? "extra" : null, + sourceVersionId: mode === "revise" ? "v1" : null, + title: "Updated title", meetingContext: "Updated context", documentRequirements: "Updated requirements", + runRequest: "Updated request", providerId: secondProvider.id, modelId: "manual-model", estimatedInputTokens: expect.any(Number), + }))); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + }); + + it("blocks every dismissal path while submitting generation", async () => { + testState.workspace!.templates = [template("summary", "meeting_summary")]; + let finish!: (value: AiDocumentVersion) => void; + vi.mocked(api.generateAiDocument).mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + render(); + fireEvent.click(await screen.findByRole("button", { name: "生成新文档" })); + await waitFor(() => expect(screen.getByRole("button", { name: "生成新版本" })).toBeEnabled()); + fireEvent.click(screen.getByRole("button", { name: "生成新版本" })); + const dialog = screen.getByRole("dialog"); + expect(screen.getByRole("button", { name: "关闭生成窗口" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "取消" })).toBeDisabled(); + fireEvent(dialog.parentElement!, new MouseEvent("pointerdown", { bubbles: true, button: 0 })); + fireEvent.click(dialog.parentElement!); + expect(dialog).toBeInTheDocument(); + await act(async () => finish(version("pending", 1, "queued"))); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + }); + + it("keeps token budget and preview failures blocking generation without losing the draft", async () => { + testState.workspace!.templates = [template("summary", "meeting_summary")]; + render(); + fireEvent.click(await screen.findByRole("button", { name: "生成新文档" })); + await waitFor(() => expect(document.querySelector(".ai-token-estimate.over")).not.toBeNull()); + expect(screen.getByRole("button", { name: "生成新版本" })).toBeDisabled(); + vi.mocked(api.previewAiGenerationRequest).mockRejectedValueOnce(new Error("Preview unavailable")); + fireEvent.change(screen.getByLabelText("文档标题"), { target: { value: "Keep this draft" } }); + await screen.findByText(/Preview unavailable/); + expect(screen.getByLabelText("文档标题")).toHaveValue("Keep this draft"); + expect(screen.getByRole("button", { name: "生成新版本" })).toBeDisabled(); + expect(api.generateAiDocument).not.toHaveBeenCalled(); + }); beforeEach(() => { testState.versions = []; testState.contents.clear(); @@ -214,11 +315,12 @@ describe("AI documents panel", () => { expect(await screen.findByRole("heading", { name: "Current summary" })).toBeInTheDocument(); expect(screen.getByText("[图片未自动加载:chart]")).toBeInTheDocument(); expect(document.querySelector("img")).toBeNull(); + fireEvent.click(screen.getByLabelText("更多文档操作")); expect(screen.getByRole("button", { name: "重新生成" })).toHaveClass("compact"); expect(screen.getByRole("button", { name: "重新生成" })).not.toHaveAttribute("title"); expect(screen.getByRole("button", { name: "AI修改" })).toHaveClass("compact"); expect(screen.getByRole("button", { name: "AI修改" })).not.toHaveAttribute("title"); - fireEvent.click(screen.getByRole("tab", { name: "生成详情" })); + fireEvent.click(screen.getByRole("button", { name: "生成详情" })); expect(await screen.findByText("该版本生成时尚未记录原始请求 JSON。")).toBeInTheDocument(); }); @@ -269,9 +371,11 @@ describe("AI documents panel", () => { />, ); - const documentTab = await screen.findByRole("tab", { name: "文档" }); - fireEvent.keyDown(documentTab.parentElement!, { key: "ArrowRight" }); - expect(screen.getByRole("tab", { name: "生成详情" })).toHaveAttribute("aria-selected", "true"); + const detailsButton = await screen.findByRole("button", { name: "生成详情" }); + expect(api.readAiGenerationDetails).not.toHaveBeenCalled(); + detailsButton.focus(); + fireEvent.click(detailsButton); + expect(screen.getByRole("dialog", { name: "生成详情" })).toBeInTheDocument(); expect(await screen.findByText("90 tokens")).toBeInTheDocument(); expect(screen.getByText("20 tokens")).toBeInTheDocument(); expect(screen.getByText("110 tokens")).toBeInTheDocument(); @@ -288,6 +392,9 @@ describe("AI documents panel", () => { await waitFor(() => expect(api.copyAiGenerationJson).toHaveBeenCalledWith( expect.stringContaining('"response-1"'), )); + fireEvent.keyDown(screen.getByRole("button", { name: "关闭生成详情" }), { key: "Escape" }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(detailsButton).toHaveFocus(); }); it("skips an unavailable speaker template when opening a new document dialog", async () => { diff --git a/src/components/AiDocumentsPanel.tsx b/src/components/AiDocumentsPanel.tsx index bddcce6..a8c763d 100644 --- a/src/components/AiDocumentsPanel.tsx +++ b/src/components/AiDocumentsPanel.tsx @@ -3,24 +3,25 @@ import { AlertCircle, Clipboard, FileText, - FolderOpen, Link2, LoaderCircle, Plus, RefreshCw, - RotateCcw, Sparkles, Square, WandSparkles, + X, } from "lucide-react"; import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; +import { AiDocumentToolbar } from "./AiDocumentToolbar"; +import { AiDocumentReader } from "./AiDocumentReader"; import { api, type UnlistenFn } from "../api"; import { estimateAiRequestInputTokens } from "../ai-token-estimate"; import { llmProviderReady } from "../llm"; +import { AiGenerationDetailsDrawer } from "./AiGenerationDetailsDrawer"; import { AppTooltip } from "./AppTooltip"; import { JsonTreeView } from "./JsonTreeView"; +import { useBackdropDismiss } from "./useBackdropDismiss"; import type { AiDocument, AiDocumentContent, @@ -99,6 +100,8 @@ export function AiDocumentsPanel(props: AiDocumentsPanelProps) { const [contentLoading, setContentLoading] = useState(false); const [dialog, setDialog] = useState(null); const [submitting, setSubmitting] = useState(false); + const closeGenerationDialog = () => { if (!submitting) setDialog(null); }; + const generationBackdrop = useBackdropDismiss(closeGenerationDialog); const [contentReloadKey, setContentReloadKey] = useState(0); const [previewTab, setPreviewTab] = useState<"document" | "details">("document"); const [generationDetailTab, setGenerationDetailTab] = useState<"request" | "response">("request"); @@ -421,16 +424,6 @@ export function AiDocumentsPanel(props: AiDocumentsPanelProps) { }); }; - const handlePreviewTabKeyDown = (event: KeyboardEvent) => { - if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; - event.preventDefault(); - const nextTab = previewTab === "document" ? "details" : "document"; - setPreviewTab(nextTab); - window.requestAnimationFrame(() => { - window.document.getElementById(`ai-document-${nextTab}-tab`)?.focus(); - }); - }; - const handleGenerationDetailTabKeyDown = (event: KeyboardEvent) => { if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; event.preventDefault(); @@ -539,129 +532,36 @@ export function AiDocumentsPanel(props: AiDocumentsPanelProps) { return (
- - + ({ id: version.id, label: formatVersionLabel(version) })), + documentId: selectedDocumentId, versionId: selectedVersionId, + canCreate: canCreateDocument, canRegenerate: availableProviders.length > 0, canRevise: canReviseVersion, + canRead: selectedVersion?.status === "completed" && selectedVersion.fileState !== "missing", + createHint: !availableProviders.length ? "请先配置可用的 LLM Provider" : unusedTemplates.length ? "生成新文档" : "所有模板都已生成", + }} + actions={{ + selectDocument: setSelectedDocumentId, selectVersion: setSelectedVersionId, + create: () => openDialog("create", null), + regenerate: () => { if (selectedDocument) openDialog("regenerate", selectedDocument); }, + revise: () => { if (selectedDocument) openDialog("revise", selectedDocument, selectedVersion); }, + details: () => setPreviewTab("details"), + refresh: () => setContentReloadKey((current) => current + 1), + copy: () => { if (selectedVersion) void api.copyAiDocumentVersion(selectedVersion.id).then(() => props.onMessage("success", "已复制 Markdown")).catch((error) => props.onMessage("error", String(error))); }, + copyPath: () => { if (selectedVersion) void api.copyAiDocumentPath(selectedVersion.id).then(() => props.onMessage("success", "已复制文件路径")).catch((error) => props.onMessage("error", String(error))); }, + open: () => { if (selectedVersion) void api.openAiDocumentVersion(selectedVersion.id).catch((error) => props.onMessage("error", String(error))); }, + reveal: () => { if (selectedVersion) void api.revealAiDocumentVersion(selectedVersion.id).catch((error) => props.onMessage("error", String(error))); }, + }} + /> + {!availableProviders.length &&
请先在设置中添加可用的 LLM Provider;OpenAI 官方服务需要 API Key。
}
{!selectedDocument ? ( -

选择或生成一份 AI 文档。

+
+

选择模板生成第一份 Markdown 文档。

+ +
) : ( <> -
-
- {selectedDocument.title} - {selectedDocument.templateName} -
-
- - - - - - - -
-
- -
- - -
- {selectedVersion?.status === "generating" || selectedVersion?.status === "queued" ? (
@@ -690,52 +590,13 @@ export function AiDocumentsPanel(props: AiDocumentsPanelProps) {
文件已在外部修改;预览和“基于此版本修改”都会使用磁盘上的当前内容。
)} - {previewTab === "document" ? ( - <> -
- - {selectedVersion - ? `${selectedVersion.providerName} · ${selectedVersion.modelId}` - : "尚未生成版本"} - - {selectedVersion?.status === "completed" && selectedVersion.fileState !== "missing" && ( -
- - - - - -
- )} -
-
- {contentLoading ? ( -
读取 Markdown…
- ) : content ? ( - [图片未自动加载:{alt || "无标题"}], - }} - > - {content.markdown} - - ) : ( -

选择一个已完成版本查看内容。

- )} -
- - ) : ( + + {previewTab === "details" && setPreviewTab("document")}>
{selectedVersion ? ( <> @@ -833,13 +694,13 @@ export function AiDocumentsPanel(props: AiDocumentsPanelProps) {

选择一个版本查看生成详情。

)}
- )} +
} )}
{dialog && workspace && ( -
+
@@ -849,7 +710,7 @@ export function AiDocumentsPanel(props: AiDocumentsPanelProps) { 每次生成都会创建新的 Markdown 版本,不会覆盖已有版本或文件。
- +
)}
- + +
+ {props.children} + + ); +} diff --git a/src/components/DetailActionPopover.tsx b/src/components/DetailActionPopover.tsx new file mode 100644 index 0000000..4e9eb16 --- /dev/null +++ b/src/components/DetailActionPopover.tsx @@ -0,0 +1,34 @@ +import { useEffect, useRef, type ReactNode } from "react"; +import { MoreHorizontal } from "lucide-react"; +import { AppTooltip } from "./AppTooltip"; + +/** Low-frequency actions keep native disclosure keyboard behavior. */ +export function DetailActionPopover(props: { label: string; children: ReactNode }) { + const ref = useRef(null); + useEffect(() => { + const close = (event: PointerEvent) => { + if (!ref.current?.contains(event.target as Node)) ref.current?.removeAttribute("open"); + }; + document.addEventListener("pointerdown", close); + return () => document.removeEventListener("pointerdown", close); + }, []); + return ( +
{ + if (event.key === "Escape" && ref.current?.open) { + event.stopPropagation(); + ref.current.open = false; + ref.current.querySelector("summary")?.focus(); + } + }}> + + + +
{ + if ((event.target as HTMLElement).closest("button:not(:disabled)") && ref.current) { + ref.current.open = false; + ref.current.querySelector("summary")?.focus(); + } + }}>{props.children}
+
+ ); +} diff --git a/src/components/DetailSelect.tsx b/src/components/DetailSelect.tsx new file mode 100644 index 0000000..ae5bfdf --- /dev/null +++ b/src/components/DetailSelect.tsx @@ -0,0 +1,6 @@ +import type { SelectHTMLAttributes } from "react"; +import { ChevronDown } from "lucide-react"; + +export function DetailSelect({ children, ...props }: SelectHTMLAttributes) { + return
; +} diff --git a/src/components/JsonTreeView.test.tsx b/src/components/JsonTreeView.test.tsx new file mode 100644 index 0000000..5b5756f --- /dev/null +++ b/src/components/JsonTreeView.test.tsx @@ -0,0 +1,21 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; +import { JsonTreeView } from "./JsonTreeView"; + +afterEach(cleanup); + +it("preserves pointer and keyboard expansion with the Lucide mask style hooks", () => { + render(); + const toggle = screen.getByRole("button", { name: "展开 JSON 节点" }); + expect(toggle).toHaveClass("app-json-tree-expand"); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(toggle).toHaveClass("app-json-tree-collapse"); + expect(screen.getByText('"example"')).toBeInTheDocument(); + fireEvent.keyDown(toggle, { key: "ArrowLeft" }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByText('"example"')).not.toBeInTheDocument(); + fireEvent.keyDown(toggle, { key: "ArrowRight" }); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(toggle).toHaveFocus(); +}); diff --git a/src/components/JsonTreeView.tsx b/src/components/JsonTreeView.tsx index 947d768..ebd0a04 100644 --- a/src/components/JsonTreeView.tsx +++ b/src/components/JsonTreeView.tsx @@ -1,5 +1,7 @@ import { JsonView, collapseAllNested } from "react-json-view-lite"; +// The library exposes CSS icon slots only. Their masks use the bundled Lucide +// ChevronRight SVG (rotated for collapse), preserving its native tree behavior. const jsonTreeStyles = { container: "app-json-tree", childFieldsContainer: "app-json-tree-children", diff --git a/src/components/RecordingsWorkspace.test.tsx b/src/components/RecordingsWorkspace.test.tsx index 4251505..6bdab86 100644 --- a/src/components/RecordingsWorkspace.test.tsx +++ b/src/components/RecordingsWorkspace.test.tsx @@ -9,12 +9,18 @@ import type { TranscriptionVersionSummary, } from "../types"; import "../styles.css"; +import "../recording-detail.css"; import { RecordingsWorkspace } from "./RecordingsWorkspace"; vi.mock("@tauri-apps/api/core", () => ({ convertFileSrc: (path: string) => `asset://${path}`, })); +vi.mock("../api", () => ({ api: { + getAiWorkspace: vi.fn(async () => ({ profile: {}, documents: [], templates: [] })), + onAiStatus: vi.fn(async () => () => {}), +} })); + const completed: RecordingItem = { id: "completed", title: "产品周会", @@ -202,6 +208,60 @@ afterEach(() => { }); describe("RecordingsWorkspace", () => { + it("places the recording-list toggle before the title and names its next action", async () => { + const actions = renderWorkspace(); + await waitFor(() => expect(actions.onPreparePlayback).toHaveBeenCalled()); + const toggle = screen.getByRole("button", { name: "折叠录音列表" }); + const title = screen.getByRole("heading", { name: "产品周会" }); + const header = title.closest("header")!; + expect(header.firstElementChild).toBe(toggle); + expect(toggle.compareDocumentPosition(title) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(toggle).toHaveAttribute("aria-controls", "recording-history-panel"); + fireEvent.click(toggle); + expect(screen.getByRole("button", { name: "展开录音列表" })).toBe(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(document.getElementById("recording-history-panel")).toHaveAttribute("hidden"); + fireEvent.click(toggle); + expect(toggle).toHaveAccessibleName("折叠录音列表"); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(document.getElementById("recording-history-panel")).not.toHaveAttribute("hidden"); + }); + it("preserves audio and transcript scroll while switching tabs and focus mode", async () => { + const actions = renderWorkspace(); + await waitFor(() => expect(actions.onPreparePlayback).toHaveBeenCalledTimes(1)); + const audio = document.querySelector("audio")!; + audio.currentTime = 12; + const body = document.querySelector(".transcript-body")!; + body.scrollTop = 180; + const history = document.querySelector(".history-list")!; + history.scrollTop = 200; + fireEvent.click(screen.getByRole("button", { name: "折叠录音列表" })); + expect(document.querySelector(".history-pane")).toHaveAttribute("hidden"); + fireEvent.click(screen.getByRole("tab", { name: "AI 文档" })); + await screen.findByRole("button", { name: "生成新文档" }); + fireEvent.click(screen.getByRole("tab", { name: "文字转写" })); + expect(document.querySelector("audio")).toBe(audio); + expect(audio.currentTime).toBe(12); + expect(body.scrollTop).toBe(180); + fireEvent.keyDown(document, { key: "Escape" }); + expect(document.querySelector(".history-pane")).not.toHaveAttribute("hidden"); + expect(history.scrollTop).toBe(200); + expect(screen.getByRole("button", { name: "折叠录音列表" })).toHaveFocus(); + expect(actions.onPreparePlayback).toHaveBeenCalledTimes(1); + }); + + it("closes the overflow disclosure before leaving focus mode", async () => { + renderWorkspace(); + fireEvent.click(screen.getByRole("button", { name: "折叠录音列表" })); + const more = screen.getByLabelText("更多转写操作"); + fireEvent.click(more); + fireEvent.keyDown(more, { key: "Escape" }); + expect(more.closest("details")).not.toHaveAttribute("open"); + expect(document.querySelector(".library-workspace")).toHaveClass("is-focused"); + fireEvent.keyDown(document, { key: "Escape" }); + await waitFor(() => expect(document.querySelector(".library-workspace")).not.toHaveClass("is-focused")); + }); it("switches between completed transcription generations", () => { const versions: TranscriptionVersionSummary[] = [{ generation: 2, @@ -278,13 +338,16 @@ describe("RecordingsWorkspace", () => { expect(screen.getByRole("button", { name: "01:02:03" })).toBeInTheDocument(); }); - it("keeps the player and transcription toolbar in one sticky control region", async () => { + it("keeps the player below the independently scrolling reading region", async () => { const actions = renderWorkspace(); await waitFor(() => expect(actions.onPreparePlayback).toHaveBeenCalled()); - const sticky = document.querySelector(".record-detail-sticky-controls"); + const sticky = document.querySelector(".record-detail-controls"); expect(sticky).not.toBeNull(); - expect(sticky?.querySelector("audio")).not.toBeNull(); + expect(sticky?.querySelector("audio")).toBeNull(); + const player = document.querySelector(".unified-player")!; + expect(player.querySelector("audio")).not.toBeNull(); + expect(document.querySelector(".record-transcript-content")!.compareDocumentPosition(player) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(sticky?.querySelector(".transcript-toolbar")).not.toBeNull(); expect(sticky?.querySelector(".transcript-body")).toBeNull(); }); diff --git a/src/components/RecordingsWorkspace.tsx b/src/components/RecordingsWorkspace.tsx index 6996ced..4c3f729 100644 --- a/src/components/RecordingsWorkspace.tsx +++ b/src/components/RecordingsWorkspace.tsx @@ -10,6 +10,8 @@ import { LoaderCircle, MoreHorizontal, Pause, + PanelLeftClose, + PanelLeftOpen, Play, RotateCcw, Search, @@ -35,6 +37,8 @@ import type { TranscriptionVersionSummary, TranscriptionOptions, } from "../types"; +import { DetailActionPopover } from "./DetailActionPopover"; +import { DetailSelect } from "./DetailSelect"; import { AiDocumentsPanel } from "./AiDocumentsPanel"; import { AppTooltip } from "./AppTooltip"; import { @@ -269,6 +273,19 @@ export function RecordingsWorkspace(props: RecordingsWorkspaceProps) { retranscription: boolean; options: TranscriptionOptions; } | null>(null); + const [focused, setFocused] = useState(false); + const [aiVisited, setAiVisited] = useState(false); + const focusButtonRef = useRef(null); + useEffect(() => { + const escape = (event: globalThis.KeyboardEvent) => { + // Tooltips may consume Escape; only task overlays take precedence over focus mode. + if (event.key !== "Escape" || document.querySelector('dialog[open], [role="dialog"], details[open], [role="menu"]')) return; + setFocused(false); + focusButtonRef.current?.focus(); + }; + if (focused) document.addEventListener("keydown", escape); + return () => document.removeEventListener("keydown", escape); + }, [focused]); const [detailTab, setDetailTab] = useState<"transcript" | "ai">("transcript"); const audioRef = useRef(null); const actionMenuRef = useRef(null); @@ -665,8 +682,8 @@ export function RecordingsWorkspace(props: RecordingsWorkspaceProps) { : null; return ( -
-