From 99d50583cff1a9f725f8b786bdf17a6c2bb09a75 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sat, 11 Jul 2026 00:13:01 -0500 Subject: [PATCH] feat(ui): live notes pane, click-to-annotate transcript, resizable split Granola-style redesign: the live-recording view gains a Notes pane (bound to recording.notesText) alongside the transcript, and each transcript line is clickable to attach/edit a note for that moment (recording.segmentNotes, badge on annotated lines). Also, for both the live and finalized-meeting layouts: the transcript/notes split is now drag-resizable and each side independently collapsible (Splitter + layout store), and the post-finalize Notes pane defaults to Preview (rendered) instead of raw Editor mode, since the merged document is now properly sectioned rather than a single-speaker blob. --- src/lib/views/TranscriptNotes.svelte | 518 +++++++++++++++++++++------ 1 file changed, 403 insertions(+), 115 deletions(-) diff --git a/src/lib/views/TranscriptNotes.svelte b/src/lib/views/TranscriptNotes.svelte index f44744f..490a161 100644 --- a/src/lib/views/TranscriptNotes.svelte +++ b/src/lib/views/TranscriptNotes.svelte @@ -8,6 +8,8 @@ import { api, type SpeakerInfo } from "../api"; import { renderMarkdown } from "../markdown"; import { save, open } from "@tauri-apps/plugin-dialog"; + import { layout, clamp } from "../stores/layout.svelte"; + import Splitter from "../components/Splitter.svelte"; import { Bold, Italic, @@ -23,6 +25,10 @@ NotebookPen, Eye, Pencil, + PanelLeftClose, + PanelLeftOpen, + PanelRightClose, + PanelRightOpen, } from "@lucide/svelte"; function speakerName(label: string, speakers: SpeakerInfo[] = []): string { @@ -40,11 +46,50 @@ let notesText = $state(""); // Notes is a single pane: raw markdown ("Editor") or the rendered result // ("Preview"), toggled by one button whose label flips to the other mode. - let notesPreview = $state(false); + // Defaults to Preview: the merged notes.md is now a properly sectioned + // document (## Notes / ## Transcript), not worth reading as raw markdown + // by default the way the old single-speaker-blob output arguably was. + let notesPreview = $state(true); let editorEl: HTMLTextAreaElement | undefined = $state(); let saveTimer: ReturnType | undefined; let loadedForId: string | null = null; + // Transcript/notes split (FR-UX-1): resizable (drag the Splitter) and + // each side independently hideable, shared across the finalized-meeting + // and live-recording views via the layout store. `splitWidth` tracks the + // container's current pixel width (bound below) so a drag delta in + // pixels can be converted to a fraction of the available space. + let splitWidth = $state(600); + function splitColumns(): string { + if (layout.transcriptCollapsed) return "auto auto 1fr"; + if (layout.notesCollapsed) return "1fr auto auto"; + return `${layout.transcriptFraction * 100}% auto ${(1 - layout.transcriptFraction) * 100}%`; + } + function onSplitResize(deltaPx: number) { + if (splitWidth <= 0) return; + layout.transcriptFraction = clamp(layout.transcriptFraction + deltaPx / splitWidth, 0.2, 0.8); + } + function toggleTranscript() { + const wouldCollapse = !layout.transcriptCollapsed; + if (wouldCollapse && layout.notesCollapsed) return; // never hide both + layout.transcriptCollapsed = wouldCollapse; + layout.persist(); + } + function toggleNotes() { + const wouldCollapse = !layout.notesCollapsed; + if (wouldCollapse && layout.transcriptCollapsed) return; + layout.notesCollapsed = wouldCollapse; + layout.persist(); + } + + // Live-recording transcript: which segment (by start_ms, the note anchor — + // see recording.svelte.ts) is showing its note-entry field, if any. + let selectedSegmentMs = $state(null); + $effect(() => { + void recording.meetingId; // dependency: reset the open note field for a new recording + selectedSegmentMs = null; + }); + // Sync the editor buffer whenever a different meeting is selected. $effect(() => { const m = meetings.selected; @@ -163,131 +208,287 @@ onchange={onTitleChange} aria-label="Meeting title" /> -
-
-

-

- {#if m.recorded && settings.models.some((mo) => mo.installed)} - {@const reprocessModelInfo = settings.models.find((mo) => mo.id === reprocessModel)} -
- - {#if reprocessModelInfo?.multilingual} - + + {#each settings.models.filter((mo) => mo.installed) as mo (mo.id)} + {/each} - {/if} - +
+ {/if} + {#if m.segments.length === 0} +

No transcript for this meeting.

+ {:else} + {#each m.segments as s (s.id)} +

{speakerName(s.speaker, m.speakers)}: {s.text}

+ {/each} + {/if} + {/if} +
+ {#if !layout.transcriptCollapsed && !layout.notesCollapsed} + layout.persist()} + /> + {:else} + + {/if} +
+ {#if layout.notesCollapsed} + + {:else} +

+

+ - {/if} - {#if m.segments.length === 0} -

No transcript for this meeting.

- {:else} - {#each m.segments as s (s.id)} -

{speakerName(s.speaker, m.speakers)}: {s.text}

- {/each} - {/if} -
-
-

- -
- {#if notesPreview} - -
{@html renderMarkdown(notesText)}
- {:else} - - {/if} -
+
+ {/if}
- {:else if recording.segments.length === 0} + {:else if recording.state === "idle"}

Transcript will appear here as you record.

{:else} -
- {#each recording.segments as s (s.id)} -

- {speakerName(s.speaker)}: - {s.text} -

- {/each} + +
+
+ {#if layout.transcriptCollapsed} + + {:else} +

+

+ {#if recording.segments.length === 0} +

Transcript will appear here as you record.

+ {:else} + {#each recording.segments as s (s.id)} + {@const hasNote = recording.segmentNotes.has(s.start_ms)} + {@const open = selectedSegmentMs === s.start_ms} +
+ + {#if open} + + recording.setSegmentNote(s.start_ms, (e.target as HTMLInputElement).value)} + aria-label="Note for this transcript line" + /> + {/if} +
+ {/each} + {/if} + {/if} +
+ {#if !layout.transcriptCollapsed && !layout.notesCollapsed} + layout.persist()} + /> + {:else} + + {/if} +
+ {#if layout.notesCollapsed} + + {:else} +

+

+ + {/if} +
{/if}
@@ -328,9 +529,71 @@ line-height: 1.5; } + .segment { + margin: 0.2rem 0; + } + .segment-line { + display: block; + width: 100%; + text-align: left; + background: transparent; + border: none; + border-left: 2px solid transparent; + color: inherit; + font: inherit; + line-height: 1.5; + padding: 0.15rem 0.4rem; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background-color 120ms ease-out; + } + .segment-line:hover { + background: var(--bg-hover); + } + .segment-line.active { + background: var(--accent-soft); + border-left-color: var(--accent); + } + .segment-line.interim { + opacity: 0.55; + font-style: italic; + } + .note-badge { + margin-left: 0.3rem; + } + .segment-note-input { + display: block; + width: 100%; + box-sizing: border-box; + margin: 0.2rem 0 0.4rem; + padding: 0.3rem 0.5rem; + border: 1px solid var(--accent); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--fg); + font: inherit; + font-size: 0.85rem; + } + .live-notes { + resize: none; + width: 100%; + height: calc(100% - 2rem); + box-sizing: border-box; + padding: 0.6rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg); + color: var(--fg); + font: inherit; + line-height: 1.5; + } + .live-notes:focus-visible { + border-color: var(--accent); + } + .split { display: grid; - grid-template-columns: 1fr 1fr; + /* grid-template-columns set inline — depends on resize/collapse state (FR-UX-1). */ flex: 1; min-height: 0; } @@ -342,6 +605,12 @@ .pane.transcript { border-right: 1px solid var(--border); } + .pane.collapsed { + display: flex; + align-items: flex-start; + justify-content: center; + padding: 0.4rem; + } .pane h4 { display: flex; align-items: center; @@ -353,6 +622,25 @@ letter-spacing: 0.04em; color: var(--muted); } + .pane-toggle { + display: flex; + align-items: center; + justify-content: center; + flex: none; + background: none; + border: none; + color: var(--muted); + cursor: pointer; + padding: 0.3rem; + border-radius: var(--radius-sm); + } + .pane-toggle:hover { + background: var(--bg-hover); + color: var(--fg); + } + .pane-toggle.inline { + margin-left: auto; + } /* Transcription language (T8.7, FR-TRX-4) — a quiet pill, not a status color, since "which language" isn't a good/bad state to flag. */ .badge.lang {