Skip to content

Gap close: Phases 1-10 + hex arch test seam#24

Merged
HydraOps-T-rav merged 35 commits into
mainfrom
feat/gap-close
May 13, 2026
Merged

Gap close: Phases 1-10 + hex arch test seam#24
HydraOps-T-rav merged 35 commits into
mainfrom
feat/gap-close

Conversation

@HydraOps-T-rav
Copy link
Copy Markdown
Contributor

Summary

Closes the gap between mockups/flow.html and the current iOS + API implementation across 10 phases.

  • Phase 1Conference, ChatThread, ChatMessage SwiftData entities; Note.conference relationship + Note.speaker + Note.backendSessionId; idempotent ConferenceMigration that backfills from legacy conferenceName.
  • Hex archTranscriptionPort, HybridTranscriptionPort, NetworkPort, BlendPort, ChatPort + World composition root. Production callsites and the four network-touching test files route through the ports; FakeTranscriptionAdapter / FakeNetworkAdapter / FakeBlendAdapter cut tests off from real network.
  • Phase 2 — Chat backend (chatService.js + POST /v1/sessions/:id/chat + POST /v1/chat) with ownership check, rate limit, real token-budget heuristic, cost telemetry, and JSON-contract guards.
  • Phase 3BlendRenderer (pure value renderer for blendedMarkdown + char-range overlays + photo segments) and AugmentedNoteView. UTF-16 offset translation so non-ASCII content doesn't drift.
  • Phase 4MainView (conference-grouped notes list) + ConferenceDetailView. Conferences with no unarchived notes still appear; archived notes stay on the archive screen.
  • Phase 5ChapteredPlaybackView with chapter scrubber that handles tap + drag correctly, transport controls, and a tappable chapter list. AVAudioPlayer-backed.
  • Phase 6 — iOS ChatView + LiveChatAdapter. End-to-end functional after fixing four critical bugs (wrong base URL, missing backendSessionId on Note, empty conference-resolver, raw-string predicate). Citation chips open the player or the cited note.
  • Phase 7WaveformView density jump (5 → 24 bars, center-weighted envelope) + square red stop button matching the mockup.
  • Phase 8 — Live Activity scaffolding: RecordingActivityAttributes + LiveActivityController (start/update/end), wired into AudioRecordingManager. Manual step: add the Widget Extension target in Xcode for the Dynamic Island banner to render.
  • Phase 9 — Deletes the orphaned SimpleMainView, SimpleNoteDetailView, AISummaryEditorView, EnhancedNoteEditorView, MyNotesView, and NotesListView after Phases 3-4 superseded them. Plus their tests.
  • Phase 10 — Sample data carries backendSessionId so chat works in debug builds.

Each phase went through a review-and-fix cycle. Spec: docs/superpowers/specs/2026-05-12-gap-close-design.md. Per-phase plans in docs/superpowers/plans/.

Stats

  • iOS: 162 tests, sequential ~17s, no parallel-clone crash dialogs (the hex-arch fakes cut off the real WebSocket / NWPathMonitor that previously caused the test runs to hang).
  • API: 133 tests across 22 suites, 92.02% statements / 73.3% branches (gate is 70%).
  • Branch: 30 commits, +7770 / -3260.

Known follow-ons (not in this PR)

  • Auth tokens on live adapters. SessionsService and LiveChatAdapter both skip the Authorization header; the codebase relies on AUTH_ENABLED=false in dev. Separate effort touching all routes.
  • Widget Extension Xcode target for Live Activity. Cannot be created from code. Once added, the Dynamic Island banner starts working immediately because the in-app controller is already wired.
  • Per-run tap-to-seek inside AugmentedNoteView. AttributedString attributes for transcript timestamps are in place; wiring needs a UIViewRepresentable wrapping UITextView for per-run gestures.
  • SimpleArchiveView / SimpleSettingsView renames to ArchiveView / SettingsView. Cosmetic.

Test plan

  • cd src/mobile && xcodebuild test -scheme Muesli -destination "platform=iOS Simulator,name=iPhone 17,OS=26.1" -only-testing:MuesliTests -parallel-testing-enabled NO — full iOS suite green.
  • cd src/api && npm test — full API suite green; coverage above 70%.
  • Simulator smoke: launch app → conference-grouped sections → tap a sample talk → AugmentedNoteView renders → tap Listen → chaptered scrubber → tap a chapter row, playhead jumps → close → tap Ask → chat sheet opens with scope chip → type a question.
  • Conference smoke: tap a conference header row → ConferenceDetailView → "Chat with this conference" → chat sheet scoped to the conference.

🤖 Generated with Claude Code

T-rav and others added 30 commits May 12, 2026 16:29
Captures the architecture, scene-by-scene UI design, data model
changes, chat backend, salvage map, and testing strategy for closing
the gap between mockups/flow.html and the current iOS + API
implementation. Single-PR delivery shape with phased commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bite-sized TDD plan for adding Conference, ChatThread, ChatMessage
SwiftData entities, the Note.conference relationship, the Note.speaker
field, and the idempotent ConferenceMigration that backfills existing
notes. SampleDataManager refactored to seed two conferences.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a Conference SwiftData entity with name, location, date range,
and description metadata. Note gains a conference relationship and
a speaker field. conferenceName is retained for one release as a
fallback for the migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds client-side persistence for chat threads scoped to either a talk
(Note) or a conference. Messages cascade-delete with their thread.
Backend remains stateless; iOS retains conversation history.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
One-time idempotent migration that groups existing notes by their
conferenceName string (case-insensitive, whitespace-trimmed) and
attaches them to a find-or-created Conference. Backfills conference
startDate/endDate from note timestamps. Guarded by a UserDefaults
flag so it does not re-run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the PhotoMigration trigger pattern. Migration is gated by a
UserDefaults flag so it runs at most once per install.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refactors seedDatabase to build DataSummit 2026 (3 talks) and
DevWorld 2026 (2 talks) with location, date range, and descriptions.
Each conference talk includes a speaker. Two ungrouped notes are
retained for non-conference flows. clearAllData now deletes the new
ChatMessage/ChatThread/Conference entities in dependency order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Note model's duration field is TimeInterval? defaulting to nil
when no audio is attached. The test previously asserted == 0, which
matched neither the model nor the convention. Aligning the
expectation with the actual default closes a pre-existing failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds TranscriptionPort, NetworkPort, BlendPort, and ChatPort
protocols under Muesli/Ports/. Existing services conform via
no-op extensions (their method signatures already match the
port surface): TranscriptionService, NetworkMonitor (NWPath
monitor), and the SessionsService actor.

World.swift is the composition root. World.current defaults to
.live (real adapters); tests overwrite it in setUp to inject
fakes. ChatPort uses an UnimplementedChatAdapter placeholder
that throws ChatPortError.notImplemented until Phase 6 lands
the chat backend.

This is the seam tests use to avoid hitting real network and
the pattern future adapters (alternate transcription backends,
the chat client) will follow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds FakeTranscriptionAdapter, FakeNetworkAdapter, FakeBlendAdapter
in MuesliTests/Fakes/. Each records calls and exposes stub-control
fields so tests configure responses per scenario. TestWorld.install()
replaces World.current with a fully-faked World and returns the fakes
to the caller for stub setup and call inspection. TestWorld.restore()
re-installs .live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces direct TranscriptionService.shared / NetworkMonitor.shared
access in the four network-touching test files with calls through
World.current.transcription and World.current.network. Each test
struct installs fakes in init() via TestWorld.install() so the real
WebSocket / NWPathMonitor are never reached.

Sequential test execution previously hung against the non-existent
staging API; with the fakes in place the suite completes in under
two seconds. Concurrent-shape tests that were stress-testing a
singleton with mutable state have been converted to sequential
cycling against the fake — the same idempotency property without
the thread-safety noise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SimpleSummaryGenerator returned an empty string for transcripts with
no sentence at least 11 chars after splitting on punctuation. For a
single-word transcript like "Hello" the generator produced nothing,
which violated its contract (the test asserted the summary is
non-empty). Restructured so any non-empty transcript yields at least
a one-bullet summary plus the word-count + speaking-time block.

EnhancedNoteEditorViewTests expected "multiple words in a sentence"
to have 6 words; the correct count is 5. Aligned the test datum.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds HybridTranscriptionPort (separate from TranscriptionPort because
its file-transcription contract throws and returns non-optional
String). Adds FakeHybridTranscriptionAdapter for tests and threads it
through TestWorld.install.

Replaces the remaining production .shared reaches with World.current
port access:
  - DebugMenuView: TranscriptionService.shared → World.current.transcription
  - SimpleMainView: NetworkMonitor.shared.isConnected → World.current.network.isConnected
  - SimpleMainView + SimpleNoteDetailView + TranscriptionOrchestrator:
    HybridTranscriptionService.shared → World.current.hybridTranscription
  - BlendOrchestrator: SessionsService.shared → World.current.blend

Service-internal singleton access (TranscriptionService reading
NetworkMonitor.shared, AISummaryService reading NetworkMonitor.shared)
stays as live-adapter implementation detail — those callsites are
inside the adapters themselves, not at the boundary.

Drops the @mainactor annotation from World.current / World.live so
adapters whose isolation differs (the SessionsService actor) can be
composed and accessed from any context.

Full suite remains green (148 tests, ~2s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stateless chat endpoints (POST /v1/sessions/:id/chat and
POST /v1/chat for multi-session) with TDD steps for chatService,
citation post-processing, and the conference-scope token budget
heuristic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds chatService.js with a strict JSON contract (answer + parallel
references array) and a conference-scope context heuristic that
keeps full blends only for the 3 most recent sessions and degrades
older ones to title + speaker + summary.

Adds POST /v1/sessions/:id/chat for single-session chat and
POST /v1/chat for multi-session (conference-scope) chat where the
iOS client supplies sessionIds. Both routes:
- 200 with { message, citations, usage }
- 400 on missing messages (or sessionIds for multi-session)
- 404 when any sessionId is unknown
- 502 on Anthropic / parser failure

Citation references are post-processed: [[c:N]] tokens stripped
from the user-facing answer; transcript references get mm:ss labels
and note references get resolved titles; references that point to
sessions outside scope are dropped.

128 tests / 22 suites green; coverage 91.69% statements,
73.3% branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
API (chat backend):
- Add ownership check on POST /v1/sessions/:id/chat and POST /v1/chat
  (403 when the session belongs to another user).
- Apply transcriptionRateLimit (the spec-mandated 20/15min tier) to
  both chat routes.
- Cap multi-session /v1/chat at 50 sessionIds; 400 with too_many_sessions.
- Record cost entries (microsDelta=0 in v1) on both routes via
  sessionsRepo.recordCost for usage telemetry. Real money debits via
  ledgerService can come later once chat pricing lands.
- Replace fixed N=3 conference-scope heuristic with a real character
  budget (~150k tokens ≈ 600k chars). Oldest full sessions are
  progressively demoted to compact summary until under budget.
- Stop collapsing all whitespace in citation post-processing. The
  prior \\s+ → ' ' regex destroyed paragraph breaks and bullets.
  Now only the whitespace immediately around the [[c:N]] token is
  normalized.

API tests:
- Conference-scope test rewritten to actually verify the heuristic:
  asserts recent sessions render full bodies (Transcript: + blend)
  and older sessions render only compact summaries. Previous test
  asserted a substring present in both code paths.
- Add budget-demotion test: three oversized blends are demoted until
  total context fits under the budget.
- Add citation-paragraph-preserve test.
- Add integration tests for the new ownership 403 and too_many_sessions 400.

iOS:
- ConferenceMigration and PhotoMigration now set the "done" flag only
  when context.save() actually succeeds. Previously a save failure
  marked migration complete and prevented retry on next launch.
- Add Note.resolvedConferenceName helper that prefers the Conference
  relationship and falls back to conferenceName for the one-release
  transitional window. Phase 3 consumers should read through this.
- Annotate World.current as nonisolated(unsafe) with a comment
  documenting the write-only-from-test-setUp contract. Quiets the
  Swift 6 strict-concurrency warning while preserving the v1 access
  pattern.

133 API tests / 22 suites green (92% statements coverage);
148 iOS tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tasks for the flagship augmented-note display: pure-value renderer
that converts blendedMarkdown + parallel char-range arrays + photos
into a [BlendSegment] list, SlideCard component, and the view that
iterates the segments. Tap-to-seek wiring deferred to Phase 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BlendRenderer is a pure value renderer that converts a Note's
blendedMarkdown + parallel char-range arrays (userNoteSpans,
quoteSpans, imagePlacements, citations) plus its photos into a
[BlendSegment] list. Each segment is either .text(AttributedString)
with attribute overlays applied or a .photo(Photo, caption) card.

Indices are interpreted against the raw markdown source because the
blend service emits char offsets there; any markdown parser would
shift indices and break the overlays. Bold/italic from markdown
syntax is forfeited in v1; user-note highlighting (bold + accent
color), quote styling, and citation underlines are preserved.

Custom AttributedString attribute keys carry transcript timestamps
for later tap-to-seek wiring (Phase 5). Defensive against bad span
offsets (clamps + drops invalid ranges) and image placements
referencing photos no longer in note.photos.

SlideCard renders a Photo as a full-width image with OCR text and
optional blend-provided caption. Falls back to a placeholder when
the file is missing.

AugmentedNoteView iterates the segment list, draws an eyebrow
header (conference + speaker + date) above the title, and shows
blend-status-appropriate placeholders for not-yet-blended or
failed notes.

7 BlendRenderer unit tests green; no app callsite wired yet
(Phase 4 introduces MainView/ConferenceDetailView navigation that
pushes AugmentedNoteView).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ates

The blend service emits char offsets as UTF-16 code units (Node's
String.length), but the renderer was using Swift's grapheme-cluster
indexing. Every offset past a multi-unit character (emoji, ZWJ
sequence, combining mark) would drift, silently corrupting overlay
placement and image splits. Translation now goes through
String.UTF16View → String.Index → AttributedString.Index.

Defensive drops (out-of-range spans, unresolved photoIds) now log
via AppLogger instead of swallowing silently. Matches the spec's
"clamp and log, do not crash" rule.

AugmentedNoteView's .complete fallback no longer silently shows raw
transcript when blendedMarkdown is unexpectedly nil. It surfaces an
error card and logs the inconsistency so the corruption isn't hidden.

Custom AttributedString attribute keys conform to
CodableAttributedStringKey so the transcript-timestamp attributes
survive any future JSON round-trip rather than silently dropping.

New tests:
- UTF-16 offsets through "Hi 👋 there" (emoji surrogate pair)
- Overlapping userNoteSpans
- Photo at offset 0 (no leading text)
- Photo at end-of-string (no trailing text)
- Two photos at the same offset

12 BlendRenderer tests green (was 7).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Conference-grouped notes list + conference hero with talks list.
Wires AugmentedNoteView into the navigation stack and flips the
app root from SimpleMainView. Chat CTA is a placeholder until
Phase 6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MainView groups notes by Conference (most recently active first)
with an Other bucket for ungrouped notes. Each row pushes
AugmentedNoteView via NavigationStack value-based destinations.
Tapping a conference section header pushes ConferenceDetailView.

ConferenceDetailView shows name + location + date range +
description as a hero, then lists the conference's talks in
chronological order. dateRangeString uses explicit conference
dates when present, falls back to min/max of attached note
timestamps when missing, and collapses to a single date when
start == end. Chat with this conference button is a disabled
placeholder until Phase 6.

NoteRow renders title + (conference · speaker · relative date ·
slide count) — matches the mockup row shape and is reused by both
MainView and ConferenceDetailView.

MuesliApp.body flips from SimpleMainView to MainView. SimpleMainView
is now orphaned; Phase 9 deletes it after the salvage harvest.

7 new tests across MainView + ConferenceDetailView; 19 iOS tests
green for the renderer + new screens. Build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…date range

MainView: conference rows are now tappable list rows at the top of
each section instead of section headers (which strip gestures from
their content in List). A sibling @query<Conference> joins the data
so conferences with no unarchived notes still appear with a "No
active talks" subtitle, keeping them reachable when their notes are
archived.

ConferenceDetailView: filters notes by !isArchived to match
MainView. Archived talks are reachable from the archive screen.

dateRangeString fix: cross-year ranges no longer silently drop the
start year. Same-year ranges still show "MMM d – MMM d, yyyy";
cross-year ranges now show "MMM d, yyyy – MMM d, yyyy".

NoteRow: empty-string speaker no longer renders a stray dot; slide
count now uses max(photos.count, imagePaths.count) so legacy notes
without populated Photo rows still surface their slide count.

partition: secondary sort on conference.name when timestamps tie,
keeping order stable across renders.

Tests strengthened: assertions now check content (the year is in
the string, "Alpha" sorts before "Beta"), not just non-nil/non-empty.
New tests cover empty conferences appearing as sections, the stable
tiebreaker, the older-conference-second order, and the cross-year
date range path. 11 tests pass across the two new suites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure PlaybackTimer helpers + AVAudioPlayer-backed observable
controller + scrubber + view. Listen CTA in AugmentedNoteView
opens the sheet. Per-run tap-to-seek inside note body remains
deferred (would need a UIViewRepresentable for per-run gestures).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PlaybackTimer holds the pure helpers: ChapterModel value type,
decodeChapters from note.chaptersJSON (empty on missing/malformed
input), currentChapterIndex picking the last chapter whose start
<= time, and formatTime as mm:ss under one hour or h:mm:ss past
one hour. 7 unit tests cover the lot.

ChapteredPlaybackController is an @observable @mainactor wrapper
around AVAudioPlayer. Publishes currentTime / duration / isPlaying
so the view binds against player state. Exposes play / pause /
toggle / seek / skipChapter. A 0.25s timer polls AVAudioPlayer's
currentTime to drive the scrubber. The timer field carries a
nonisolated(unsafe) annotation so deinit can invalidate it cleanly.

ChapterScrubber draws a horizontal track with chapter-boundary
ticks and a draggable thumb. Drag updates a binding; the host
view commits via seek(to:).

ChapteredPlaybackView assembles the header (chapter index + title
+ speaker), the scrubber + time labels, the transport controls
(skip-back / play-pause / skip-forward), and a tappable chapter
list that jumps the playhead and resumes playback. Bails out with
an error card if the audio file can't be loaded.

AugmentedNoteView gains a Listen button in the toolbar that
presents this view (disabled when no audio file is attached).
Per-run tap-to-seek inside the note body remains deferred — the
AttributedString attribute keys carrying transcript timestamps
stay in place for that follow-on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ChapterScrubber rewritten so taps and drags actually move the
playhead. Previously the first onChanged event flipped isDragging
to true *and* wrote the new currentTime through a stale-read
binding; the host's set-closure observed isDragging as false and
silently dropped the seek. The scrubber now owns its drag state
internally and reports seeks via an onSeek callback. Visual thumb
position follows the drag value during the gesture so a fast
controller tick doesn't snap the bar.

ChapteredPlaybackView audio path resolution now mirrors SlideCard:
absolute paths bypass AudioRecordingManager.getRecordingURL (which
only handles relative filenames). The Listen sheet was previously
showing 'Audio file not found' for any callsite that stored a
fully-qualified path.

ChapteredPlaybackController.skipChapter now calls play() after
seeking. Matches the chapter-list row's tap behavior so the user
gets consistent playback-on-jump across all chapter-navigation
entry points.

Drop the Task hop inside the 0.25s polling Timer in favor of
MainActor.assumeIsolated — saves a Task allocation 4× per second.

Tests gained two decoder edge cases: empty ChaptersWrapper array
and missing-summary chapter (DTO field is optional). 9 cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LiveChatAdapter (ChatPort), ChatViewModel for thread state +
send loop, CitationChip + ChatView. Wires from ConferenceDetailView
(replaces the disabled placeholder) and AugmentedNoteView (new
Ask button). v1 reuses Note.id as the backend session ID.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LiveChatAdapter implements ChatPort over URLSession. Talk scope
POSTs to /v1/sessions/:id/chat; conference scope resolves session
membership via a closure and POSTs to /v1/chat. Adapter throws
ChatAdapterError.http on non-2xx so callers can surface failures.

ChatViewModel @observable owns one ChatThread's send loop: append
the user message (optimistic), call ChatPort, persist the assistant
turn with citations on success, roll back the user message on
failure. Whitespace-only input is a no-op so the chat doesn't fire
empty turns.

ChatView is the modal sheet: scope chip + scrolling thread of
bubbles + citation chips below assistant messages + text input
with a send button that disables while in-flight. Auto-scrolls to
the latest message. CitationChip is a pill with mm:ss for
transcript citations and the note title for note citations.

ConferenceDetailView's "Chat with this conference" button is no
longer a disabled placeholder — it finds-or-creates a ChatThread
for the conference and presents ChatView.

AugmentedNoteView gains an "Ask" toolbar button doing the same
for talk scope.

World.live now composes LiveChatAdapter (replacing the
UnimplementedChatAdapter placeholder). Default sessionIdsResolver
returns empty; future per-conference resolver injection from
SwiftData is a small follow-on so the conference chat shipping
without a member list is the known v1 limitation.

8 tests across LiveChatAdapter (URLProtocol-stubbed) and
ChatViewModel (in-memory SwiftData with port stubs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nd-to-end

The review surfaced four bugs that would each independently break chat in production:

1. Wrong base URL. The adapter used APIConfiguration.transcriptionAPIBaseURL
   which already includes the /api/v1 prefix, then appended /v1/sessions
   itself — producing /api/v1/v1/sessions/... and 404. SessionsService
   uses APIConfig.baseURL (bare host, typealias for APIConfiguration);
   World.live now does the same. Routes are at /v1/sessions/:id/chat
   and /v1/chat per server.js, no /api prefix.

2. Note.id was being passed as the scopeId for talk-scope chat, but the
   backend's session UUID is assigned by sessionsRepo.createSession and
   was never persisted on the Note. Talk-scope chat would always 404.
   Adds Note.backendSessionId; BlendOrchestrator now writes it back on
   session creation. ChatViewModel.resolveSessionIds() prefers the
   backend ID and falls back to scopeId for back-compat.

3. Conference scope never resolved its session list. The default
   LiveChatAdapter resolver returns []. ChatViewModel now fetches the
   conference's notes from SwiftData, maps to backendSessionId, and
   passes via the explicit-resolver variant of LiveChatAdapter.send.

4. Find-or-create predicates used raw string literals "talk" /
   "conference". Renaming a ChatScopeKind case would silently desync.
   Predicates now use locally-bound let constants so the Predicate
   macro can evaluate them while keeping the scope kind as the source
   of truth.

Auth header is intentionally NOT added — SessionsService matches that
convention; both rely on the backend's requireAuth middleware being
disabled in dev (AUTH_ENABLED=false). Wiring access tokens to live
adapters is a separate follow-on across all routes.

Other follow-on fixes from the review:

5. CitationChip taps are no longer no-ops. ChatView presents
   ChapteredPlaybackView(note:startAt:) for transcript citations and
   pushes AugmentedNoteView for note citations, resolving the
   referenced UUID through Note.id or Note.backendSessionId.

6. ChatViewModel.send no longer manually appends to thread.messages.
   SwiftData's inverse relationship populates the array when the new
   ChatMessage is inserted with thread set; the manual append was a
   duplicate-after-save risk.

8 tests across LiveChatAdapter + ChatViewModel still green after the
refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tight plan for the four smaller remaining phases:
- 7: WaveformView density + recording-button polish
- 8: Live Activity scaffolding (ActivityAttributes + controller +
  Info.plist background mode); widget extension target still needs
  to be added in Xcode by hand
- 9: Delete the orphaned views replaced earlier in this branch
- 10: Sample data carries backendSessionId so chat works in dev

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 7. WaveformView jumps from 5 bars to 24 with a per-bar
center-weighted height envelope and shorter (0.06s) refresh cadence
so the wave looks like the mockup. Bars use Color.accentColor so the
visualization adapts to the system theme.

The recording controls' End text button becomes a square red
stop.fill icon — matches Scene ii of the mockup.

Phase 8. Live Activity scaffolding:
- RecordingActivityAttributes (ActivityKit) lives in
  Muesli/LiveActivity/ and is the type the Widget Extension target
  consumes once it's added.
- LiveActivityController is the @mainactor wrapper around
  Activity<RecordingActivityAttributes>. start/update/end. No-ops
  cleanly when ActivityAuthorizationInfo().areActivitiesEnabled
  returns false (which it does until a widget extension target is
  added in Xcode).
- AudioRecordingManager calls start() on record success and end()
  on stopRecording. Both guarded by #available(iOS 16.2, *).

Widget Extension target setup is documented at the top of
RecordingActivityAttributes.swift — it must be added once in Xcode
(File → New → Target → Widget Extension); it can't be created from
code. Until then the controller no-ops and recording works the same
as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
T-rav and others added 5 commits May 12, 2026 20:38
Phase 9. Delete views replaced earlier in this branch:
- SimpleMainView (MainView replaced it in Phase 4)
- SimpleNoteDetailView (AugmentedNoteView replaced it in Phase 3)
- AISummaryEditorView (no consumer; salvage was deferred-to-later)
- EnhancedNoteEditorView (no consumer)
- MyNotesView (no consumer)
- NotesListView (component depended on SimpleNoteCard)
- Their tests: AISummaryEditorViewTests, EnhancedNoteEditorViewTests,
  SimpleMainViewFallbackTests

SimpleArchiveView's sheet body switched from SimpleNoteDetailView to
AugmentedNoteView via a wrapping NavigationStack so the archive
screen still pushes the augmented note detail.

The SimpleArchiveView and SimpleSettingsView renames to
ArchiveView/SettingsView from the spec are deferred — they're
cosmetic and out of scope for this PR.

Phase 10. SampleDataManager seeds every Note with backendSessionId =
note.id so chat against a backend with matching seed rows works
without a real blend round-trip. Production notes get this from
BlendOrchestrator after the upload/blend cycle.

iOS test suite (sequential) green after the deletions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…l + Live Activity update

Final cross-branch review surfaced three small follow-ups worth
landing on the branch rather than as separate issues:

1. AugmentedNoteView's Ask button is disabled when
   note.backendSessionId is nil. Before the blend pipeline writes
   that field the backend has no session row; without the gate the
   user would see "Chat request failed (HTTP 404)" during the blend
   window. Mirrors the existing Listen-button audio gate.

2. NoteRow shows an inline ProgressView next to the title while
   blendStatus is in flight (.transcribing / .extracting / .blending /
   .transcribed). Scene v of the spec wanted a more prominent
   in-flight indicator than the augmented-view fallback; this
   surfaces it directly on the list.

3. LiveActivityController.update is now actually called: the
   AudioRecordingManager duration timer pushes elapsed-seconds +
   paused state once per second so the Dynamic Island banner
   reflects pause/resume. No-op until the widget extension target
   is added in Xcode.

Also adds a TODO comment at the LiveActivity start callsite
flagging that the temporary fresh UUID should be the eventual
backend session ID once the recording flow threads it through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…k, lint

After opening the PR, the final-review feedback surfaced four
follow-ons that are small enough to land on the same branch:

1. Extract BlendingOverlay component (Scene v of the spec). The
   in-view fallback in AugmentedNoteView was the only Scene v
   surface; it now uses a reusable BlendingOverlay with inline +
   fullScreen styles and per-status icons / labels. NoteRow already
   shows the in-flight spinner from the prior commit.

2. Rename SimpleArchiveView → ArchiveView and SimpleSettingsView →
   SettingsView (git mv preserves history). No callers outside the
   debug menu reference them.

3. Per-run tap-to-seek inside AugmentedNoteView text. SwiftUI Text
   exposes no per-run gesture hook, so a new UIViewRepresentable
   (TappableAttributedText) wraps UITextView. BlendRenderer gains
   a tapTargets(in:) helper that walks the AttributedString runs
   and returns NSRanges for quoteSpans and citations. When a target
   is hit, the tap opens ChapteredPlaybackView seeked to the
   transcript timestamp. Two new BlendRenderer tests cover the
   target extraction.

4. SwiftLint autofix across the project (number separators, comma
   spacing, vertical whitespace, etc.). 465 → 284 violations; 21
   remaining are pre-existing serious findings across files this
   branch doesn't own.

Also fixes a pre-existing contradictory assertion in
SampleDataTests.extractPersonalNotesFromContent that exercised
both #expect(personalNotes.isEmpty) AND the
hasActionContent-or-empty branch. The new sample transcript
includes "Action items:" which the extractor surfaces; the test
now allows either shape.

164 iOS tests green; build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. Auth tokens on live adapters

Adds TokenStore — an actor with the current access + refresh tokens,
defaults to UserDefaults, picks up MUESLI_DEV_ACCESS_TOKEN from the
environment so a developer can paste a token in their scheme env vars
to test against an AUTH_ENABLED backend.

SessionsService.authorize(_:) injects Authorization: Bearer <token>
into every outbound request (createSession, uploadAudio,
uploadMultipart for photos, runBlend). LiveChatAdapter does the same
before POST /v1/sessions/:id/chat and POST /v1/chat.

The /v1/auth/google sign-in flow that mints these tokens stays a
separate piece of work; the wiring on this side is ready.

2. Widget Extension target for the Live Activity

Adds the MuesliRecordingLiveActivity Widget Extension target to the
Xcode project so the Dynamic Island banner actually renders.

- New target sources live under src/mobile/MuesliRecordingLiveActivity/:
  the WidgetBundle, the ActivityConfiguration for
  RecordingActivityAttributes, and the Info.plist that declares
  NSExtensionPointIdentifier + NSSupportsLiveActivities.
- The target is added to the pbxproj via scripts/add-live-activity-target.rb,
  an idempotent Ruby script using the xcodeproj gem. It creates the
  target, wires PRODUCT_NAME/PRODUCT_BUNDLE_IDENTIFIER/INFOPLIST_FILE,
  adds the sources, shares RecordingActivityAttributes.swift with the
  extension, embeds the extension in the main app's "Embed App
  Extensions" build phase, and sets the main app's
  NSSupportsLiveActivities + UIBackgroundModes=audio.
- I ran the script; the pbxproj diff is committed alongside the new
  files. Re-running the script is a no-op.

Main app + extension build clean end-to-end. The Live Activity is now
live: LiveActivityController.start/update/end will actually surface
the banner.

3. Sendable annotations on cross-actor types

BlendPort.uploadPhoto previously took the Photo @model directly, which
isn't safe to cross actor boundaries under Swift 6 strict concurrency.
New value-type PhotoUpload carries the same data as Sendable; the
SessionsService actor + FakeBlendAdapter implement against it.
BlendOrchestrator builds the DTO on the main actor before crossing
into the SessionsService actor.

164 iOS tests green; main + extension build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend
-------

POST /v1/auth/dev mints access + refresh tokens for an arbitrary email
without going through Google's OAuth flow. Disabled in production
(returns 404). Reuses the same envelope shape as /v1/auth/google so
the iOS side has one decoder for both. User row is upserted with a
synthetic `dev:<email>` googleSub so the existing unique constraint
and signup-grant logic still works.

Two new tests in authFlow.test.js: happy path mints a token that
unlocks /v1/auth/me, and invalid emails return 400 email_invalid.
13 auth tests green.

iOS
---

AuthService (actor) wraps the auth surface: signInDev, signOut,
refreshAccessToken, isSignedIn. Tokens land in TokenStore which the
live adapters already read from.

SignInView is a modal screen with email + optional display name +
a Sign in button. Wired into MainView via .task: if TokenStore has
no access token at launch, the sheet appears with interactive
dismiss disabled. After a successful sign-in the sheet closes and
the app continues normally.

Auto-refresh on 401: both SessionsService.dataWithRefresh and
LiveChatAdapter.dataWithRefresh now retry once after calling
AuthService.refreshAccessToken() when the first attempt returns 401.
Covers token expiry without bouncing the user back to the sign-in
sheet for every backend round-trip. If refresh itself fails, the
original 401 surfaces so callers can react.

164 iOS tests green; auth flow round-trip works against an
AUTH_ENABLED backend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@HydraOps-T-rav HydraOps-T-rav merged commit b0c6060 into main May 13, 2026
6 checks passed
@HydraOps-T-rav HydraOps-T-rav deleted the feat/gap-close branch May 13, 2026 04:55
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.

2 participants