fix(desktop): one navigation, dismissal, delete and copy vocabulary across the macOS app (INV-UI-2)
Status (2026-09-24): merged as ba1f71d03c after reconciliation onto #17281 (8d8984bac8). CI on head f8041ec774: 31 pass / 11 skipped. Not in a desktop release yet.
Summary
A UI/UX consistency pass over the shipped macOS app, plus the infrastructure that keeps it from drifting back.
The audit found the same interaction built many different ways. The worst cases:
- about 11 back controls and 6 close buttons, including an xmark on the Transcript (which replaces the page) beside a "‹ Back" on the page it replaced;
- Esc jumping straight to Chat from inside a conversation, memory or transcript;
- four delete policies, one of them an undo toast whose X deleted immediately;
- nine date formats, none of them locale-aware;
- raw
SPEAKER_00labels in the Participants line; - Activity and Conversations showing different durations for the same conversation.
This PR adds one shared component per interaction, migrates callers onto them, writes the rules down, and locks them with a ratchet.
Design infrastructure (so the next change doesn't reinvent this)
-
Contract:
desktop/macos/docs/ux-contract.md. It gives each rule and the component that implements it: leaving a surface, Esc, controls, destructive actions, feedback timing, dates, speakers, type/radius, words, page chrome. The rootAGENTS.mdroutes macOS UI work to it. -
Shared components (
MainWindow/Components/):- navigation and dismissal:
BackChip,DismissButton(moved out ofAppsPage.swift),ClearFieldButton - controls:
OmiIconButton,OmiIconMenu,CopyButton,OmiClipboard - feedback and prompts:
UndoToast,OmiToastCenter,TextPromptSheet,ConversationRowPrompts - formatting:
OmiDateFormat,SpeakerLabelFormatter - pointer:
pointingHandOnHover
- navigation and dismissal:
-
Guard —
INV-UI-2(product/invariants/desktop-ux-contract.md)..github/scripts/check_desktop_ux_contract.pyis a per-file, no-increase ratchet against the merge base. It covers 10 rules:- hand-rolled back/close glyphs
.alert- raw pasteboard writes
- raw
NSCursorpushes dateFormatstrings.system(size:)fonts- literal font sizes
- literal corner radii
- "..." in UI copy
Existing debt may stay; a file may not grow more, and new files start at zero. Deliberate exceptions carry
// omi-ux-allow: <rule> -- <reason>.test_check_desktop_ux_contract.pyproves each rule fails on the defect it was written for. Both are wired intochecks-manifest.yaml. The PR rule does not require naming INV-UI-2 in routine UI PRs. -
Tests:
DesktopUXContractTestscovers:- date styles, pinned to a calendar and locale
- 1-based speaker labels, matching mobile, with no raw labels in participants or copies
- Settings Back and Esc returning to the page that opened Settings
- the empty-copy guard
- the icon-button fill ladder
- Activity/list duration parity
- a tripwire that every Settings search result has a card to scroll to
Fixes, by area
Leaving a surface and Esc
- The Transcript leaves with
‹ Summaryon the leading edge and keeps the conversation title in its header. The count badge says "388 segments". - Esc removes the innermost layer first on Conversations (transcript → detail → multi-select → search), Memories (panel → search), Tasks (edit → detail → chat panel), Apps ("See more" → search) and Brain Map. Brain Map no longer switches sub-tabs.
- Back returns to where you came from:
- a conversation opened from Activity or a memory shows
‹ Activity/‹ Memories, and the memory panel stays open; - Settings shows
‹ <origin>and Esc does the same; - Daily Recap names its origin.
- a conversation opened from Activity or a memory shows
- Clicking the active Memories chip returns that page to its root.
- While a detail is open, the search bar above it is hidden, so typing no longer silently closes the detail.
- Every remaining back/close control now uses
BackChip/DismissButton/ClearFieldButton:- Rewind's "⌃ Back to Rewind" and its chevron-in-a-circle
- Apps "See more"
- Daily Recap
- Task chat panel
- Brain Map inspector
- Settings and in-chat sheets
- banners and popups
- Unused views carrying the old styles are deleted:
ActivityBackButton,GoalsHistoryPage,CanonicalMemoryAtlasPage,CategoryAppsSheet,RewindTimelinePlayerView.
Destructive actions: one policy — undo when the delete can be deferred, a shell-drawn confirmation when it can't, never neither.
- These now use
.shellConfirmationinstead of.alertorNSAlert:- conversation delete (detail and row)
- merge
- bulk task delete
- Delete Default Memories
- Delete Account
- persona and screenshot delete
- Rescan Files
- "Make Public"
- These had no confirmation and now ask first:
- Clear Chat
- live note delete
- AI profile delete
- integration-suggestion reset
- the status-bar "Reset Onboarding…"
- Memories and Tasks share one
UndoToast, which shows the item name and has no X. The Memories X that committed the delete is gone. - ⌘D deleted whatever task the pointer was over. Delete is now ⌘⌫, acting on the keyboard selection only.
- The Apps "disable" button is a labeled secondary button instead of a red trash can.
Headers and controls
- The conversation header uses one control language:
- labeled actions in
OmiButtonStyle - icon actions as 28 pt
OmiIconButtons - the folder menu without its stray chevron
- a red delete glyph
- labeled actions in
- Icon-only buttons that had no tooltip or label now have both.
- Sign Out is secondary.
- Memories' "More" is a menu, not a popover, and sits in
[More] [New Memory]order.
Copy
- Every copy confirms, via a checkmark or the shared toast.
- A row's "Copy Transcript" fetches the detail when the list omitted segments, and honors the lock.
- Copies never report success on an empty string.
Conversations and transcript
- Participants use real names or "Speaker N".
- Bubble, copy and participant labels agree and are 1-based, matching mobile.
- The timestamp sits in the speaker's name row; recordings over an hour read
1:02:05instead of62:05. - The speaker label shows "Change speaker…" on hover even once a speaker is named.
- The speaker sheet preselects the current assignment and responds to Return/Esc.
- A soft top fade and a divider stop labels being sliced under the header.
- Rows group by the date they display, and show only the time inside a day group.
- Row menus: the hover "…" menu and the right-click menu are now one list, with consistent casing and ellipses.
- Title and date:
- truncated titles carry a tooltip
- the detail header reads "Sep 23, 2026, 10:17 – 11:19 AM" in the user's locale
- the source label reads "Omi" / "Apple Watch"
- the Summary icon is no longer the Starred star.
Activity
- Duration now comes from the conversation's content, the same number the list shows. It previously used the capture-socket window (
FC-capture-session-window-read-as-content-duration). - Activity uses
displayTitle, the neutral waveform tile instead of 💬, and the shared day-header style instead of British date order.
Floating bar
- Esc closes and keeps the conversation. It used to clear it, cancelling any answer still streaming.
- Clearing is an explicit "Clear" control.
- Esc dismisses a notch card.
- The header shows an xmark when it closes and a chevron only when it goes back.
Onboarding and popups
- Onboarding:
- Back is on the leading edge.
- The header action reads "Skip Setup".
- Arrow-only buttons have spoken labels.
- Permission reasons use one voice and macOS naming.
- ⌘Q keeps working on the shortcut steps.
- The first-use popup no longer vanishes forever on a stray click on the dim, and closes with Esc.
- The Cloud Connector card no longer dismisses when clicked.
- What's New pauses while hovered.
Menus and shortcuts
- ⌘1–⌘4 mirror the top bar (Chat, Memories, Tasks, Apps). The Memories chip row is under "Go to in Memories" as ⌥⌘1–5.
- The status-bar menu:
- drops a ⌘O that only worked while the menu was open
- gains "Settings…"
- confirms "Reset Onboarding…"
- Settings lists ⌃⌘O, which onboarding recommends, so it never reads as "Custom". Onboarding offers push-to-talk keys in Settings' order.
- Settings search:
- "Ask omi" and "Rewind" pointed at cards that don't exist
- the duplicate "Report an Issue" entry is removed
- a test now pins every search result to a card
- The Settings audio-mode names match the toolbar ("Always On", "Only Meetings").
- A click on the audio control while transcription is blocked opens Settings, as its tooltip already promised.
- The chat plan-limit prompt opens Settings → Plan and Usage instead of a hard-coded "$199/month" web page.
Words
- Omi (not omi) in UI copy; "…" instead of "..." in UI strings.
- One retry verb ("Try Again") and one snooze verb ("Not Now").
- Title Case buttons and menu items.
- Search placeholders name what they search, with no trailing ellipsis.
Round 2: follow-up improvements
Done by six parallel agents, each on its own branch cut from this PR (ux2/*), then merged here.
- Settings: neutral selection.
- The selected sidebar row uses the top bar's neutral pill fill, not a solid blue slab.
- The on track of
OmiToggleStyleswitches isInk.primary, app-wide. - Sliders, stepped sliders, the tier picker, the Transcription mode cards and the Shortcut chips are neutral.
Ink.accentstays only on focus rings, status colors and the one actionable link.
- Settings: window reset. "Reset Window Size" has its own quiet Window Size card; it's no longer a black primary button inside Font Size. Search points at it.
- Prompt editors. The three prompt editors share one
AssistantPromptEditor:- edits are a draft, saved with Save (⌘↩) or discarded with Cancel (Esc)
- Reset asks first
- Reset clears the saved prompt instead of writing the default as if the user had typed it
- Conversation summary.
- The redundant "Conversation Details" card is gone.
- Action items show "Add to Tasks" / "Transcript" on hover or focus. The space is reserved so rows don't reflow, and added, in-flight and failed states stay visible. A failed add now shows "Try Again".
- More menus. They're neutral everywhere through a new
PageMoreMenu. A new guard rule,hand-rolled-more-menu, stops the blue label coming back. - Speaker unassign. The speaker sheet offers "Unassign", backed end to end: API
assign_type: "person_id"with no value, backend, then SQLite. "Also tag other segments" now includes the tapped segment when you're the speaker. - Find in transcript.
- ⌘F opens a find field in the transcript.
- Matching ignores case and diacritics, highlights matches, and shows "N of M".
- ⌘G / ⇧⌘G and Return / ⇧Return step between matches.
- Esc closes find, then the transcript.
- Live and saved transcripts render through one bubble component: same name · time row, colors, avatar, and 1-based speaker names.
- ⌘F and ⌘N everywhere.
FindCommandRoutersends ⌘F to the visible page's search field on every page. ⌘N opens New Memory. - Page states.
- One
GlassEmptyState/GlassErrorState/GlassLoadingState, used by Conversations, Tasks, Memories, Apps, Persona, Goals, Rewind and the chat sessions sidebar. - Page buttons are on
OmiButtonStyle, and Apps has one Open/Install size. - A new guard rule,
scaled-progress-view, catches spinners resized with.scaleEffect.
- One
- Floating bar.
- One notice policy (
FloatingBarNoticePolicy): confirmations 1.5 s, informational cards 8 s and paused while hovered, trial cards until dismissed. - One 24 pt
NotchDismissButton, replacing five 18 pt ones. - The reach-error card says "Dismiss" instead of "Skip".
- The menu bar has "Show / Hide Floating Bar".
- One notice policy (
- Onboarding. Back returns to the last step actually shown. The progress dots count only steps that will be shown and advance one per answer. Skip is always offered on the screen demo.
- Navigation.
- Goals no longer lights the Chat pill, and has a Back chip and Esc to its origin.
- Daily Recap lights its origin's pill.
- A task's screen evidence opens Rewind with
‹ Tasks.
- Headers and motion.
- One page-header rule, with Goals and Recap on
GlassPageHeader. - Motion tokens (
quick,standard,emphasized) behind one Reduce-Motion gate. - The floating bar's springs are gathered into named
FloatingBarMotionconstants.
- One page-header rule, with Goals and Recap on
The contract doc gains §10 Page states, §11 Page headers, §12 Keyboard and §13 Motion.
Round 3: David's rulings
-
The audio control opens a mode menu instead of cycling on each click.
- The menu lists Off / Always On / Only Meetings, each with what it records, plus "Audio Settings…". It's keyboard- and VoiceOver-reachable.
- The icon stays a microphone in every mode; Only Meetings adds a handset badge.
- "Armed, waiting for a call" gets its own ring dot, so green only means recording now.
- Choosing a mode that needs the mic without permission asks for it and doesn't change the mode.
ShellListeningCycleTestsbecomesShellListeningModeMenuTests, and the legibility tests measure the new dot and badge.
-
Multiple Chat Sessions is removed, along with its dead code (~1,400 lines):
- the toggle and its search entry
ChatSessionsSidebar- the ChatProvider session APIs and state
- the multi-chat bridge snapshot fields
- six analytics events that only this path fired
Every path now behaves as the default toggle-off setting did. INV-CHAT-1 still holds:
agent-logic-harness.sh --swift-onlypasses 680 tests, including the INV-6 continuity suites, andcheck-single-chat-shell.pypasses. Desktop no longer callsGET/POST /v2/chat-sessions,PATCH /v2/chat-sessions/{id},/v2/chat/initial-messageor/v2/chat/generate-title. The web app still uses them, so the backend keeps them.What upgraders see: a user who had the toggle on lands in the main chat, as they already did at every launch. Their other sessions stay in storage but can't be reached from desktop; the session list was already unmounted, so nothing visible changes.
Rulings (David, 2026-09-23)
- Top-bar "Memories" vs the "Memories" sub-tab: leave as is. The 2026-08-28 rename (
7ea1efa6bc) stands. - Default Ask-Omi shortcut: keep ⌘O. ⌃⌘O is offered first in both onboarding and Settings, and saved bindings are not migrated.
- Audio control: a click opens a mode menu instead of cycling. See "Round 3" above.
- "Multiple Chat Sessions": remove the toggle and all dead multi-chat code. See "Round 3" above.
Reconciled with #17281 (capture groups), 2026-09-24
#17281 merged first. Both PRs edited the same 8 files under desktop/macos/Desktop/, so this branch merges it by hand and keeps both behaviour sets. Nothing was transplanted from the pre-#17281 page.
- Conversation page. #17281's split stays:
ConversationDetailHeader,ConversationDetailSectionsandConversationCaptureRecordings. This PR's behaviour moves into that structure:- The header's back control is
BackChipwith the origin's name ("‹ Conversations", "‹ Memories"). - The emoji falls back to the waveform, and the status badge no longer wraps.
- Star, Discuss and "…" use the shared icon-button chrome (
OmiIconButton/OmiIconMenu). - Esc peels one layer at a time: find, then the recordings list, then the transcript, then the page.
- Find in transcript (⌘F, highlights, "N of M") sits beside the transcript's re-sync button.
- Delete uses
shellConfirmation, and rename usesTextPromptSheet. - Copy Transcript goes through
OmiToastCenterwithSpeakerLabelFormatter.
- The header's back control is
- Action items use
ConversationActionItemRowinside #17281's grouped card. Controls appear on hover or focus, and every state works: linked, adding, added, failed ("Try Again") and add. The row no longer draws its own card. - Speaker labels. The header's participants now use
SpeakerLabelFormatter, so they match the bubbles: SPEAKER_01 is "Speaker 2". #17281's test is updated to match. - Recordings list. The Separate confirmation moves from a system
.alerttoshellConfirmation, which INV-UI-2 requires. Esc goes throughonEscapeKey(.modal)instead ofonExitCommand. - Pendant label (coordinator ruling, 2026-09-24, delegated by David). The Omi pendant's capture label reads "Pendant" in the row badge's tooltip and accessibility label, the detail header and the recordings list. Mobile makes the same change. Desktop, Phone and Apple Watch are unchanged.
- List. The group collapse runs inside this PR's date buckets, which are keyed by the displayed
startedAtand labelled withOmiDateFormat. The row keeps #17281's device badges, and the source-label switch this PR had reworded stays deleted, because nothing read it. - Conversations page. A merge acts only on visible rows (
mergeableSelectedIds), so a member hidden behind its event's row can never reach a merge. This holds even when the member was selected before its group collapsed. The confirmation's count uses the same set. - Automation (non-production only).
conversation_detail_recording action=request_separateraises the real Separate confirmation.conversation_detail_prompt prompt=rename|delete|add_taskraises the page's own rename or delete prompt, or presses an action item's task control.- Both go through the production handlers, not around them.
- Also fixed: the suggested-app spinner uses
controlSize(.small)instead ofscaleEffect. It had moved into a new file, where the ratchet counted it as new.
Not changed
- Typography, radius and colour debt (124
.system(size:)fonts, 174 literal sizes, 110 literal radii, mostly in the dark notch bar and onboarding illustrations) is held by the ratchet rather than rewritten here. Nearest-rung migration changes rendered sizes and needs its own visual pass.
Verification
xcrun swift build -c debugis clean. Local toolchain is Xcode 27.0; CI pins 26.6, which isn't installed here, so the pinnedrun-swift-ci.sh --testresult will come from CI.- Full
scripts/swift-test-suites.shwith the local toolchain:- It found two regressions from this branch, both fixed: the prompt bar's enlarged dismiss target, and a label a test pins.
- Two suites still fail,
CaptureArchiveTests(AVPlayer playback timing) andFloatingBarSurfaceFloorTests.testAStateChangeAloneRaisesTheFrameToTheSurfaceItRequires. Both fail identically on an untouched checkout of the merge base on this machine, so they come from the environment, not this change.
- After rebasing onto current
main, 450 tests across the 19 affected suites (including the newDesktopUXContractTests) pass. python3 .github/scripts/test_check_desktop_ux_contract.py: 13 pass. The guard on this branch reports no increase across 111 changed desktop files.check_desktop_test_quality.py,check_agents_md_lean.py,check_product_invariants.py, swift-format lint: pass. No line-count exceptions are needed.- Real app (named bundle
omi-ux-consistency, dev backend, test account, driven through the automation bridge, with window captures):- The conversation detail shows
‹ Conversations, has no search bar above it, and uses one icon-button style. - The Transcript shows
‹ Summary, keeps the title, and reads "214 segments", with the timestamp in the speaker row and 1-based speaker numbers. - Esc ×3 goes transcript → summary → list (with search back) → Chat. The list's day header reads "Monday, Sep 21", and rows show time only.
- Settings opened from Tasks shows
‹ Tasks, and Esc returns to Tasks. - The audio-mode segments fit "Always On" / "Only Meetings". This check caught a truncation, which is fixed.
- The conversation detail shows
Verification after the #17281 reconciliation (2026-09-24)
- Build and tests:
xcrun swift build --build-testsis clean. 244 tests pass across the affected suites (Xcode 27 locally; CI's pinned 26.6 is authoritative). The suites are capture groups, recordings, conversation detail, summary UX, merge selection, repository, search filter, share link, projection, display state, decoding,DesktopUXContractTests,EscapeKeyDispatchTests,GlassContentChromeTests,TranscriptSearchModelTests,ChatFirstDrillInNavigationTests,ShellListeningModeMenuTests,ShortcutSettingsTestsandFindCommand*. - Guard:
check_desktop_ux_contract.pyreports no increase against the merge base. - Real app. Named bundle
omi-lane1-ux, built from this head against the offline local harness (Firestore emulator, synthetic useralice; nothing touched dev or prod). Captures were grouped by the production hook. Checked through the automation bridge, with window captures:- List: one row per event with device badges, under a "Today" bucket.
- Detail:
‹ Conversations, and no search bar above the page. The header reads "You, Speaker 2", matching the bubbles, and shows "2 recordings". - Action item controls are hidden until hover. Add to Tasks → "✓ Added", and the task appears in Tasks.
- Transcript: ⌘F opens find. Typing "factory" highlights the match with "1 of 1".
- Esc ×3 goes find → summary → list, with the search bar back.
- Recordings list: opens, and Esc closes only the list.
- Recordings list after the Pendant ruling: "Desktop" and "Pendant", each with its time window.
- Separate:
request_separateshows the in-window "Separate This Recording?" dialog, and Esc cancels only the dialog. - Delete prompt: the in-window "Delete Conversation?" dialog renders, and Esc cancels it.
- Rename sheet: renders prefilled.
- ⌘N on Tasks opens the inline new task.
- Onboarding (after
reset_onboarding): the first screen renders.
- Could not verify headless. None of these can be driven without a real pointer or key window:
- Clicking a dialog's confirm button. Confirm buttons deliberately have no Return shortcut, and the agent session has no Accessibility grant. Separation itself was verified on #17281 through the same handler.
- Typing into the rename sheet. It ignores synthetic key events.
- ⌘N on Memories. A SwiftUI
keyboardShortcutneeds a key window, and a background-launched app has none. - The mic-mode menu and the menu-bar "Show / Hide Floating Bar" item. Both are system menus.
- Notch card timing.
- The onboarding flow past its first screen.
Product invariants affected
- INV-UI-2 — new; this PR introduces and locks it.
- INV-UI-1 — no hue introduced; every new component draws from
Ink. - INV-NAV-1 — no destination loses a way in. Settings Back and Esc now return to their origin, and the menu shortcuts map onto the same four pills plus the Memories chip row.
- INV-CHAT-1 — the transcript is untouched. The floating bar's Esc now closes instead of clearing (clearing is an explicit control), and the chat copy/clear actions are presentation only.
- INV-CHAT-2 —
ChatMessagesViewchanges only an ellipsis in a status string. - INV-AUTH-1 —
SessionRecoveryViewchanges a button label only; the status-bar Settings item and the Reset Onboarding confirmation do not touch session state. - INV-BETA-1 — copy casing only in
SettingsContentView+Controls. - INV-MEM-1 — Brain Map close/clear controls and Esc fall-through only; tiers untouched.
- INV-MEM-4 — presentation only.
- INV-INT-1 — copy and the integration-suggestion reset now confirm before running.
- INV-TASK-1 — Tasks presentation, keyboard and confirmation only; bucket paging untouched.
- INV-TASK-2 — presentation only. The desktop manual task writer (
TasksStore.createTaskfrom an action item's own control) stays inConversationDetailSections.swift, where #17281 registered its anchor; it adds a failed/retry state and nothing else.
Failure class
Failure-Class: FC-capture-session-window-read-as-content-duration
🤖 Generated with Claude Code