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.
This commit is contained in:
iamdoubz committed 2026-07-11 00:13:01 -05:00
1 parent 0027becd10
commit 99d50583cf
1 file changed
+403 -115
+403 -115
View File
@@ -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<typeof setTimeout> | 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<number | null>(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"
/>
<div class="split">
<div class="pane transcript">
<h4>
<MessageSquareText size={14} aria-hidden="true" /> Transcript
<span class="badge lang" title="Transcription language">{languageLabel(m.language)}</span
<div
class="split"
style="grid-template-columns: {splitColumns()};"
bind:clientWidth={splitWidth}
>
<div class="pane transcript" class:collapsed={layout.transcriptCollapsed}>
{#if layout.transcriptCollapsed}
<button
class="pane-toggle"
onclick={toggleTranscript}
title="Show transcript"
aria-label="Show transcript"
>
</h4>
{#if m.recorded && settings.models.some((mo) => mo.installed)}
{@const reprocessModelInfo = settings.models.find((mo) => mo.id === reprocessModel)}
<div class="reprocess">
<select bind:value={reprocessModel}>
<option value="">Re-transcribe with…</option>
{#each settings.models.filter((mo) => mo.installed) as mo (mo.id)}
<option value={mo.id}>{mo.label}</option>
{/each}
</select>
{#if reprocessModelInfo?.multilingual}
<select bind:value={reprocessLanguage} aria-label="Reprocess language">
<option value="">Keep current language</option>
<option value="auto">Auto-detect</option>
{#each settings.languages as l (l.code)}
<option value={l.code}>{l.label}</option>
<PanelLeftOpen size={16} aria-hidden="true" />
</button>
{:else}
<h4>
<MessageSquareText size={14} aria-hidden="true" /> Transcript
<span class="badge lang" title="Transcription language"
>{languageLabel(m.language)}</span
>
<button
class="pane-toggle inline"
onclick={toggleTranscript}
title="Hide transcript"
aria-label="Hide transcript"
>
<PanelLeftClose size={13} aria-hidden="true" />
</button>
</h4>
{#if m.recorded && settings.models.some((mo) => mo.installed)}
{@const reprocessModelInfo = settings.models.find((mo) => mo.id === reprocessModel)}
<div class="reprocess">
<select bind:value={reprocessModel}>
<option value="">Re-transcribe with…</option>
{#each settings.models.filter((mo) => mo.installed) as mo (mo.id)}
<option value={mo.id}>{mo.label}</option>
{/each}
</select>
{/if}
<button disabled={!reprocessModel || reprocessing} onclick={reprocess}>
<RefreshCw size={13} aria-hidden="true" class={reprocessing ? "spin" : ""} />
{reprocessing ? "Re-transcribing…" : "Go"}
{#if reprocessModelInfo?.multilingual}
<select bind:value={reprocessLanguage} aria-label="Reprocess language">
<option value="">Keep current language</option>
<option value="auto">Auto-detect</option>
{#each settings.languages as l (l.code)}
<option value={l.code}>{l.label}</option>
{/each}
</select>
{/if}
<button disabled={!reprocessModel || reprocessing} onclick={reprocess}>
<RefreshCw size={13} aria-hidden="true" class={reprocessing ? "spin" : ""} />
{reprocessing ? "Re-transcribing…" : "Go"}
</button>
</div>
{/if}
{#if m.segments.length === 0}
<p class="muted">No transcript for this meeting.</p>
{:else}
{#each m.segments as s (s.id)}
<p><strong>{speakerName(s.speaker, m.speakers)}:</strong> {s.text}</p>
{/each}
{/if}
{/if}
</div>
{#if !layout.transcriptCollapsed && !layout.notesCollapsed}
<Splitter
label="Resize transcript and notes"
onResize={onSplitResize}
onResizeEnd={() => layout.persist()}
/>
{:else}
<span></span>
{/if}
<div class="pane notes" class:collapsed={layout.notesCollapsed}>
{#if layout.notesCollapsed}
<button
class="pane-toggle"
onclick={toggleNotes}
title="Show notes"
aria-label="Show notes"
>
<PanelRightOpen size={16} aria-hidden="true" />
</button>
{:else}
<h4>
<NotebookPen size={14} aria-hidden="true" /> Notes
<button
class="pane-toggle inline"
onclick={toggleNotes}
title="Hide notes"
aria-label="Hide notes"
>
<PanelRightClose size={13} aria-hidden="true" />
</button>
</h4>
<div class="toolbar" role="toolbar" aria-label="Notes formatting">
<button onclick={() => wrapSelection("**")} title="Bold" aria-label="Bold">
<Bold size={14} aria-hidden="true" />
</button>
<button onclick={() => wrapSelection("_")} title="Italic" aria-label="Italic">
<Italic size={14} aria-hidden="true" />
</button>
<button onclick={() => insertLinePrefix("# ")} title="Heading 1" aria-label="Heading 1">
<Heading1 size={14} aria-hidden="true" />
</button>
<button
onclick={() => insertLinePrefix("## ")}
title="Heading 2"
aria-label="Heading 2"
>
<Heading2 size={14} aria-hidden="true" />
</button>
<button
onclick={() => insertLinePrefix("- ")}
title="Bullet list"
aria-label="Bullet list"
>
<List size={14} aria-hidden="true" />
</button>
<button
onclick={() => insertLinePrefix("- [ ] ")}
title="Checkbox"
aria-label="Checkbox list item"
>
<ListChecks size={14} aria-hidden="true" />
</button>
<button
class="toggle"
onclick={() => (notesPreview = !notesPreview)}
title={notesPreview ? "Edit the raw markdown" : "Render the markdown"}
aria-pressed={notesPreview}
>
{#if notesPreview}
<Pencil size={14} aria-hidden="true" />
Editor
{:else}
<Eye size={14} aria-hidden="true" />
Preview
{/if}
</button>
<span class="spacer"></span>
<button onclick={exportMd} title="Export notes as .md">
<FileText size={13} aria-hidden="true" />
.md
</button>
<button onclick={exportPdf} title="Export notes as .pdf">
<FileDown size={13} aria-hidden="true" />
PDF
</button>
<button onclick={exportDocx} title="Export notes as .docx">
<FileDown size={13} aria-hidden="true" />
Word
</button>
<button onclick={exportBundle} title="Export audio + transcript + notes to a folder">
<FolderOutput size={13} aria-hidden="true" />
Bundle
</button>
</div>
{/if}
{#if m.segments.length === 0}
<p class="muted">No transcript for this meeting.</p>
{:else}
{#each m.segments as s (s.id)}
<p><strong>{speakerName(s.speaker, m.speakers)}:</strong> {s.text}</p>
{/each}
{/if}
</div>
<div class="pane notes">
<h4><NotebookPen size={14} aria-hidden="true" /> Notes</h4>
<div class="toolbar" role="toolbar" aria-label="Notes formatting">
<button onclick={() => wrapSelection("**")} title="Bold" aria-label="Bold">
<Bold size={14} aria-hidden="true" />
</button>
<button onclick={() => wrapSelection("_")} title="Italic" aria-label="Italic">
<Italic size={14} aria-hidden="true" />
</button>
<button onclick={() => insertLinePrefix("# ")} title="Heading 1" aria-label="Heading 1">
<Heading1 size={14} aria-hidden="true" />
</button>
<button onclick={() => insertLinePrefix("## ")} title="Heading 2" aria-label="Heading 2">
<Heading2 size={14} aria-hidden="true" />
</button>
<button
onclick={() => insertLinePrefix("- ")}
title="Bullet list"
aria-label="Bullet list"
>
<List size={14} aria-hidden="true" />
</button>
<button
onclick={() => insertLinePrefix("- [ ] ")}
title="Checkbox"
aria-label="Checkbox list item"
>
<ListChecks size={14} aria-hidden="true" />
</button>
<button
class="toggle"
onclick={() => (notesPreview = !notesPreview)}
title={notesPreview ? "Edit the raw markdown" : "Render the markdown"}
aria-pressed={notesPreview}
>
<div class="editor-preview">
{#if notesPreview}
<Pencil size={14} aria-hidden="true" />
Editor
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via renderMarkdown() -->
<div class="preview">{@html renderMarkdown(notesText)}</div>
{:else}
<Eye size={14} aria-hidden="true" />
Preview
<textarea
bind:this={editorEl}
bind:value={notesText}
oninput={scheduleSave}
placeholder="Notes…"
></textarea>
{/if}
</button>
<span class="spacer"></span>
<button onclick={exportMd} title="Export notes as .md">
<FileText size={13} aria-hidden="true" />
.md
</button>
<button onclick={exportPdf} title="Export notes as .pdf">
<FileDown size={13} aria-hidden="true" />
PDF
</button>
<button onclick={exportDocx} title="Export notes as .docx">
<FileDown size={13} aria-hidden="true" />
Word
</button>
<button onclick={exportBundle} title="Export audio + transcript + notes to a folder">
<FolderOutput size={13} aria-hidden="true" />
Bundle
</button>
</div>
<div class="editor-preview">
{#if notesPreview}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via renderMarkdown() -->
<div class="preview">{@html renderMarkdown(notesText)}</div>
{:else}
<textarea
bind:this={editorEl}
bind:value={notesText}
oninput={scheduleSave}
placeholder="Notes…"
></textarea>
{/if}
</div>
</div>
{/if}
</div>
</div>
{:else if recording.segments.length === 0}
{:else if recording.state === "idle"}
<p class="muted pad">Transcript will appear here as you record.</p>
{:else}
<div class="pad">
{#each recording.segments as s (s.id)}
<p class:interim={s.interim}>
<strong>{speakerName(s.speaker)}:</strong>
{s.text}
</p>
{/each}
<!-- Granola-style redesign: the Notes pane is open and typable while
recording, and clicking a transcript line attaches a note to that
moment — both merged into notes.md with the transcript at stop. -->
<div
class="split"
style="grid-template-columns: {splitColumns()};"
bind:clientWidth={splitWidth}
>
<div class="pane transcript" class:collapsed={layout.transcriptCollapsed}>
{#if layout.transcriptCollapsed}
<button
class="pane-toggle"
onclick={toggleTranscript}
title="Show transcript"
aria-label="Show transcript"
>
<PanelLeftOpen size={16} aria-hidden="true" />
</button>
{:else}
<h4>
<MessageSquareText size={14} aria-hidden="true" /> Transcript
<button
class="pane-toggle inline"
onclick={toggleTranscript}
title="Hide transcript"
aria-label="Hide transcript"
>
<PanelLeftClose size={13} aria-hidden="true" />
</button>
</h4>
{#if recording.segments.length === 0}
<p class="muted">Transcript will appear here as you record.</p>
{:else}
{#each recording.segments as s (s.id)}
{@const hasNote = recording.segmentNotes.has(s.start_ms)}
{@const open = selectedSegmentMs === s.start_ms}
<div class="segment">
<button
type="button"
class="segment-line"
class:interim={s.interim}
class:active={open}
onclick={() => (selectedSegmentMs = open ? null : s.start_ms)}
>
<strong>{speakerName(s.speaker)}:</strong>
{s.text}
{#if hasNote}
<span class="note-badge" title="Has a note">📝</span>
{/if}
</button>
{#if open}
<input
type="text"
class="segment-note-input"
placeholder="Add a note for this moment…"
value={recording.segmentNotes.get(s.start_ms) ?? ""}
onchange={(e) =>
recording.setSegmentNote(s.start_ms, (e.target as HTMLInputElement).value)}
aria-label="Note for this transcript line"
/>
{/if}
</div>
{/each}
{/if}
{/if}
</div>
{#if !layout.transcriptCollapsed && !layout.notesCollapsed}
<Splitter
label="Resize transcript and notes"
onResize={onSplitResize}
onResizeEnd={() => layout.persist()}
/>
{:else}
<span></span>
{/if}
<div class="pane notes" class:collapsed={layout.notesCollapsed}>
{#if layout.notesCollapsed}
<button
class="pane-toggle"
onclick={toggleNotes}
title="Show notes"
aria-label="Show notes"
>
<PanelRightOpen size={16} aria-hidden="true" />
</button>
{:else}
<h4>
<NotebookPen size={14} aria-hidden="true" /> Notes
<button
class="pane-toggle inline"
onclick={toggleNotes}
title="Hide notes"
aria-label="Hide notes"
>
<PanelRightClose size={13} aria-hidden="true" />
</button>
</h4>
<textarea
class="live-notes"
value={recording.notesText}
oninput={(e) => recording.setNotesText((e.target as HTMLTextAreaElement).value)}
placeholder="Type notes while you talk…"
></textarea>
{/if}
</div>
</div>
{/if}
</div>
@@ -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 {