Skip to content

Add Settings item to the app menu with Cmd+,#125

Closed
statico wants to merge 316 commits into
matthartman:mainfrom
statico:ian/app-menu-preferences
Closed

Add Settings item to the app menu with Cmd+,#125
statico wants to merge 316 commits into
matthartman:mainfrom
statico:ian/app-menu-preferences

Conversation

@statico

@statico statico commented May 27, 2026

Copy link
Copy Markdown

Made with Claude Code.

Adds a standard "Settings…" item to the application menu (bound to Cmd+,) via SwiftUI .commands, with a gearshape icon for macOS Tahoe. Settings was previously only reachable from the menu bar dropdown, which can be hidden.

obra and others added 30 commits March 26, 2026 16:14
Add transcription lab and upgrade fast cleanup model
matthartman and others added 28 commits April 28, 2026 22:15
…dexSheet

Plus-menu now offers "New People index" alongside personal note / ad-hoc /
Granola import. Selecting it opens BuildIndexSheet:
- estimating phase shows a spinner while count_tokens runs;
- ready phase shows "≈ \$X — N meetings — ~M tokens, could be up to 3x";
- building phase shows running cost and entries-written count, with Stop;
- completed/failed phases close cleanly.

The sidebar grows an "Indexes" section above the date groups, listing one
entry per dossier (with the configured kind icon — person.3 for People).
Clicking opens the entry as a new MeetingSurface case (`.indexEntry`).
IndexEntryView renders the dossier body with `[[Wikilink]]` cross-refs as
tappable buttons that open the linked entry, plus a "Source meetings"
section with quick-jump links back to the original markdown.

State plumbing:
- MeetingWindowState.indexItems (per-kind lists) loaded from disk on
  appear, on .indexUpdated, and after the build sheet closes.
- AppState owns the lazy IndexBuilder per kind; the controller's
  onMakeIndexBuilder bridges into the window state. Missing API key
  surfaces a small inline view pointing to Settings.
- AppState.finishMeetingSession now triggers People index incremental
  updates after a recording stops, but only if an index already exists
  on disk and an API key is configured.

Search filters indexes the same way it filters meetings.

Wikilinks parse via a tiny WikilinkParser that splits the body into
.text / .wikilink segments and renders each appropriately. Plain text
goes through AttributedString(markdown:) for inline formatting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
People (167) collapses by default; click chevron to expand. Search auto-
expands all matching folders. Avoids drowning the sidebar in entries on
large archives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each opened dossier becomes its own OpenIndexTab in the file tab bar with
the canonical name as the tab title and the kind icon as the leading
glyph. Closing the tab is the standard X. Re-opening an already-open
entry switches to its existing tab rather than reloading.

Refactor: MeetingSurface.indexEntry(kind, slug) → .indexTab(UUID); the
single activeIndexEntry slot is replaced by an indexTabs array so
multiple dossiers can be open simultaneously and switched between like
meetings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Click a [[wikilink]] or source-meeting link → the current tab navigates
to the target (replaces content, pushes prior content onto a per-tab
history). Right-click the link → "Open in new tab" creates a sibling.
Cmd+[ goes back; the back button is disabled with no history.

Implementation: OpenIndexTab now holds a NavTabContent enum (either an
index-entry payload or a meeting payload, both wrappable for read-only
display) plus a navigation stack. NavTabContentView renders a small
back-bar above the content and dispatches to IndexEntryView or
MeetingTabContentView depending on the current case.

External URLs are unchanged; only in-app cross-refs gain this behavior.
Meeting tabs opened from the regular sidebar still use OpenMeetingTab
with full recording-capable state.

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

Three feature areas land together:

1. Agentic cross-meeting Q&A — provider-neutral LLMProvider protocol, an
   AnthropicProvider that streams over the Messages API, a MeetingQAAgent
   that runs a tool-use loop with grep / read_file / list_dir over the
   meeting archive, sandboxed by PathSandbox. UI surfaces the agent's
   trace (status line, expandable trace pane, Stop button, running cost).
   Drops the obsolete local-QA backend; agentic Q&A is Anthropic-only.

2. Today's calendar list + currentMeeting hardening — eventsForToday()
   with multi-calendar support, 60s memory cache + disk-backed offline
   fallback, OAuth scope upgrade (calendar.readonly) with primary-only
   fallback for legacy connections; a hardened currentMeeting() that
   filters all-day, declined, and ended events; smarter join-link
   extraction. MeetingAttendee gains a declined flag surfaced with
   strikethrough; applyCalendarEvent locks user-picked events.

3. People index — generated dossier folders alongside meetings. Plus-menu
   offers New People index; BuildIndexSheet shows an estimate then
   streams progress with running cost and Stop. Index agent walks the
   archive and writes one dossier per canonical person to
   .indexes/people/<slug>.md with YAML frontmatter (canonical_name,
   aliases, source_meetings, last_updated). _manifest.json tracks
   processed meetings for incremental updates on .meetingRecordingStopped.
   Sidebar gains a collapsible People (N) folder; entries open as real
   tabs. In-tab links navigate browser-style: click navigates in place
   (back button + Cmd+[), right-click for Open in new tab.

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

The 30-iteration cap was the limiting factor on full archive coverage —
787 meetings need hundreds of tool calls and the agent ran out of rope.
Bumped to 200 iterations; cost mostly compounds via prompt caching past
that point so the new ceiling is still cost-bounded but no longer the
binding constraint for typical archive sizes.

Also persist the manifest after every successful write_file so hitting
Stop mid-build no longer loses dedupe state. Partial manifests don't
mark meetings as processed (only completed builds do that), so a
resumption sees a manifest present, reads existing entries via the
on-disk .md files, and appends rather than recreating.

Updated full-build prompt with a new Step 1: list_dir the index dir
first, read_file any existing entry before writing, preserve existing
body. Makes resumption non-destructive.

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

The old estimate used count_tokens on a single iteration, ignoring the
fact that each agent iteration appends prior tool results to context.
For a 787-meeting archive it was off by ~100x. Replaced with a
per-meeting heuristic, calibrated against observed runs:

  Haiku 4.5: $0.002–$0.006 per meeting
  Sonnet 4.6: $0.005–$0.018 per meeting
  Opus 4.7: $0.025–$0.075 per meeting

The dialog now shows a range ("Likely $X–$Y") with a clear note that
it's order-of-magnitude and the running cost during the build is
authoritative. Estimate is also faster to render — no API call needed.

Detect existing entries on disk and surface a "resume" banner: the
button label flips to "Resume" and the copy explains that the agent
will read each existing dossier before writing, so re-running on an
already-built index appends and updates rather than starting over.
Pairs with the prompt change in the prior commit which instructs the
agent to list_dir + read_file existing entries before writing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the full-build flow handed the agent every meeting in the
archive on every run, relying on the prompt to make it append rather
than recreate. Wasteful: cost paid twice for already-covered ground.

Now we filter. coveredMeetings(in:kind:) walks every existing entry's
source_meetings and returns the union — meetings that show up in *any*
entry are considered done. The unprocessed list is what gets passed to
the agent, so resume genuinely skips covered work.

Estimate uses the unprocessed count, not the archive size, so the cost
range scales down on resume. The dialog explains what's happening:
"Resuming existing index: 39 entries on disk, 187 of 787 meetings
already covered. This run will only process the remaining 600."

Building view gains a progress bar driven by a new
.meetingsProcessed(processed, total) event. After each successful
write_file the builder re-reads the entry, picks up its source_meetings,
and emits an updated count. You can watch it advance toward 787.

Edge case: if every meeting is already covered, we skip the agent
entirely, show a green "Index is up to date" banner, and the only
button is "Done".

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

Three related UI moves:

1. **Move People index trigger off the + menu and onto the home view.** It
   now lives next to the Granola sync row with the same status semantics:
   - "Build People index from N meetings" (no entries yet)
   - "Sync N new into People index" (entries exist, some meetings unprocessed)
   - "People index up to date" with a re-sync icon (everything covered)
   Refresh on app focus, .meetingRecordingStopped, and .indexUpdated.

2. **Sidebar 'People (N)' becomes a single-click row.** Drops the
   expandable disclosure (which would have been unwieldy at hundreds of
   entries — earlier instinct confirmed). Click → opens an IndexListView
   tab.

3. **New IndexListView surface.** Searchable, scrollable list of every
   entry in the kind. Click a name → navigates the *current tab* to that
   dossier (browser-style, back works). Right-click → "Open in new tab".
   Empty state has a "Build" CTA.

Plumbing: NavTabContent gains an .indexList(kind) case;
state.openIndexList(kind:) creates or focuses the tab. The "+" menu
loses its New People index entry — it was redundant once the home row
exists.

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

Previously the per-entry refresh would have run a silent agent and
overwritten the dossier. Better: the ↻ button now types
"Look up what we know about <name> and augment their People page." into
the bottom Q&A bar and fires it. Same agent infrastructure (Q&A trace,
streaming, Stop button), but the user can see the search happening,
learn what kinds of prompts work, and decide what to do with the answer.

Implementation: a new MeetingWindowState.pendingQAPrompt that deep views
publish into; MeetingRootView observes it on .onChange, fills qaQuestion
and triggers askAcrossMeetings, then clears the slot.

Future: a follow-up "Apply changes to dossier" button could surface
after the agent finishes, with confirmation, to actually write back to
the .md file. v1 leaves that to the user — they can manually edit the
markdown if they want to fold the answer in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the ↻ button on a dossier kicks off a Q&A run, remember the target
(kind/slug/canonicalName) in state.pendingDossierApply. After the run
finishes, an orange "Apply to <name>'s dossier" button surfaces between
the streamed answer and the input row, with a Discard option. Click
Apply → take the trimmed answer, write it as the entry's new body,
update last_updated, and live-update any open tab showing that entry.

The pending apply is cleared whenever the user manually submits a
different question, hits the X to clear the Q&A bar, or clicks Discard
— so a stray Apply button never targets the wrong dossier.

Frontmatter (canonical_name, aliases, source_meetings) stays as-is. We
don't try to parse new aliases or sources out of free-form prose. The
prompt sent to the agent now asks for a thorough markdown dossier with
sections + wikilinks, so the answer is shaped like dossier content.

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

The visible Q&A prompt becomes "Tell me about <Name>" — short, clear,
educational; the user could have written it themselves. The merge
instructions (preserve existing body, fold in new info, no frontmatter,
use sections + wikilinks) are embedded in the *Apply* step instead, as
a separate single-shot LLM call that has access to the existing
dossier body.

Apply now:
1. Reads the existing dossier from disk
2. Calls IndexBuilder.mergeDossierBody — system prompt = merge rules,
   user message = existing body + new findings, no tools, prose-only
   output
3. Writes the merged body back; updates last_updated; live-refreshes
   any open tab showing the entry

Button label is "Apply to <slug>.md" so the user sees exactly which
file they're touching. While merging it shows "Merging into <slug>.md…"
with a spinner; both buttons disable during the merge call.

Cost: one extra small LLM call per Apply (~few cents on Sonnet, ~1
cent on Haiku). User can Discard to back out before the call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apply was only updating body + lastUpdated. Now scans both the Q&A
answer and the merged body for `YYYY-MM-DD/<slug>.md` patterns and folds
any newly-cited meetings into the entry's source_meetings frontmatter
(union with existing, sorted). Tolerates trailing `:line` numbers from
Q&A-style citations.

Aliases stay as-is — extracting those reliably from prose needs more
care than a regex.

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

The dossier body was being rendered with inline-only AttributedString,
so `## About` showed as literal hashes, bullets as literal dashes, and
wikilinks rendered on their own line because each segment was a separate
View in a vertical stack.

Replaced with a small block-level markdown parser (MarkdownBlockParser)
that splits the body into headings, paragraphs, bullet lists, and fenced
code blocks. Each block renders with appropriate fonts and spacing.

Within each block, [[Wikilinks]] are rewritten to `wikilink://<slug>`
links and rendered via AttributedString — so they stay inline with the
surrounding text and word-wrap naturally. Taps are intercepted via an
`.environment(\.openURL, ...)` override on the view root, which routes
to onOpenEntry. Right-click → "Open in new tab" on individual wikilinks
isn't preserved (links inside Text don't support context menus); the
right-click affordance still exists on the People list and on
source-meeting links.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SwiftUI's Text(AttributedString) doesn't support per-link context menus,
so right-click on a wikilink inside a dossier body wasn't surfacing the
"Open in new tab" affordance after the markdown render rewrite. Use the
macOS browser convention instead: Cmd+click → open in new tab; plain
click → navigate in place. Modifier check happens inside the openURL
interceptor via NSApp.currentEvent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "X meetings to process using Sonnet 4.6" line is now an inline
Picker — Opus 4.7 / Sonnet 4.6 / Haiku 4.5 — bound to the same
@AppStorage("claudeAPIModel") that drives Settings → Cross-Meeting Q&A.
Cost range recalculates instantly on change (heuristic only, no API
call needed).

AppState's index-builder cache is now keyed on the active model, so
calling makeIndexBuilder after the picker changes the model returns a
freshly-constructed builder with the new provider rather than a stale
cached one.

The sheet fetches the builder on demand at click time via a
`fetchBuilder` closure rather than receiving it once at sheet open.
This way the picker's current model selection is honored without any
explicit "recreate builder" plumbing.

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

Every dossier now carries a `generation:` block recording how its body
was produced — Claude API model, named prompt template, SHA-256 prefix
of the actual prompt content, and timestamp. Stamped automatically by
IndexBuilder after every successful write_file (full-build, incremental
update) and by the merge call inside Apply. Stored as flat YAML fields
in the entry's frontmatter (`generated_by_model`, `generated_by_prompt`,
`generated_by_hash`, `generated_at`) so it survives external editing
without breaking the parser.

In the entry view, the bare ↻ refresh icon becomes a small grey pill:
"Sonnet 4.6 generated ↻". Clicking still kicks off the same Q&A-driven
refresh; hovering shows full provenance (date, prompt kind, hash) as a
tooltip. Falls back to a plain icon for entries without provenance
(pre-existing dossiers from before this commit).

The hash is the first 12 hex chars of SHA-256(prompt). When we change a
prompt, the hash changes — so we can tell at a glance which prompt
revision wrote a given entry, and find stale ones if needed.

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

The bottom Q&A bar is now a thread, not a single answer. State moves
from a flat (qaAnswer, qaUsage) pair to qaThread: [QATurn] — each turn
holds its own question, streaming answer, usage, and isStreaming flag.
The thread renders top-to-bottom with the active streaming turn at the
bottom; the scrollview auto-pins to the latest answer so follow-ups
stay in view.

Each new question carries the prior thread as conversation history. The
agent's loop now accepts pre-built [LLMMessage] (alternating user/
assistant) so prior turns become context — "tell me about Ryan" →
"what fund does he run" now resolves "he" correctly.

Inline picker on the input row, bound to @AppStorage("claudeAPIModel")
so it shares the setting with the build sheet and Settings. Model
choice applies on the next send (the existing run keeps its model).

The X clear icon becomes a labeled "+ New" button — visible only when
the thread is non-empty, with a tooltip explaining what it does. Cancels
any in-flight stream and resets all Q&A state including
pendingDossierApply.

Apply-to-dossier now uses the *latest* turn's answer, since that's
typically the refined follow-up the user actually wants to merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cmd+K opens a search sheet that filters across all three content types
in real time. Results are grouped under labeled sections (PEOPLE, MEETINGS,
NOTES) with the kind icon and a count. Click → opens the selected item
in the current tab; Enter → activates the first match; Esc → dismisses.

People are matched against canonical_name; meetings/notes against
filename + date folder. Notes are split out from meetings by the
quick-note filename prefix. Each section is capped at 12 to keep the
palette tight.

Implementation: a new CommandKSearchSheet view + a hidden Button at the
root attached to .keyboardShortcut("k", modifiers: .command). The
keyboard binding fires whether or not the search field is focused; once
the sheet appears, the field auto-focuses for typing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
↑/↓ moves a highlight through the flattened result list (people, then
meetings, then notes); the highlighted row gets an orange background and
a tiny ↩ glyph at the right edge. Enter activates the highlighted item
instead of always firing the first match. Hover also moves the
highlight, so mouse and keyboard users stay in sync. The selection
auto-scrolls into view.

Inline key hints (↑↓ ⏎) live in the search field's right side, next to
ESC.

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


Typing felt slow because the palette's results were a computed property
re-evaluated on every body access. With ~1000 candidates (people +
meetings + notes), each keystroke triggered ~5 full scans plus a fresh
batch of CommandKItem closure allocations. SwiftUI also re-rendered the
view tree multiple times per keystroke.

Two changes:

1. Snapshot a flat haystack at sheet-open time, with each row's title
   pre-lowercased so per-keystroke filtering is a tight String.contains
   loop with no dictionary traversal or repeated lowercasing.
2. Cache the filtered results and flat list in @State, recomputed only
   when the query actually changes (.onChange). All result reads in
   body now hit cached values; no recompute per access.

The haystack snapshot is taken on .onAppear, so it doesn't observe live
updates to indexItems / historyGroups during the sheet's lifetime —
fine for a transient palette; if the user dismisses + reopens, they
get a fresh snapshot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors browser convention: Cmd+N → fresh tab. Wires the existing
state.startNewNote() to a hidden Button at the root with
.keyboardShortcut("n", modifiers: .command). Same hidden-button pattern
as the Cmd+K palette trigger.

Note tabs don't start recording — they're just a markdown editor for
quick capture, same as picking "New personal note" from the + menu.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "What Ghost Pepper does" section in the right Models pane is now
live pickers for Speech-to-text, Cleanup, and Agent. Each picker is
filtered to choices the user can actually use:

- Speech-to-text: only speech models the user has downloaded
- Cleanup: only downloaded cleanup models (Meeting summary inherits
  this — labeled "(same as Cleanup)" rather than getting its own
  picker, since the underlying setting is shared)
- Agent: Claude models, only when the API key is set

When a section has no usable choices — e.g. no speech models downloaded
yet, or no Claude key — the picker is replaced with a small hint
pointing to the right Settings panel ("Download a speech model in
Settings → Models", etc.) so the path to fix is obvious.

Selection writes through @AppStorage to the same key the rest of the
app reads, so changing the model here also changes it for the build
sheet, the Q&A bar, and incremental updates. Diarization stays a
status row since it's a property of the chosen speech model, not an
independent selection.

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

Models panel (right sidebar):
- Inline download/delete buttons per local model
- Cancel-on-download for llama.cpp-served cleanup models
- DeepSeek R1 Distill Qwen 7B added to the cleanup catalog

Usage report (new right-sidebar tab, segmented picker switches with Models):
- UsageStatsStore tracks dictation, meetings, granola, people-pages, Q&A
- One-time backfill from disk uses dated folder names (not filesystem
  ctime, which collapses years of meetings into a single day after restores)
- 7d / 30d / lifetime window picker drives chart, totals, and prompt
- Bar chart + per-row recent/lifetime breakdown
- "Generate feature requests" via the selected cleanup model with a
  pre-ranked prompt so small (0.8B) models can't invert most/least

Granola sync:
- Auto-imports on sheet open (local cache first, then API if key set)
- v6 encrypted cache detected; explicit "use API key" pivot button
- "Sync Granola" button replaces the hung "Checking Granola..." state
- NotificationCenter .granolaImported drives the usage counter

Naming + chrome:
- Menu bar dropdown entry "Meetings..." -> "IDE..."
- README footer (factorialcap.com + @matthartman)
- Privacy table row updated to reflect local-only usage counters

Bundles in pre-existing in-flight work: agent backend selector
(cloud vs local llama.cpp + Qwen tool-call parser), Reader capture,
QA answer/attachment UI, meeting attendee + transcript polish.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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.

8 participants