Skip to content

Add durable recording notes with live transcription - #95

Merged
watzon merged 76 commits into
mainfrom
feature/notes-that-record
Sep 5, 2026
Merged

watzon merged 76 commits into
mainfrom
feature/notes-that-record

Conversation

@watzon

@watzon watzon commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Add recording notes with durable audio, live transcripts, speaker attribution, generated notes, and transcript citations in Ask.

  • Extract shared code into PindropShared with macOS and iOS build targets.
  • Persist capture intent, microphone and system audio, transcript revisions, recovery checkpoints, and staged finalization.
  • Add unified Notes views, templates, source previews, search, meeting detection, and speaker enrollment.
  • Fix retry and cancellation after finalization failures, repeated recordings on one note, writes after note deletion, audio handovers, citation offsets, and refused capture starts.
  • Set the settings window minimum and default width to 660. Update localization, migrations, and regression coverage.

Validation:

  • just build passes.
  • just test passes with 1,451 tests in 111 suites.
  • CI passes the shared-package tests, iOS builds, macOS build, and app tests: https://github.com/watzon/pindrop/actions/runs/33944244367
  • just l10n-lint and git diff --check pass.
  • Live microphone and system audio smoke testing was not performed.

watzon added 30 commits July 22, 2026 15:04
- Avoid leaking dictionary terms into diarized Whisper segments
- Omit vocabulary bias for Parakeet offline fallback and enhancement paths
- Add coverage for diarization options and streaming finalize behavior
Record Granola as a reference-only constraint, keep post-foundation automation deferred, and preserve the dirty main worktree through an isolated feature worktree.
Define validated dictate, voice-note, and meeting lifecycles in PindropCore. Add seven flat UUID-linked SwiftData records, a lightweight V12-to-V13 migration, complete store health checks, and migration/state-machine coverage. Keep runtime capture wiring deferred to later phases.
Persist Quick Capture and speak-to-append history, notes, CaptureSession revisions, microphone sources, and human-note references. Preserve first-source IDs, serialize editor autosaves, enforce exact-handle cancellation, and teach app repair/test-mode paths that V13 is current.
Retain microphone and system audio as separate deterministic PCM artifacts with source-owned ledger chunks and failures. Keep mixed final ASR compatible, survive either source failing, serialize Core Audio teardown, and ensure final transcript inputs match retained sources.
Rotate microphone and system audio into five-minute durable chunks, transcribe bounded files sequentially, checkpoint progress, derive diarization best-effort, reserve stable History IDs, and recover interrupted capture/finalization work across relaunch and orderly shutdown.
Snapshot immutable provider, model, diarization, and note prompt selections for each capture stage. Reuse those snapshots during recovery and fail closed when a persisted selection is no longer available.

Migrate legacy V13 prompt references into V14 prompt companions or tombstones before runtime. Expose the effective next-session assignments as a read-only Models summary.

Verified: just test-shared; just shared-build-ios; just build; just test; live macOS accessibility-tree inspection.
Run artifact-only streaming for quick capture and note append, and persist only stable cumulative text with microphone, provider, and parent provenance.

Make checkpoint and final revision retries idempotent. Serialize engine teardown, scope generic and artifact ownership, and prevent stale output from mutating a successor session.

Expose deterministic recovery candidates while deferring user-facing delivery until capture intent is persisted.

Verified: just test-shared; just shared-build-ios; just build; just test; focused race and persistence tests.
Create protected human anchor notes before meeting finalization and generate derived notes only after History and final transcript checkpoints are durable.

Freeze logical provider, model, and prompt assignments while late-binding provider configuration and credentials. Keep transient generation failures retryable in finalizing sessions.

Persist canonical transcript provenance plus a separate immutable anchor-content snapshot. Render both in a read-only Sources panel, reject citation spoofing, and fail closed on malformed provenance.

Verified: just test-shared; just shared-build-ios; just build; just test; just l10n-lint; focused citation UI test; live panel inspection.
Replace the Home-first shell with Capture, Workspace, and Tools navigation. Make Dictate, Voice Note, and Meeting first-class routes while preserving all five supporting destinations and Command-1 through Command-8 shortcuts.

Use controller-owned route state, one-shot Library requests, localized labels, and a single observable capture-admission state. Capture starts now reject duplicate work, update meeting-sheet availability live, and cannot commit after termination begins.

Verified with just build, just test, localization lint, focused main-shell UI tests, LTR and RTL runtime inspection, and independent correctness, interface, localization, and Swift concurrency reviews.
Delete canonical source audio after explicit meeting cancellation, retain durable cleanup retry state, and keep cancellation exact-handle scoped across delayed tasks and successors.

Require final-ASR coverage for every durable chunk before history, note, or session completion. Successful retries recover matching persisted failures while permanent source gaps remain timed placeholders.

Add regressions for full-session artifact removal, symlink safety, idempotent cancellation, 90-minute chunk coverage, incomplete completion, and retry recovery.
Detect same-version model-hash drift before SwiftData opens the store. Back up the SQLite store, WAL, and SHM, then refresh exact version metadata and model cache only after structural parity checks.

Restore missing Core Data primary-key registrations in the same repair transaction and run the shared idempotent V14 prompt-snapshot backfill on every container open.

The production failure was an intermediate V14 hash set reported as an unknown staged-migration version. Regression tests cover hash drift, semantic prompt backfill, repaired-entity writes, reopen, and backup preservation.
Use the existing localized View all string for the Dictate activity link instead of an uncatalogued English-only label.
Reconcile all fourteen decisions against the implemented branch. Record ten implemented outcomes, the active independence constraint, isolated-worktree preservation, and the two intentional deferrals.

Document the three capture workflows and current main-window navigation. Dictate remains an explicit capture-domain migration follow-up; calendar automation, cross-meeting chat, camera consent, and voice-note recovery delivery remain out of scope.
…editor

- Dictate: restore the hero-first dashboard with a top-right Start
  dictating action; drop the title block and Change shortcut pill
- Voice Note / Meeting: editorial heroes with weekly counts, start
  actions, and Recent sections backed by real notes and meetings
- Note editor: document-size window; markdown heading markers render
  dimmed in the left margin instead of inline
- Extract NoteEditorWindowControllerRegistry.presentEditor for reuse
- Add capture-pillar strings across all locales and regenerate catalogs
- Update main-shell UI test to open Settings via the sidebar
# Conflicts:
#	Packages/PindropShared/Sources/PindropSpeech/TranscriptionService.swift
#	Packages/PindropShared/Tests/PindropSpeechTests/TranscriptionServiceTests.swift
#	Pindrop/AppCoordinator.swift
#	PindropTests/OutputManagerTests.swift
#	PindropTests/StreamingSessionControllerTests.swift
…em IA)

Phase 1 of the notes-that-record plan (plans/notes-that-record-plan.md):

P0: CaptureEnhancedPanelModel, NoteViewStateModel, and CaptureIntentModel land
as schema V15 (lightweight, append-only). Store repair learns a per-version
table map so V15 stores repair upward, never down to V14.

WP0: typography roles absorb HomeLayoutMetrics font values, focus rings move
into the shared components, IconSlot fixes icon lanes, and the duplicated
diarization banner becomes InlineNotice + StageProgressRow.

WP1: sidebar becomes Capture (Dictate, Notes), Workspace (Library, Stats),
Tools (Dictionary, Models). Legacy voice-note and meeting raw values resolve
to Notes. NotesRoute adds an in-window note destination with a stub page, and
capture entry points collapse into one NoteCaptureRequest.

Docs: approved plan, agent briefing, and the extracted design spec.

Verified: just build, just test (870 tests, 79 suites), focused suites per
package.

Claude-Session: https://claude.ai/code/session_01CLPPXoX8t4asUdmdzooQjc
Phase 2 of the notes-that-record plan:

P1: CaptureSessionMode gains .note; NoteCaptureHandle carries an optional
system-audio source and replaces MeetingCaptureHandle. OwnedNoteCapture
supports one- or two-source sessions; a mic-only capture creates no
system-audio row. MeetingCaptureSpoolPlan.systemAudioSourceID is optional
through the media layer. noteCaptureRecoveryCandidates() is the single
recovery scanner with thin legacy shims.

WP2: one Notes page replaces the Voice Note pillar, the Meeting pillar, and
the old workspace list: humanized serif header meta, split New note button,
date groups, capture-aware rows with Enhanced badges and a live-row
treatment, and a shared listKeyboardSelection modifier. The status bar gains
New note items wired through the main window's capture seam.
MeetingCaptureOptionsSheet is gone; speaker count moves to the note page.

Verified: just build; full Unit plan 888 tests in 80 suites; focused
CaptureSessionStoreTests (65) and LongMeetingReliabilityTests (9, durability
gate).

Claude-Session: https://claude.ai/code/session_01CLPPXoX8t4asUdmdzooQjc
…capture

Phase 3 of the notes-that-record plan:

P2: note-mode sessions cannot start without a validated CaptureIntent; the
intent row lands in the same transaction as the session and sources.
newNote intents bind to their note when it exists, recovery snapshots carry
the intent, and legacy sessions honestly report nil. Origins thread through
the main-window, menu-bar, and hotkey entry points.

P3: the source-separated backend forwards microphone buffers to the
streaming pump (system audio never reaches it), mic-only spool plans skip
the system child entirely, and the streaming controller's artifact API is
keyed on NoteCaptureHandle with a 2-hour live-transcription bound that
degrades to spool-only. This is the recorder-level groundwork that lets P4
route every note capture through the durable path.

Verified: just build; full Unit plan 895 tests in 80 suites; package suites
CaptureIntentPersistenceTests (12) + CaptureSessionStoreTests (65);
AudioRecorderTests (52), StreamingSessionControllerTests (18),
LongMeetingReliabilityTests green.

Claude-Session: https://claude.ai/code/session_01CLPPXoX8t4asUdmdzooQjc
P4 step 1 of 2 (bisectable). NoteCaptureController owns the full note-capture
lifecycle moved out of AppCoordinator: durable session start with intent,
mic-only or dual-source spool, live artifact transcription, finalization
(batch ASR with live-text fallback, diarization only for system-audio
captures), record persistence and linking, note generation, failure
recording, and termination checkpointing. NoteCaptureState is the observable
the UI will bind. AppCoordinator conforms to CaptureArbiter (claims, model
activation, progress, navigation) and keeps thin forwarders; it shrinks from
9,434 to 8,114 lines. VoiceNoteCaptureHandle is gone; the store speaks
NoteCaptureHandle everywhere.

Every main-window and menu-bar note capture now routes through the durable
path, ending the 10-minute voice-note cap on those flows. The quick-capture
hotkey still uses the legacy path until step 2 deletes the forwarders.

Verified: just build; full Unit plan 911 tests in 81 suites (3 runs);
NoteCaptureControllerTests (16); coordinator, streaming, recorder, and
LongMeetingReliability suites green; CaptureSessionStoreTests 65/65.

Claude-Session: https://claude.ai/code/session_01CLPPXoX8t4asUdmdzooQjc
P4 step 2 of 2. The quick-capture hotkey now runs through the unified
durable controller (origin-aware: no window yanking, note opens on finish,
silent captures leave no note behind). The transitional forwarders, the
in-memory quick-capture pipeline, and its stop routes are deleted; recovery
orchestration moves onto the controller with the coordinator keeping only
the scheduling shell. AppCoordinator drops to 7,370 lines (from 9,434 at
the start of P4).

Note-append keeps the legacy voice-note path deliberately; it dies with the
editor-window retirement. Mic-only manual captures regain voice-isolation
preprocessing (they are the successors of quick capture); mixed system
audio stays raw so other speakers are not suppressed.

Verified: just build; full Unit plan 917 tests in 81 suites; focused
controller/coordinator/recorder/streaming/reliability suites green;
CaptureSessionStoreTests 67/67.

Claude-Session: https://claude.ai/code/session_01CLPPXoX8t4asUdmdzooQjc
Phase 5 of the notes-that-record plan:

P5: CaptureEnhancedPanelModel becomes a working artifact store. Panels are
per-template, regenerable, and supersede atomically; every generation
allocates the next assignment attempt and freezes provider + prompt
snapshots in the same transaction. NoteEnhancementService runs the
generation flow on the existing MeetingNoteDerivation evidence envelope,
and NoteCaptureController auto-generates a panel for every recorded note on
finish. Generation failure records on state and never fails the capture.
New captures write panels only; legacy generated notes surface as read-only
panels. Save-as-note exists as an explicit operation.

P6: noteCaptureViews(noteID:) resolves the three views (typed notes,
panels, transcript) for every note shape: legacy meeting, legacy voice
note, plain typed, and V15. Transcript assembly shares the exact diarized
spans the citations use, attributes speakers via participant profiles with
honest unattributed fallbacks, falls back to live checkpoints during
capture, and respects independent transcript deletion. View selection
persists on NoteViewStateModel.

Verified: just build; full Unit plan 933 tests in 82 suites; package suites
327 (PindropData) including the 33 new panel/view tests.

Claude-Session: https://claude.ai/code/session_01CLPPXoX8t4asUdmdzooQjc
WP3 of the notes-that-record plan. The stub becomes the real page: header
rail with a split Record control, editable serif title on the shared gutter
axis, the three-view SegmentedViewToggle with persisted selection, and a
720pt canvas. The proven MarkdownEditor and NoteEditorPersistenceController
stack moves in-window verbatim; the editor is never disabled while
recording.

The markdown renderer now draws real bullet and ordered markers with tight
in-list rhythm, serif heading hashes right-aligned in the gutter,
blockquote bars, inline code chips, and fenced code cards, all while raw
markdown stays editable. Capture states bind to NoteCaptureState: capture
strip with live one-liner, StageProgressRow finalization with honest copy,
enhancement failure notice with Try again, and an enhanced-ready dot that
never auto-switches views. Nothing presents the separate editor window any
more.

Verified: just build; full Unit plan 992 tests in 86 suites; new
NotePagePresentation (33) and MarkdownBlockGrammar (26) suites.

Claude-Session: https://claude.ai/code/session_01CLPPXoX8t4asUdmdzooQjc
WP4-WP6 of the notes-that-record plan:

WP4: CaptureBar in page and global densities on a pure presentation type;
the global bar pins to every destination while a capture runs elsewhere;
the sidebar status card becomes a navigating button with a finalizing
phase; the Notes item shows a record dot while live; list REC rows tick in
their own clock; Cancel joins the note-page overflow behind a confirmation.

WP5: transcript turns render as speaker bubbles sharing the participant
color rule with Library, with search + match counts, click-to-seek, a
playback bar, and the live transcript sheet (36pt collapsed row, 40%/70%
snap points, committed vs tentative text, Jump to live, Esc collapses).

WP6: the Enhanced view renders sections with citation resolution feeding
both inline chips and the Sources disclosure (the disclosure is the shipped
surface: generation sanitizes inline markers away by design), a
TemplateMenuButton on a shared MenuButtonChrome with instant switching
between generated panels and in-place regeneration, and honest read-only
treatment for legacy meeting notes.

Verified: just build; full Unit plan 1090 tests in 93 suites; package suite
914 tests in 67 suites.

Claude-Session: https://claude.ai/code/session_01CLPPXoX8t4asUdmdzooQjc
WP7 + WP9 close the notes-that-record plan:

WP7: the Start dictating CTA sits on the kicker baseline with the shortcut
inside the button and swaps in place to a recording control (dot, mono
elapsed, level bars, Stop) with no layout jump. Stats drop to three tiles
with Sessions in the chart header; the slogan empty state becomes plain
copy; recent rows gain keyboard selection; the dead pillar presentation
helpers leave with their tests.

WP9: all 111 outstanding keys translated across the 30 non-English locales
with placeholder validation; l10n-sync run; l10n-lint clean. Three latent
slug-collision bugs fixed (Delete note menu item, edited-time footer,
speaker Current label). VoiceOver labels for rows, dots, and chips;
reduce-motion audited; RTL mirroring with LTR-pinned key sequences; note
page focus starts at the title. AppUITestSurface.notePage ships a real V15
fixture with a CI-shaped UI test, and two never-registered test suites now
run (+90 tests, one unsound timing test disabled with rationale).

Verified: just build; full Unit plan 1188 tests in 98 suites; just
l10n-lint clean; UI test plan compiles.

Claude-Session: https://claude.ai/code/session_01CLPPXoX8t4asUdmdzooQjc
Cleanup pass against the Paper design after first hands-on use:

One CapturePrimaryButton now renders both the Dictate CTA and the Notes
split New note button, which also fixes the split button stretching to the
header height (its divider filled an unbounded frame). Secondary chrome
pads 7/13 to match the mock.

Paper fidelity: the Dictate page gets its 56pt frame and the 18pt gap below
the kicker row; the Notes header keeps 24 above the list; the note page's
header rail and dock span the pane while title, meta, and canvas keep the
720pt measure; title and meta form one 14pt block; the page capture bar's
elapsed uses mono 13.

Auto-naming is an AI job now: every recorded note that is still untitled
after capture gets a title from the .noteMetadata assignment, with the
derived-title heuristic as fallback, a re-fetch guard so a person's rename
always wins, and no delay to capture completion. Quick-capture titling
folds into the same job.

Verified: just build; full Unit plan 1193 tests in 98 suites, including 5
new auto-naming tests.

Claude-Session: https://claude.ai/code/session_01CLPPXoX8t4asUdmdzooQjc
FAB system (search resting, hover fan, morphing pills), merged Enhanced
chip dropdown, source peek replacing citation chrome, ask-this-note dock,
solo-transcript pause breaks, speakers popover, title hover affordance.
Values extracted from Paper boards 52, 55, 56, 65, 70, 71 and crops 66-69.

Claude-Session: https://claude.ai/code/session_01CLPPXoX8t4asUdmdzooQjc
…ords fix

UI: the three note views are individual chips; the selected Enhanced chip
carries the merged dropdown (Enhanced notes header with regenerate, glyphed
template rows, All templates, New template), replacing the separate
template button. Citation chips and the Sources disclosure give way to
source peek: hover a bullet for the magnifier, click for the quoted span
with timestamp, speaker, and Show in transcript. The speakers chip opens a
popover with profile-backed renames (unmatched voices say honestly why they
cannot be renamed yet). The title shows a pencil on hover.

Backend: the dormant VoiceActivityDetector now segments mic-only captures
into paragraph blocks at 1.2s pauses, persisted as single-speaker diarized
segments so every existing validator applies; solo transcripts render as
time-coded blocks with a mono gutter. VAD and streaming models are required
first-run downloads with a launch catch-up for existing installs.

The duplicated-last-words bug is fixed at its root: the refinement
coordinator never notified the sink when a verbatim tentative tail was
promoted to committed, and the note transcript concatenates the two
channels. Delivery now tracks the split, proven by failing-first tests.

Verified: just build; full Unit plan 1217 tests in 98 suites; package suite
941; LongMeetingReliabilityTests green.

Claude-Session: https://claude.ai/code/session_01CLPPXoX8t4asUdmdzooQjc
watzon added 28 commits August 31, 2026 10:59
The system channel now carries per-speaker labels instead of one "Call audio"
header. The diarizer gets its own stream and its own detached consumer, fed
every system buffer whichever channel owns the streaming engine, so its speaker
cache and its frame clock both stay coherent while the microphone holds the
engine.

Slots are numbered 1-based in first-finalized-speech order past a 0.35 s noise
gate, never the model's own track numbers. A speaker change cuts a paragraph
only while the system channel owns the engine, only past the hysteresis floor,
and only once per window: changes inside it coalesce, last one wins.

The late boundary splits the open span retroactively against the commit stamps,
so text decoded before the switch stays with the speaker who said it. The cut is
recorded as an offset rather than written into the committed text, because the
durable checkpoint takes every write as an extension of the previous one and one
inserted character would end persistence for the capture.

The capture degrades to channel labels for its whole life when the bundle is
missing or the bounded load fails, and a runtime kill switch drops live labels
when partial arrival latency stays over budget for three windows.
A slot that has said three seconds is embedded from the diarizer's ring and
matched against the participant profiles, and a match names it in place: the
whole slot is repointed, so a reader never sees a name start partway down a
transcript. The header keeps a "was Speaker 2" affix for the rest of the
capture, derived from promotedAt rather than delivered once.

LiveSpeakerEmbedder is its own actor with its own OfflineDiarizerManager on its
own serial executor. FluidSpeakerDiarizer is @mainactor and would block the main
actor for hundreds of milliseconds per clip, which is visible orb jank, so the
live path never reaches for it. The embedder loads at the first promotion
attempt and is released at step 6 of the stop sequence, before the offline pass
starts.

The live match thresholds are new constants beside the offline pair, and higher.
The offline numbers were tuned against whole-recording embeddings; a live label
comes from a six second slice of conference audio, and a wrong name shown for
forty minutes is the outcome this phase most needs to avoid. Both numbers are
placeholders that the gated calibration harness replaces with a measurement.

Everything else degrades to "Speaker 2": a missing offline model, an embedding
space mismatch, a budget overrun, or a match inside the live margin. Tiers only
go up, a named slot is never re-checked, and the live path records no training
evidence.
The note page reuses DiarizationSetupIssueBanner for the two live-speaker faults
a reader can act on: the model is not downloaded, or its bounded load failed. Its
download covers both bundles, because a real name needs the offline embedder as
well, and a fetch that took only the streaming model would leave every slot at
"Speaker 2" with nothing left to press.

The settings row is the switch the whole feature hangs off. It gates engine
startup and nothing else: diarizationFeatureEnabled still owns the finalize
stage, so turning live names off never strips the speakers out of a finished
note.

Two chips, both checked against the collapsed sheet row. Neither states a fact
the app cannot observe: the four-voice line names what the feature covers and
claims no headcount, and the paused line promises a second pass only when
finalize is actually going to run one.

The reconciliation line reads from the flag inside the diarization payload, so it
survives quit and relaunch and shows on every open until the reader dismisses it.
A long meeting finalizes minutes after stop, usually into an empty room, and a
transient line would be delivered almost only to a reader who was already
watching. Dismissal clears the one key and keeps the rest of the payload: the
mic-only ranges are still what makes the recorder "You".
Eight new strings for Phase 2 live speaker names: the two setup banner
messages, the paused and capacity chips on the live sheet, the sentence
the chip appends when the offline pass is scheduled, the note page line
about the second check, and the Settings row title and description. All
30 non-English locales are translated, not left in English.

Each locale reuses the word it already uses for "speaker" and for the
finished note, so the live names read like the rest of the app instead
of like a bolted-on feature.

LocalizationKeyResolutionTests now covers the eight strings, so a locale
that misses one fails the suite instead of hiding behind the English
fallback that `localized` returns.
FluidAudio materializes the streaming Sortformer bundle under
Repo.sortformer.folderName, which is "sortformer", not under the
Hugging Face repo name. Readiness that looked for the repo name stayed
false after a good download, so the model reported itself incomplete
and live speaker labels never turned on.
Thirteen new strings for the Phase 3 meeting entry points: the two menu
bar rows, the call notification title, body, and decline action, the four
Meetings settings rows with their two descriptions, and the four parts of
the one-time ask. All 30 non-English locales are translated, not left in
English.

Four rows of the section 5.7 table needed no new key. "New note" and
"Record" were already in the tree, and so were "Meetings" and "Not now",
which the table did not mark. "New note with system audio" stays, because
NotesPresentation.NoteCreationOption still uses it.

Each locale reuses the words it already uses for a call, a meeting note,
the system audio, and System Settings, so the meeting rows read like the
rest of the app.

LocalizationKeyResolutionTests now pins all of them in English and asks
every locale for a translation. "Meetings" is left out of the translation
check on purpose: German keeps the English word, and an equality check
would read that correct translation as a miss.
…eset

The note page header already carried the template control: the Enhanced
chip opens a menu that lists every preset, checks the one that wrote the
panel on screen, and regenerates through the existing
onGenerateEnhancedPanel path. It said nothing when no template wrote the
panel, so the menu now reads "No template. The note is written as plain
notes." above the list. No second control was built.

The record button dropdown gains a Template submenu, built from the same
preset rows in the same order, which records the choice before the start.
NoteCaptureRequest carries the identifier into captureIntentRequest, so
the durable intent holds it and a capture recovered after a crash is
written with the template the person picked.

NoteCaptureRequest.meetingNote now carries
BuiltInPresets.meetingNotes.identifier. All three meeting entry points
read that one preset: "New meeting note", "Record this call", and the
notification "Record" action. "New note" stays template-neutral.

StatusBarControllerTests.newMeetingNoteRequestsBothSourcesAndNoTemplate
is renamed to newMeetingNoteRequestsBothSourcesAndTheMeetingTemplate.
Its meaning changes in this commit: Phase 3 kept the meeting note
template-neutral on purpose until the picker existed, so the template and
the control that explains it ship together.
Startup recovery read only the system-audio subset of the recovery
candidates, so an interrupted microphone-only capture stayed interrupted
forever and its committed words landed nowhere a reader looks. Recovery
now reads every interrupted note capture and resolves a destination
first: the anchor the capture bound wins, then the note the durable
intent names, then a fresh note. A destination that was deleted costs a
title, never the transcript.

Nothing in the recovery path raises a window. The shell callbacks that
open a note belong to a live capture; a recovered one waits in the
library.

The Recovered state is derived, not stored. Phase 4 adds no persistence:
a note is recovered when a capture session bound to it has a failure
record that recovery stamped, which is one fetch per library page inside
the existing row-facts pass and one fetch for a note page.

Renames recoveryLeavesMicrophoneOnlyCapturesForTheirDeliveryStep to
recoveryDeliversMicrophoneOnlyCaptures. That test pinned the gap this
commit closes, so it now asserts the delivery it was waiting for.
Cleanup item 7 of section 6.4. Round B replaced the numeric chips with
EnhancedSourcePeekRow, so no view draws a chip.

Section 6.4 says no view reads EnhancedNoteBlock.citations. peekTarget does,
and that read is what makes a line peekable at all. So the block keeps the
only thing the read needs, the citation identifiers that resolved, and loses
the chip type with its undrawn label, segmentID, and startOffset. The make()
citations parameter and the panelCitations load stay: they build the source
rows the peek shows.

The chip-field assertions in EnhancedViewPresentationTests go with the type.
Each one is restated on the identifiers, so the resolution rules stay covered.
Cleanup item 8 of section 6.4. canPause was hardcoded false and nothing
branched on it, so it was a false affordance in a state model that Phase 3 and
Phase 4 both extend.

Decision 52 of the design document now holds the rationale it carried: v1 has
no pause because AudioRecorder cannot pause a durable spool and
CaptureSession.isValid encodes state in revision parity, so a paused lifecycle
state would mean rewriting the validator.

Its single assertion in NoteCaptureControllerTests goes with it.
Ten new strings for the Phase 4 finalization surfaces: the "Matching
names" stage, the "Skipped" word a stage that does not apply wears, the
stall line and its action, the transcribing failure row, the line over
the settling transcript, the recovered note line and its library chip,
and the two template picker strings P4.2 started calling. All 30
non-English locales are translated, not left in English.

The four shipped stage names are reused, not renamed. "Sealing audio",
"Transcribing", "Identifying speakers", and "Writing note" already exist
in every locale and mirror MediaTranscriptionStage, so renaming them
would churn 31 files and split two surfaces that agree today.

"Template" collides with the shipped "Template: %@" chip on the slug the
importer derives, so it takes the digest form the importer itself would
have produced. Each locale reuses the word it already uses for a
template, a transcript, and a recording.

LocalizationKeyResolutionTests pins the new strings in English and asks
every locale for a translation, which is the only place a missing one is
visible: localized falls back to the English source on screen.
Three reviews of P4.1 to P4.8 against section 6 of the design.

Fixed:
- The checklist ticked off both speaker rows for work that never ran. The
  system channel alone does not make finalize diarize: it also needs the
  setting on and the model on disk. `appliesToThisCapture` now reads
  `isOfflineSpeakerPassScheduled`, which is the predicate finalize applies.
- The checklist walked backwards once per sealed chunk. `.diarizing` was
  reported before every chunk call and `.transcribing` after it, so a long
  meeting retracted a checkmark per chunk and attributed an ASR failure to
  the speaker row. It is reported once, after the loop.
- The failed row and its 31-locale sentence could not render: the dock
  unmounts for a failed capture. `showsCaptureStrip` keeps it up for a
  capture that failed while finalizing, and the page draws that checklist.
- Two rows read `Writing note`. `.enhancing` takes the shipped `Enhancing…`,
  so no stage name is renamed and no locale file changes (decision 50).
- The record dropdown's neutral row promised plain notes, which the pipeline
  cannot produce: nil falls back to the settings template. It reads
  `Default`, a shipped string, and says what it does.
- The header menu's empty-template line was unreachable. It keys on whether
  any row is checked, which is the state a panel written with no template
  reaches through the generator's own default identifier.
- VoiceOver read the checklist as six bare names. The row label carries the
  status word and the stall action title.
- The settling sentence was drawn twice at once. It keeps its host over the
  turns; the footer says what the app is doing with the recording.
- The stall action could open an empty sheet when the capture heard nothing.
- `completedStages(before:)` lost both call sites in P4.1. It goes, and so
  do its four assertions: they pinned copy no surface draws.
- `aRecoveredCaptureWithNoBoundNoteCreatesOne` asserted a tautology. It now
  names both titles the auto-naming path produces.

Rejected: rewriting the bodies of the three landed deletion commits (each is
already its own commit, as section 8.3 asks) and regenerating
`L10nKeys.swift` (`just l10n-sync` is idempotent on the committed tree).

Renamed `everyStageHasAnHonestNameAndAPredecessor` to
`everyStageHasAnHonestName`, since the predecessor line is gone.
The 10 s bound tripped in the gated integration run whenever a Nemotron
load ran at the same time as the Sortformer load, which is what a cold
first capture looks like, and the reader would have been told the model
could not be loaded. Measured: 3.1 s for a warm load alone, over 10 s
with a concurrent Nemotron load, 63 s only when downloading. 30 s absorbs
the contention and still refuses a download. The load runs concurrently
with capture start, so a slow load only delays labels, never audio.

Evidence: LiveDiarizationEngineTests 11/11, just build, and the
LiveDiarizationIntegrationTests plus LiveDiarizationLatencyIntegrationTests
pair passing together (13.8 s and 244.8 s) after failing at 10 s.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 441dc2732b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

recordID = try captureSessionStore.reserveMeetingTranscriptionRecordID(handle)
}
do {
let record = try historyStore.save(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Attach recorded-note audio to the history record

For every normally finalized capture, this save call omits managedMediaPath (the live-transcript fallback does too), and no subsequent updateManagedMediaPath exists for note captures. NotePageView.refreshPlayableAudio only exposes playback when that field resolves to an existing file, so every production recorded note lacks the Play recording control even though its source chunks were retained. Materialize and attach a playable capture asset, or teach the view to resolve the capture ledger, before completing the session.

AGENTS.md reference: AGENTS.md:L166-L167

Useful? React with 👍 / 👎.

askTask = Task { @MainActor in
defer { askMessages = noteChatService.conversation(noteID: noteID) }
do {
_ = try await noteChatService.ask(noteID: noteID, question: trimmed)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Flush pending drafts before AI reads the note

When someone edits the note and immediately presses Ask—or regenerates a panel—the latest snapshot can still be waiting in the editor's 500 ms debounce while this call dispatches by note ID. Both NoteChatService and NoteEnhancementService then reread the durable SwiftData row, so the prompt can omit visible edits and a generated panel can persist a stale humanAnchorContentSnapshot. Flush the pending draft or pass the live snapshot before dispatching either AI action.

Useful? React with 👍 / 👎.

Comment on lines +178 to +181
let previous = reportedCall
reportedCall = call

guard let call else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear the previous call when switching detected apps

When two catalog apps overlap and the currently reported app ends while the other remains active, the monitor publishes call B directly after call A without an intermediate nil. This assignment therefore skips the branch that withdraws A's notification and clears its declined bundle identifier; the old alert remains, and choosing “Not now” for A can suppress future A calls indefinitely. Treat a bundle-identifier change as the end of the previous call before processing the replacement.

Useful? React with 👍 / 👎.

@watzon
watzon merged commit fe518a8 into main Sep 5, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant