Skip to content

feat(desktop-windows): hydrate default chat thread from backend on mount - #9

Open
formed2forge wants to merge 89 commits into
mainfrom
feat/windows-default-thread-backend-hydration
Open

feat(desktop-windows): hydrate default chat thread from backend on mount#9
formed2forge wants to merge 89 commits into
mainfrom
feat/windows-default-thread-backend-hydration

Conversation

@formed2forge

Copy link
Copy Markdown
Owner

Summary

  • The main chat window's default thread now loads from GET /v2/desktop/messages (the same Firestore-backed store macOS and mobile read) before falling back to local SQLite.
  • Mac/mobile/web turns become visible in the Windows main window immediately, closing the cross-device read gap.
  • All three entry points to the default thread (mount loader, switchThread(null), selectApp(null)) share a single loadDefaultThreadHistory helper — backend-first, local SQLite fallback.

Test plan

  • Start Windows app — default chat thread shows messages from other devices
  • SQLite fallback: disconnect from network, restart — local history still loads
  • useChat.test.tsx: 37/37 pass (R4 and R5 are new hydration tests)

🤖 Generated with Claude Code

https://claude.ai/code/session_01XCp5LUrL4FLcaLUkdDg49p

cursoragent and others added 19 commits August 25, 2026 23:44
Remove unused imports, drop a duplicate MCP scope key and dead deploy helper,
share the duplicated Levenshtein helper, and narrow bare excepts around datetime
parsing. Update webhook/app-integration tests that stubbed the removed imports.

Verification: BACKEND_UNIT_TEST_FILE_LIST covering
test_memory_ingestion_text, test_verify_output_grounding, test_mcp_data_endpoints,
test_async_app_integrations, test_async_webhooks — all passed.

Co-authored-by: Max Carter 祁明思 <max@tsc.hk>
Delete unused memory/conversation UI remnants (including unused_element ignores
and commented-out blocks) and share identical quick-edit, duration, and status
pill helpers across sibling pages. Behavior preserved; Flutter SDK unavailable
in this cloud VM so app tests were not executed here.

Co-authored-by: Max Carter 祁明思 <max@tsc.hk>
Replace eval() with json.loads for Firebase service-account JSON, unify
cm-builds under x-auth-token with HTTP status checks, and point onboarding
sync tooling at desktop/macos/Desktop instead of the removed desktop/Desktop
layout.

Co-authored-by: Max Carter 祁明思 <max@tsc.hk>
Co-authored-by: Max Carter 祁明思 <max@tsc.hk>
Avoid growing the memory_ingestion package past its grandfathered source-file
count by placing the shared Levenshtein helper in the existing ids module
instead of adding a new text.py file.

Co-authored-by: Max Carter 祁明思 <max@tsc.hk>
Delete the new text.py module so memory_ingestion stays within its
grandfathered source-file count, and point pipeline/verify_output at ids.

Co-authored-by: Max Carter 祁明思 <max@tsc.hk>
- scripts/install_onboarding_figma_sync.sh: point launchd WatchPaths at the
  real Resources bundle (desktop/macos/Desktop/Sources/Resources).
- scripts/run_onboarding_figma_sync.sh: remove redundant -path clause for
  OnboardingLoadingAnimation.swift (already matched by -name 'Onboarding*.swift').
- app/lib/pages/memories/widgets/memory_edit_sheet.dart: showMemoryQuickEditSheet
  now accepts and forwards an optional onDelete callback instead of hardcoding
  a no-op.
- app/lib/pages/phone_calls/active_call_banner.dart: ActiveCallTopBar uses the
  shared formatPhoneCallDuration helper, completing the duration-format dedup.
- backend/tests/unit/test_async_app_integrations.py: test_no_threading_used
  inspects _async_trigger_realtime_audio_bytes bytecode (not the wrapper) and
  removes the brittle hasattr(app_integrations, 'threading') check.
Re-run dart format after flutter pub get so the pinned language version
applies; 6 changed files now match the repo formatter. pubspec.lock picks
up the same transitive meta/test_api bumps CI's pub get resolves.

Failure-Class: none
Rewrite the store-link helper as sequential ifs so Frontend Lint prettier/prettier passes after the hygiene indent change.
Hygiene cleanup dropped webhook_url_from_setting from utils.webhooks,
so first-time setup treated raw Redis values as URLs and audio-bytes
sends raised NameError. Parse stored settings through the helper again
so ',5' and whitespace-only values stay disabled, and audio delivery
can extract the endpoint.

Failure-Class: none
Reproduce the revoked process-wide owner state, then establish and restore the test owner through RuntimeOwnerAuthorityTestFixture so suite order cannot turn authenticated capture into anonymous capture.

Verification: RewindCaptureExclusionGenerationTests 9/9 passed; deterministic contamination recovery passed 50/50 runs; the repaired test passed inside two 5,769-test process runs (each full run retained one unrelated baseline failure).

Failure-Class: FC-hand-listed-test-isolation-membership
@formed2forge
formed2forge force-pushed the feat/windows-default-thread-backend-hydration branch 2 times, most recently from ec9a177 to e85de0c Compare August 26, 2026 15:06
undivisible and others added 8 commits August 26, 2026 18:17
BasedHardware#11303)

<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## What changed and why

High-confidence, behavior-preserving hygiene cleanup: remove proven dead
code/unused imports, share identical helpers, narrow bare excepts around
datetime parsing, and fix broken script paths. Net ~500 lines removed;
no intentional product behavior change.

## Product invariants affected

- INV-DATA-1 (merge from main brought app/lib/main.dart into diff; no
intentional behavior change)
- INV-MEM-4 (merge from main brought memory_ingestion files into diff;
no intentional behavior change)
- INV-MEM-1 (path match on memory ingestion / memories UI files; no
intentional behavior change)

## How it was verified

Focused backend unit tests via `bash test.sh` (file list below), script
syntax checks, product line-count baseline ratchet, and
`scripts/pr-preflight --pr-body-file` (27 checks passed). App Flutter
SDK is unavailable on this Linux cloud VM; Dart brace balance was
checked manually and pre-push used the documented Flutter/Dart skip
hatches.

```
BACKEND_UNIT_TEST_FILE_LIST=... bash test.sh
# test_memory_ingestion_edit_distance: 2 passed
# test_verify_output_grounding: 26 passed
# test_async_app_integrations: 18 passed
# test_mcp_data_endpoints: 67 passed
# test_async_webhooks: 22 passed
scripts/pr-preflight --pr-body-file /tmp/pr-body.md
# PR preflight passed: 27 checks
```

## Tests

- Added `backend/tests/unit/test_memory_ingestion_edit_distance.py`
proving pipeline and verify_output share `ids.edit_distance`
- Updated `test_async_webhooks` / `test_async_app_integrations` fixtures
for removed unused imports
- No Flutter tests run here (SDK absent); CI remains the app authority

## Failure class (fixes)

Failure-Class: none

## Scoped cleanups

- Backend: unused imports, duplicate MCP scope key, shared Levenshtein
helper, bare-except narrowing, dead `_inject_config_map`
- App: dead memory/conversation UI remnants; shared quick-edit /
duration / status-pill helpers
- Scripts: `eval` → `json.loads`, unified `cm-builds`, onboarding paths
→ `desktop/macos/Desktop`
- CI: downward product file line-count baseline ratchet for shrunk
backend files

<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-6f28e0a4-53fb-41b4-8046-c6e2dbebd2bd?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-6f28e0a4-53fb-41b4-8046-c6e2dbebd2bd&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>
Unused import introduced by BYOK refactor (BasedHardware#11454). Causes pyright error
in CI.\n\nFailure-Class: none
…asedHardware#12274)

The manual development backend deploy ran its post-promotion smoke against
https://api.omi.dev, which has never resolved: the omi.dev zone exists but the
api record is NXDOMAIN, so smoke_what_matters_now.py could not reach anything
and failed with "could not reach the deployed backend". That failed the deploy
after traffic had already shifted and triggered the traffic restore, rolling the
promotion back.

Development's real public API host is api.omiapi.com, which matches the rest of
the development domain family (parakeet.omiapi.com, nllb.omiapi.com,
pusher.omiapi.com in backend/deploy/runtime_env/dev.overlay.yaml) exactly as
production uses the omi.me family. It serves /ready 200 today.

Making api.omi.dev real was rejected: the codebase already uses it as the
canonical fake hostname in mobile production-routing tests, so giving it a live
record would undermine those fixtures.

This defect was introduced by 933fdf7 on the same day as the probe-signer
defect fixed in BasedHardware#12264, and stayed invisible because the probe failed first.
Run 33008079463 is the first development deploy to reach this step.

The step is guarded to the manual development lane; the production smoke keeps
its own api.omi.me path untouched.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…asedHardware#11454's contract (BasedHardware#12277)

* chore(desktop): unbreak the local Swift build and formatter gate on Xcode 26

Two pre-existing blockers on `main` that fail the pre-push gate for any
desktop PR when the local toolchain is newer than the pinned Xcode 16.4:

- `AppState+Permissions.swift` has swift-format drift, and
  `desktop-swift-format-lint` runs `lint-scope` over every first-party
  Swift file. Formatter output only.
- `AppState+Transcription.swift:848` captures `alertPresenter` implicitly
  in an escaping closure. Swift 6.2 (Xcode 26.x) rejects this; Xcode 16.4
  accepts it. Writing `self.` is what the diagnostic asks for and is the
  same strong capture the implicit form already produced, so behavior is
  unchanged on both toolchains.

Failure-Class: none

* chore(desktop): dodge the Xcode 16.4 SILGen segfault on alertPresenter

Every desktop CI lane (Static & Test Contracts, Release Compile, Build &
Tests aggregate) has been red since main's d49f978 landed
`var alertPresenter: any DesktopAlertPresenting = AppKitSheetAlertPresenter()`:
the pinned Xcode 16.4 toolchain segfaults (signal 11) in silgen
emitStoredPropertyInitialization while lowering that existential-erasure
default initializer. Reproduced on main itself (d49f978, fb67ca9,
50cf064 all failed; d06e220 passed only because its desktop jobs were
path-filter skipped) and on unrelated PRs (BasedHardware#12269, BasedHardware#12272), so this is not
specific to this branch.

Move the initializer from the stored-property default position into
init(). Identical semantics on both toolchains - AppState is @mainactor
with a single designated init, and the alert tests overwrite the presenter
immediately after construction. Xcode 26.6 parses, swift-format lint
passes, changelog gate passes.

Failure-Class: none

* test(desktop): enroll BYOK fingerprints so paywall/agent tests match BasedHardware#11454's contract

BasedHardware#11454 replaced the old "all keys present in UserDefaults" check for
isByokActive with a stricter one: the selected provider's *current* key
must match a fingerprint already persisted via
APIKeyService.persistEnrolledFingerprints (set by activateBYOK
reconciliation after BYOKValidator confirms the key). Seven tests across
BYOKPaywallTests and AgentRuntimeProcessTests still set up state the old
way — raw UserDefaults keys, no enrollment — and started failing the
moment CI could actually reach them (BasedHardware#12276): main's own contract job
caught this on BasedHardware#11454 before merge, but a compiler crash landed 8
minutes earlier (tracked separately in BasedHardware#12275) blocked every real
desktop Swift test run afterward, so it went unnoticed.

This is a test-only fix that transcribes BasedHardware#11454's already-stated and
already-tested enrollment contract into the tests that never learned
about it; no Sources change.

- BYOKPaywallTests: add `enroll(_:)`, calling
  `persistEnrolledFingerprints` with the SHA-256 fingerprint of the
  provider's current key, exactly as `activateBYOK` reconciliation would
  after successful validation. Five tests were asserting on raw key
  presence: testByokActiveRequiresSelectedLLMKey (needs a *second*
  enrollment after setAllBYOKKeys() rewrites openrouter's key and
  invalidates the first fingerprint), testBuildHeadersAttachSelectedLLMByokKey,
  testBuildHeadersSuppressesOnlyInvalidByokHeader,
  testPaywallFlagSuppressedWhenByokActive, and
  testRemovingDeepgramKeyLeavesSelectedLLMByokActive (both of the last
  two now select the provider explicitly rather than relying on legacy
  first-match inference, since every provider's key is set and the test
  must enroll the same provider it selects).
- AgentRuntimeProcessTests: enroll the selected provider in
  testUsableByokEnvironmentIncludesAllKeysWhenAllProvidersAreUsable and
  testUsableByokEnvironmentSuppressesAllKeysWhenOneProviderIsKnownBad —
  usableBYOKEnvironment() gates on isByokActive before the
  CredentialHealthManager suppression these tests exercise. Both
  existing `defer` blocks now also save/restore the enrollment map.

Not touched: testBuildHeadersCanExplicitlyExcludeByokKeys and
testLowLevelTransportDefaultsToExcludingByokKeys currently pass
vacuously — neither enrolls a provider, so isByokActive is false and
headers come back nil regardless of includeBYOK. Flagging for whoever
owns BYOK rather than fixing here, since giving them real coverage
means deciding what "excluded despite being active" should assert, and
that's a product call, not a mechanical transcription of BasedHardware#11454.

No assertions were weakened or removed — every fix completes test setup
to match the stated contract. testPaywallFlagSuppressedWhenByokActive is
the only coverage that an enrolled BYOK user is never paywalled; it was
made to pass by enrolling correctly, not by loosening what it checks.

Fixes: BasedHardware#12276
Failure-Class: none

---------

Co-authored-by: David Zhang <9387252+Git-on-my-level@users.noreply.github.com>
Auto-generated: consolidates unreleased changelog fragments into
v0.12.223 and regenerates CHANGELOG.json.
…re#12267)

## Why

The 2026-08 macOS churn cohort analysis
(`omi-knowledge-base/projects/macos-churn-analysis/evidence/2026-08-26-macos-churn-cohort-analysis.md`)
listed `chat_agent_error` as "well-powered but cannot explain itself".

Measured against PostHog (project 302298, macOS, JSON extraction rather
than the map subscript — see below), August 2026 `chat_agent_error`:

| property | populated |
| --- | --- |
| `error_class` / `surface` / `harness` | 1177 / 1197 |
| `error_code` | 909 / 1197 |
| `root_cause` | 178 / 1197 |

`error_class` is fine — schema v2 fixed that. The remaining hole is that
only one of the ~15 `telemetryAttempt.fail(...)` call sites
(`ChatProvider.swift:5446`) passes a `ChatQueryErrorDetail`. Every other
terminal — timeout, tool stall, session setup, bridge unavailable,
attachment upload, concurrent request — reached PostHog with no
`error_code` at all, and `root_cause` was hardcoded for exactly one
error class (`.authentication`). A typed failure existed at the catch
boundary and was collapsed to a bare class name by the time it was
recorded.

## What changed

`ChatQueryErrorClass` now classifies itself, so the fix lands at the one
place that builds the payload instead of at 15 call sites:

- `rootCause` maps every class to a bounded `ChatQueryRootCause`
  (subsystem attribution: provider, agent runtime, bridge process, local
  session, network, device resources, ...). `.authentication` keeps the
  already-published `provider_claude` value so existing PostHog
  breakdowns stay valid.
- `fallbackErrorCode(watchdogFired:)` gives every class a bounded code
  when no `ChatQueryErrorDetail` is available. It separates
  `watchdog_timeout` from `bridge_timeout`, which have different owners
  and were previously indistinguishable.
- A supplied `ChatQueryErrorDetail` still wins; the fallback only fills
  the gap.

No raw exception text, prompt, path, or message enters the payload — the
values are enum raw values, per the analytics integrity contract in
`desktop/macos/AGENTS.md`.

## Proof

`ChatQueryTelemetryTests`:

- `testEveryFailureClassCarriesABoundedCodeAndRootCause` iterates all 16
  classes and fails if any emits an empty/absent code or an out-of-
  vocabulary root cause.
- `testRootCauseAndTimeoutCodesStayActionable` pins the compatibility
  value for auth and the watchdog/bridge timeout split.
- `testErrorDetailCodeOverridesTheClassFallback` proves the fallback
  cannot shadow real detail.
- `testAnalyticsPayloadUsesTypedAllowlist` (existing) still pins the
  exact emitted key set.

37 tests pass locally.

Failure-Class: FC-typed-failure-collapsed-to-generic
kodjima33 and others added 29 commits August 27, 2026 14:43
Hovering the notch opens the agent menu; when the pointer leaves, the
collapse resize lands while the menu's collapse spring is still shrinking
the SwiftUI content. The hosting view forwards the content's min size as a
window constraint, and auto layout grows the panel right back - from its
pinned bottom-left origin. That pushed the top-anchored island chrome up to
240pt above the screen edge, where nothing ever brought it back: the island
'disappeared' until a Push-to-Talk press happened to resize the window.

Two guards, both mechanical. windowDidResize re-anchors any notch-mode
resize whose top edge left the screen top (auto layout growth bypasses every
programmatic resize path, so the anchor is enforced at the notification, not
at call sites) - user-resizable and mid-drag windows are never fought. And
the collapse re-asserts the idle island frame once after the spring's visual
tail, so the panel returns to size instead of keeping a stale menu-height
frame.

Fixes the hover-then-vanish report on Omi macOS Beta 0.12.226.

Verification: reproduced deterministically on a dev bundle via a cursor-free
bridge seam driving the same pointer entry point the tracking view calls -
every hover cycle left the window at {{816,1263},{430,307}} (chrome 240pt
offscreen). With the fix, 20 timing patterns including rapid x15 and
re-enter-mid-collapse all settle back to the exact idle frame
{{828,1263},{392,67}}, menu open and idle states captured. 5 new geometry
tests cover the re-anchor policy, including the reproduced bug frame.
Auto-generated: consolidates unreleased changelog fragments into
v0.12.227 and regenerates CHANGELOG.json.
Cross-review caught a collision: a visible remote prompt plus the third
question left both bars stacked in the shared overlay. The built-in rating
ask now SUSPENDS any current remote prompt (cleared without a resolution)
via builtInAskChanged() on every ask state change, and evaluate() re-offers
it once the ask resolves.

Also registers GET /v2/desktop/prompts in the route policy manifest
(baseline check was failing CI).
…rdware#12311)

## Summary

On the macOS Beta, hovering over the notch island and then moving the
cursor away sometimes made the island vanish entirely — it stayed gone
until a Push-to-Talk press happened to bring it back.

**Root cause, reproduced deterministically.** Hover opens the agent
menu; on pointer exit the collapse resize (416×67, logged) lands while
the menu's collapse spring is still shrinking the SwiftUI content. The
hosting view forwards the content's min size as a window constraint
(`sizingOptions = [.minSize, .maxSize]`), so auto layout immediately
grows the panel back to menu height — **from its pinned bottom-left
origin**. The island chrome is top-anchored inside the window, so it
ends up to 240pt *above* the screen edge: invisible. Nothing re-anchors
it; PTT "fixed" it only because its resize recomputes the frame.
Reproduced on a dev bundle with a cursor-free bridge seam driving the
same `updateNotchPointer` entry the tracking view calls — every hover
cycle ended at `{{816,1263},{430,307}}` (top edge 1570 on a 1330-high
screen).

**Fix, two mechanical guards:**
1. `windowDidResize` re-anchors any notch-mode resize whose top edge
left the screen top
(`FloatingControlBarGeometry.notchTopReanchoredFrame`). The buggy growth
comes from auto layout, which bypasses every programmatic resize path —
so the invariant is enforced at the notification, not at call sites.
Resizable (user-sized conversation) and mid-drag windows are never
fought; an epsilon prevents setFrame churn on AppKit rounding.
2. The menu collapse re-asserts the idle island frame once after the
spring's visual tail (0.45s), so the panel returns to size instead of
keeping a stale menu-height frame. A re-hover cancels the re-assert.

Also adds the `notch_hover` bridge action (non-prod) used to reproduce
and verify without a cursor.

## Verification

- **Before:** every hover cycle across 20 timing patterns left the
window at `{{816,1263},{430,307}}` — chrome 240pt offscreen (the
user-reported disappearance, on demand).
- **After:** the same 20 patterns — five enter-hold durations × four
exit delays, rapid ×15, and re-enter-mid-collapse ×10 — all settle back
to the exact idle frame `{{828,1263},{392,67}}` with
`notchRevealProgress=1`, alpha 1. Menu-open and post-hover idle states
captured from the running bundle (`.cross-review-verify.png`).
- `swift test --filter NotchTopReanchorTests` — 5 passed, including a
case built from the reproduced bug frame.
- `swift build -c debug` — Build complete.

## Product invariants affected

- **INV-CHAT-1** — unchanged. The touched files carry chat-adjacent
surfaces, but this diff only re-anchors the notch window frame and
re-asserts its collapse size; no chat journaling or routing changes.

Failure-Class: none

Line-Count-Exception:
desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift | 4764 ->
4791 | one cursor-free notch_hover QA action, following the file's
established registry pattern
Line-Count-Exception:
desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift
| 5309 -> 5384 | the top-edge re-anchor and collapse re-assert live in
the notch panel's owner beside the resize paths they guard; extracting
the window is out of scope


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/BasedHardware/omi/pull/12311?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
…t cycle

Users who already asked 3+ questions before this build now see the rating
ask on their next launch instead of after three MORE questions: a one-shot
seed reads the real chat history (GET v2/desktop/messages, sender=human)
with launch-timing retries; fetch failure leaves the marker unset so the
next launch retries. Verified live on a dev bundle: reset -> seed -> bar
armed with zero new questions (capture in PR).

Also breaks the RatingPromptManager/RemotePromptEngine singleton init cycle
(refresh() during static-let init synchronously reached back into .shared —
the deferred MainActor hop removes the startup deadlock the reviewer
flagged; the exclusivity walk passes live: remote prompt suspends on the
3rd question and restores unresolved after the ask resolves).

Failure-Class: none
…n ledger

The engine kept an in-memory counter that reset every launch, so an 'after
Nth question' prompt demanded N fresh questions per session and ignored the
history seed. questionCount is now a read of the SAME persisted key the
rating prompt and history seed write — one ledger, relaunch-proof.

Regression tests (RemotePromptLedgerTests, 2): a persisted count of 3 arms a
question_count prompt on the relaunch-time fetch with zero session
questions; a count of 2 stays hidden. Auth check made injectable so the
tests exercise the real fetch path.

Failure-Class: none
…asedHardware#12309)

## Why

Every in-app ask (rating bar, surveys, announcements) previously needed
a desktop release and days of channel rollout. This adds the remote
prompt engine: prompts are authored on **admin.omi.me → Prompts** and
reach every desktop client within one poll (~5 minutes) — created,
targeted, killed, and measured with zero releases.

## What

- **Backend** — `GET /v2/desktop/prompts` (new
`routers/desktop_prompts.py`): serves active `desktop_prompts` Firestore
docs with stable per-(user,prompt) rollout buckets, channel and
min-build audience filters. Read-only over admin-authored docs; standard
user auth.
- **Desktop** — `RemotePromptEngine`: polls at launch + every 5 min,
renders `stars` / `nps` / `choice` / `banner` natively above the
composer, one prompt at a time, once per user (persisted resolution).
Question-count triggers ride the same accepted-question seam as the
built-in rating ask, which keeps right of way. Answers/dismissals land
in PostHog (`Desktop Prompt Shown/Answered/Dismissed`). A prompt
deactivated on admin disappears from live clients on the next poll — the
no-release kill path.
- **Admin** — `/dashboard/prompts` page (sidebar: Prompts): create (born
inactive), activate/deactivate, rollout %, delete, with live per-prompt
tallies (shown / answered / dismissed, stars-NPS averages, per-option
counts) from PostHog.
- Bridge actions `remote_prompts_state/refresh/reset`,
`remote_prompt_answer/dismiss` drive the same engine methods the visible
controls call; e2e flow `remote-prompts.yaml`.

## Verification

- Units: backend 7/7 (`test_desktop_prompts.py` — audience, rollout
stability, spec validation), `RemotePromptPolicyTests` 5/5 (trigger
thresholds, once-only, unknown-kind ignored, deterministic ordering),
admin vitest 198/198 (incl. `normalizePrompt` contract), `tsc` clean.
- **Live end-to-end on a dev bundle** (`omi-rating-qa`, real seeded
account): created a stars prompt through the real admin POST route (prod
Firestore) → real-auth backend route served it → running app fetched it
(`spec_count 1`) → hidden until the 3rd accepted question, and the
built-in rating ask correctly took right of way first → after resolving
it the remote bar rendered ("How useful was Omi today?" with stars,
window capture) → bridge answer(4) resolved it via the same method the
star buttons call → refresh does NOT re-show → **deactivating on admin
removed it from the live client on the next fetch** → deleted (no test
residue).
- UNVERIFIED (narrow): the Prompts page UI in a browser (admin login
wall blocks headless); its API routes were exercised for real above.
Will screenshot on prod admin after deploy.

Failure-Class: none

Line-Count-Exception:
desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift | 4764 ->
4765 | one registration line for the remote-prompt action group (actions
live in their own extension file)
Line-Count-Exception:
desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift | 1643 ->
1650 | remote-prompt overlay slot, engine start, and history-seed hook;
the features live in RemotePrompts.swift / RatingPrompt.swift

Product invariants affected: INV-NAV-1 — DesktopHomeView changed only by
adding a bottom overlay slot and an engine-start hook for remote
prompts; no navigation destination, route owner, or shell chrome
changed.

## Round 3

- **Instant ask for existing users** (Nik): the question counter seeds
one-shot from real chat history — users with 3+ lifetime questions see
the rating bar on next launch, zero new questions needed. Live-verified:
reset → seed → bar armed (capture).
- **Init-cycle fix** (review): `refresh()` inside
`RatingPromptManager`'s own `static let` init reached
`RemotePromptEngine` which read `.shared` back — now a deferred
MainActor hop; the pre-fix trap reproduced in a test process, post-fix
the live app runs clean.
- **Slot exclusivity live-verified** (review): remote banner current →
3rd question arms rating bar and remote prompt suspends (`current_id`
empty) → resolving the ask restores it unresolved. Captures attached
in-repo for review.
…ions

Cross-review caught the BasedHardware#9821 account-switch-bleed class: every prompt key
(question count, submitted rating, dismissed, history-seed marker, remote
prompt resolutions) was device-global, so one account's answer suppressed
prompts for every later account on the Mac. All state now lives under
owner-scoped ScopedDefaultsKeys; the first scoped access migrates the
pre-scoping global keys to the signed-in account (single-account Macs keep
their state, nobody gets re-prompted) and removes them. The remote engine's
question count is now literally RatingPromptManager.questionCount — one
owner-scoped ledger accessor.

PromptStateAccountScopingTests (3): rating submission does not suppress the
next account and survives switching back; remote resolutions are
per-account; legacy global state migrates to the first account only. Full
prompt suites 23/23.

Failure-Class: none
…tions) (BasedHardware#12312)

## Why

Cross-review caught the BasedHardware#9821 account-switch-bleed class in the new
prompt surfaces: all rating/remote-prompt state was device-global, so
one account's answer or dismissal suppressed prompts for every later
account on the same Mac.

## What

Every key (question count, submitted rating, dismissed, history-seed
marker, per-prompt remote resolutions) is now owner-scoped via
ScopedDefaultsKey. First scoped access migrates the pre-scoping global
keys to the currently signed-in account (single-account Macs keep their
state — nobody who already answered gets re-prompted) and deletes the
globals. The remote engine's question count is now literally
`RatingPromptManager.questionCount` — one owner-scoped ledger.

## Verification

`PromptStateAccountScopingTests` (3): submit on account A → account B
starts clean, asks 3 questions, sees the prompt; switching back
preserves A's submission; remote prompt dismissed by A is still offered
to B; legacy global state migrates to the first account only, second
account starts clean. All prompt suites 23/23. swift build clean.

Failure-Class: none
Auto-generated: consolidates unreleased changelog fragments into
v0.12.228 and regenerates CHANGELOG.json.
…Prometheus (BasedHardware#12100)

Implemented-By: codex gpt-5.6-sol

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ng under T5838 AAD (BasedHardware#12101)

Implemented-By: codex gpt-5.6-sol

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
142a0fe limited the chat identity mark to the newest assistant message
for a quieter timeline, which left every older answer unattributed — by
request, the mark follows the sender again, not recency. The
last-assistant-message helper had no other caller and is retired with its
tests.

Verification: exercised in the running dev bundle — two chat turns (echo,
foxtrot) each render the Omi mark beside the reply; 13
ChatWorkingIndicatorTests pass; screenshot in .cross-review-verify.png.
Cross-review follow-up: ownerProvider changes keyed the STORAGE per account,
but cached isVisible/current stayed until the next question or flag reload.
Both managers now expose ownerDidChange(); DesktopHomeView's owner-keyed
task (the established transition seam) calls it on every switch, and
RatingPromptManager also observes .userDidSignOut for the sign-out leg.
The rating manager clears thank-you and recomputes for the new owner's keys;
the remote engine drops the slot (no resolution written), re-evaluates, and
refetches.

Tests: two new PromptStateAccountScopingTests drive ownerDidChange with a
VISIBLE prompt — armed rating bar disappears for account B and returns for
A; a remote banner survives the switch unresolved and B's dismissal never
sticks to A. Prompt suites 25/25.

Failure-Class: none
…Hardware#12314)

## Why

Follow-up to BasedHardware#12312 (per-account prompt state): storage was
owner-scoped, but the cached `isVisible`/`current` published state
persisted across an account switch until the next question or flag
reload.

## What

`RatingPromptManager.ownerDidChange()` (clears thank-you, recomputes
from the new owner's keys) and `RemotePromptEngine.ownerDidChange()`
(drops the slot without writing a resolution, re-evaluates, refetches).
DesktopHomeView's owner-keyed `.task(id: currentOwnerId)` — the app's
established owner-transition seam (same pattern as the chat-first
capability task) — invokes both on every transition; the rating manager
additionally observes `.userDidSignOut` for the sign-out leg.

## Verification

Two new `PromptStateAccountScopingTests` exercise the transition with a
VISIBLE prompt and no manual flag reload or extra question: the armed
rating bar disappears the moment the owner flips to B and returns when
it flips back to A; a visible remote banner survives the switch
unresolved, B's dismissal doesn't stick to A. All prompt suites 25/25;
swift build clean.

Failure-Class: none
…#12315)

## Summary

By request: `142a0fe44e` (Jul 27) limited the chat's Omi identity mark
to only the newest assistant message, which left every older reply
unattributed. The mark now follows the sender again — every Omi reply
carries it, app personas keep their own avatars, user bubbles are
unchanged. The last-assistant-message helper had no remaining caller and
is retired together with the tests that asserted the old gating; the
replacement test asserts the sender-based rule.

## Verification

- Running dev bundle, real chat: two turns ("Say only: echo" / "Say
only: foxtrot") — **both** replies render the Omi mark (screenshot:
`.cross-review-verify.png`).
- `swift test --filter ChatWorkingIndicatorTests` — 13 passed.
- `swift build -c debug` — Build complete.

## Product invariants affected

- **INV-CHAT-1** — unchanged; presentation-only (which rows show the
identity mark), no journaling or routing changes.
- **INV-CHAT-2** — unchanged; the transcript's message ordering,
sessions, and content are untouched — only the per-row avatar gate
changed.

Failure-Class: none


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/BasedHardware/omi/pull/12315?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
…failure

Cross-review: setActive flipped the switch optimistically and never checked
response.ok, so a failed PATCH falsely showed a prompt as activated or —
worse — killed. The PATCH now goes through patchPromptActive, which reports
HTTP and network failures; the page rolls the optimistic flip back and
surfaces the error.

Tests: desktop-prompts-toggle 3/3 (success, failed HTTP status, network
throw all reported honestly).

Failure-Class: none
…ure (BasedHardware#12316)

Cross-review finding on the new Prompts page: `setActive` flipped the
switch optimistically without checking `response.ok` — a failed PATCH
falsely showed a prompt as activated or (worse, it is the kill switch)
deactivated. The PATCH now goes through `patchPromptActive`, which
reports HTTP and network failures; the page rolls the optimistic flip
back and surfaces the error inline.

## Verification
`desktop-prompts-toggle` vitest 3/3: success passes through; failed HTTP
status returns `toggle failed (502)`; a network throw returns its
message. `tsc` clean. The happy path (activate/deactivate against prod
Firestore) was exercised live in the browser earlier today.

Failure-Class: none

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/BasedHardware/omi/pull/12316?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
…il the new owner's fetch succeeds

Cross-review: ownerDidChange re-evaluated the PREVIOUS account's
server-filtered payload (rollout %, channel are per-user), so when the new
owner's refetch failed, account B could indefinitely see a prompt targeted
at account A. ownerDidChange now clears current AND specs and evaluates only
after a successful authenticated fetch for the new owner.

Test rewritten with per-owner fetch results: A inside the rollout, B outside
AND with a failing refetch — B renders nothing at the switch, after the
failed fetch, and A's prompt returns unresolved on switch-back. Suites 25/25.

Failure-Class: none
…s fetch succeeds (BasedHardware#12317)

Cross-review finding: `RemotePromptEngine.ownerDidChange()` re-evaluated
the previous account's server-filtered specs before fetching for the new
UID — audience filtering (rollout %, channel) is per-user, so a user
outside a rollout could see another account's prompt indefinitely when
their refetch failed.

`ownerDidChange()` now clears `current` AND `specs` synchronously and
evaluates only after a successful authenticated fetch for the new owner;
a failed fetch renders nothing.

## Verification
The account-switch test now uses per-owner fetch results (A inside the
rollout; B outside with a THROWING fetch): at the switch B shows nothing
and specs are empty, after B's failed refetch still nothing, and
switching back restores A's prompt unresolved. All prompt suites 25/25;
swift build clean.

Failure-Class: none
Cross-review: an in-flight fetch started for account A could complete after
switching to B and overwrite specs/current with A's audience-filtered
payload. refreshFromServer captures the generation before awaiting fetch()
and discards the result if an owner transition bumped it meanwhile.

Test: A's fetch parks on an actor gate, the owner switches to B mid-flight,
the gate opens — A's payload is provably discarded (current nil, specs free
of A's prompt). Suites 26/26.

Failure-Class: none
…2318)

Cross-review race: an in-flight `fetch()` started for account A could
complete AFTER an owner switch and land A's audience-filtered payload
for B. `refreshFromServer()` now captures `fetchGeneration` before
awaiting and discards the result unless the generation is unchanged;
`ownerDidChange()` bumps it.

## Verification
New test parks A's fetch on an actor gate, switches the owner to B
mid-flight, opens the gate, and proves the stale payload is discarded
(`current` nil, `specs` free of A's prompt). All prompt suites 26/26;
swift build clean.

Failure-Class: none
Cross-review: seedFromHistoryIfNeeded fetched without expectedOwnerId and
wrote through scopedKey() for whichever account was current when the request
finished — an account switch mid-flight could seed B from A's history. The
seed now captures the owner at start, passes it as
getMessages(expectedOwnerId:) so the transport owner-assertion applies, and
discards the result if the owner changed or the task was cancelled before
persistence (marker stays unset; the owner's next launch retries cleanly).

Test: A's history fetch parks on an actor gate, the owner switches to B,
the gate opens — B's count/seed marker are untouched and the stale result is
fully discarded. Suites 27/27.

Failure-Class: none
Cross-review finding: the history seed fetched without `expectedOwnerId`
and wrote through `scopedKey()` for whichever account was current at
completion — an account switch mid-flight could seed account B from
account A's history.

The seed now captures the owner at start, passes
`getMessages(expectedOwnerId:)` (transport-level owner assertion), and
discards the result if the owner changed or the task was cancelled
before persistence — the marker stays unset so that owner's next launch
retries cleanly.

## Verification
New regression test parks A's history fetch on an actor gate, switches
the owner to B, opens the gate: B's count and seed marker remain
untouched and the stale result is fully discarded (A's marker also unset
for a clean retry). All prompt suites 27/27; swift build clean.

Failure-Class: none
Auto-generated: consolidates unreleased changelog fragments into
v0.12.229 and regenerates CHANGELOG.json.
The main chat window's default thread now loads from GET /v2/desktop/messages
(the same Firestore-backed store macOS and mobile read) before falling back to
local SQLite. Mac/mobile/web turns become visible in the Windows main window
immediately, closing the cross-device read gap.

Call sites updated: mount loader, switchThread(null), and selectApp(null) —
all three entry points to the default thread now share the same
loadDefaultThreadHistory helper (backend-first, local SQLite fallback).

Verification:
- vitest run useChat.test.tsx: 37/37 pass (R4 and R5 are new)
- Full pnpm vitest suite: 5437/5437 pass

Failure-Class: none

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KADKRuaPJdho9CDE7nLXQP
@formed2forge
formed2forge force-pushed the feat/windows-default-thread-backend-hydration branch from e85de0c to 2fe109a Compare August 27, 2026 21:57
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.

9 participants