fix(notifications): preserve away alerts and recover missed dismissals

This commit is contained in:
Jinwoo-H
2026-09-07 14:20:14 -04:00
parent ddf2afdad3
commit 1bb74e4599
32 changed files with 849 additions and 62 deletions
+6 -2
View File
@@ -27,11 +27,14 @@
"invariant": "Headless startup installs and disposes push delivery; push and replay preserve the three-minute away policy, and host activity cannot extend the seven-day mobile lease.",
"oracle": "Require registration after RPC identity initialization and shutdown cleanup; idle 179/180/0 yields false/true/false for socket and replay, and exactly one push. Persisted lease expires exactly at seven days and only explicit registration renews it.",
"commands": [
"ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/mobile-notification-dismissal-store.test.ts src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts",
"ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/orcad/orcad-push-startup.test.ts src/main/runtime/push/push-policy-pipeline.integration.test.ts"
],
"testFiles": [
"src/main/orcad/orcad-push-startup.test.ts",
"src/main/runtime/push/push-policy-pipeline.integration.test.ts"
"src/main/runtime/push/push-policy-pipeline.integration.test.ts",
"src/main/runtime/mobile-notification-dismissal-store.test.ts",
"src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts"
],
"assertionRefs": [
{
@@ -79,7 +82,8 @@
"knownGaps": [
"Does not prove APNs silent background wakeup or actual operating-system idle transitions.",
"Rendererless agent/bell event generation remains outside the documented feature contract.",
"No live Windows or Linux policy evidence."
"No live Windows or Linux policy evidence.",
"Native iOS dismissal callback processing is verified separately; real APNs background wakeup is not established. Coalesced summaries require membership-aware dismissal before they can be cleared safely."
],
"demotionRule": "Keep experimental if lifecycle or policy assertions fail; do not weaken them to bypass platform delivery gaps."
},
+48 -8
View File
@@ -30,14 +30,18 @@ All schemas are zod, `.strict()`, exported from `cloud/packages/push-contract`.
The host keypair is X25519 (box), so it cannot sign. Reuse the relay's challenge shape.
`POST /v1/host/challenge`
```json
{ "v": 1, "hostPublicKeyB64": "<32 bytes b64>" }
```
→ 200
```json
{ "challengeId": "<opaque>", "gatewayEphemeralPublicKeyB64": "<32 b64>", "nonceB64": "<24 b64>",
"ciphertextB64": "<b64>", "expiresAt": <epoch ms> }
```
- Gateway generates an ephemeral box keypair per challenge, a 24-byte nonce, and a 32-byte secret.
- `plaintext = "orca-push-host-challenge/v1\0" || u32be(len(transcript)) || transcript || secret(32)`
- `ciphertext = nacl.box(plaintext, nonce, hostPublicKey, gatewayEphemeralSecretKey)`
@@ -57,16 +61,20 @@ The host keypair is X25519 (box), so it cannot sign. Reuse the relay's challenge
verifies, from the public key the challenge row carries.
`POST /v1/host/session`
```json
{ "v": 1, "challengeId": "<opaque>", "proofB64": "<32 b64>" }
```
- Host opens the box with its secret key, validates every transcript field (same checks as
`validateTranscript` in `src/main/runtime/relay/relay-host-proof.ts`, adapted to the push fields),
and returns `proof = HMAC-SHA256(secret, "orca-push-host-proof/v1\0ack\0" || transcript)`.
- Gateway verifies with `timingSafeEqual`, consumes the challenge (single use), and returns
```json
{ "sessionToken": "<opaque 32 b64url>", "expiresAt": <epoch ms>, "hostFingerprint": "<16 chars>" }
```
- Session TTL 24 h. Stored hashed (sha256) in DB. Bearer on every other call:
`Authorization: Bearer <sessionToken>`. 401 with `{ "error": "session_expired" }` on expiry; host
re-runs the challenge.
@@ -74,12 +82,14 @@ The host keypair is X25519 (box), so it cannot sign. Reuse the relay's challenge
### Device registration
`POST /v1/devices` (Bearer)
```json
{ "v": 1, "deviceId": "<uuid>", "platform": "ios" | "android", "token": "<native token>",
"apnsEnvironment": "sandbox" | "production", // ios only, required for ios
"filter": { "sources": ["agent-task-complete", "terminal-bell", "plugin"],
"agentStates": ["needs-input", "finished"] } }
```
→ 200 `{ "registrationId": "<opaque>" }`. Upsert keyed by (hostFingerprint, deviceId); a new token
replaces the old. `deviceId` is caller-chosen, so a host is capped at 64 registrations: the 65th
distinct `deviceId` → 409 `{ "error": "too_many_devices" }`. Re-registering a `deviceId` the host
@@ -96,6 +106,7 @@ tokens are FCM registration strings.
### Send
`POST /v1/send` (Bearer)
```json
{ "v": 1,
"registrationIds": ["<id>", "..."],
@@ -107,10 +118,13 @@ tokens are FCM registration strings.
"title": "<max 80 chars>", "body": "<max 180 chars>",
"worktreeId": "<max 2048 chars|absent>" } }
```
→ 200
```json
{ "results": [{ "registrationId": "<id>", "status": "queued" | "dead" | "rate_limited" | "error" }] }
```
- `queued` means the logical event, recipient, and pending delivery payload have committed to SQL. A
worker resumes pending work after restarts; provider acceptance is not proof of visible delivery.
- Each host gets 300 logical alerts and, independently, 300 dismissals per rolling 15 minutes. Fanout
@@ -168,18 +182,20 @@ promising exactly-once delivery.
APNs (HTTP/2, `api.push.apple.com` or `api.sandbox.push.apple.com` by `apnsEnvironment`; JWT auth
from key id + team id + `.p8`, token cached and refreshed every 50 min):
- headers: `apns-topic: com.stably.orca.mobile`, `apns-push-type: alert`, `apns-priority: 10`,
`apns-expiration: fixed event deadline (at most five minutes)`, `apns-collapse-id: <sha256(host + notification identity), or host:<fp>>`
- body: `{"aps":{"alert":{"title","body"},"sound":"default","thread-id":"<hostFingerprint>"},
"orca":{ hostFingerprint, worktreeId, notificationId, notificationSeq, notificationEpoch, source,
agentState, coalescedCount }}`
"orca":{ hostFingerprint, worktreeId, notificationId, notificationSeq, notificationEpoch, source,
agentState, coalescedCount }}`
- Dead token: 410, or 400 with `BadDeviceToken`/`Unregistered`/`DeviceTokenNotForTopic`.
FCM (V1 `projects/onorca-cloud/messages:send`, bearer from the runtime service account via the GCE
metadata server or `GOOGLE_APPLICATION_CREDENTIALS` locally):
- `{"message":{"token","notification":{"title","body"},"android":{"priority":"HIGH","ttl":"<remaining event lifetime, at most 300s>",
"collapse_key":"<sha256(collapseId) hex 32>","notification":{"channel_id":"orca-desktop","tag":"<collapseId>"}},
"data":{ all orca fields as strings }}}`
"collapse_key":"<sha256(collapseId) hex 32>","notification":{"channel_id":"orca-desktop","tag":"<collapseId>"}},
"data":{ all orca fields as strings }}}`
- Dead token: `UNREGISTERED`, or `INVALID_ARGUMENT` whose message names the token.
### Gateway storage (Postgres in prod, SQLite in tests, same pattern as `cloud/apps/relay/src/database.ts`)
@@ -190,10 +206,10 @@ metadata server or `GOOGLE_APPLICATION_CREDENTIALS` locally):
- `push_sessions` holds one row per host, enforced by a unique index and transaction lock. Minting a
session deletes the host's earlier one, since a desktop holds a single session and only re-proves once it is gone.
- `push_challenges(challenge_id pk, host_fingerprint, host_public_key, secret_hash, transcript,
expires_at, consumed_at)`
expires_at, consumed_at)`
- `push_sessions(token_hash pk, host_fingerprint, expires_at, created_at)`
- `push_devices(registration_id pk, host_fingerprint, device_id, platform, token, apns_environment,
filter_json, dead_at, created_at, updated_at, unique(host_fingerprint, device_id))`
filter_json, dead_at, created_at, updated_at, unique(host_fingerprint, device_id))`
- `push_send_log(host_fingerprint, registration_id, sent_at)` for quota, pruned after 25 h.
Logging: aggregate counters only. Never log tokens, titles, bodies, or raw fingerprints (log the first
@@ -217,13 +233,13 @@ Secret Manager names (already exist in `onorca-cloud`): `orca-cloud-push-apns-ke
- RPC `notifications.registerPush` params `{ platform, token, apnsEnvironment?, filter }` (same shapes
as the gateway `POST /v1/devices` minus deviceId, which comes from `ctx.pairedDeviceId`). Returns
`{ registered: true, registrationId } | { registered: false, reason: 'gateway_unreachable' |
'gateway_rejected' | 'not_mobile' | 'registration_storage_failed' | 'throttled' }`. A device may
'gateway_rejected' | 'not_mobile' | 'registration_storage_failed' | 'throttled' }`. A device may
register at most 10 times per minute (`throttled` beyond that, its earlier registration untouched):
each call is a gateway write plus a synchronous registry write on the main thread, and a paired
phone could otherwise loop it. The unregister RPC is not throttled, since with nothing registered it
is a lookup and with something registered it can only run once per successful register. The params
schema is strict, so a caller-supplied `deviceId` is an error, not a key silently dropped. Persists `pushRegistration:
{ registrationId, platform, filter, registeredAt }` on `DeviceEntry` in `device-registry.ts` (new
{ registrationId, platform, filter, registeredAt }` on `DeviceEntry` in `device-registry.ts` (new
optional field, tolerated by old registries). When the gateway accepted the token but the host could
not store it — the device left mobile scope mid-call (`not_mobile`) or the registry write threw
(`registration_storage_failed`) — the host queues the gateway delete in the unregister outbox rather
@@ -360,3 +376,27 @@ the wait without claiming delivery. Hosts without push registration keep local d
Native notification readers accept Expo's iOS `request.trigger.payload` as well as
`request.content.data`. APNs custom fields can exist only in the former; foreground deduplication,
tray replay suppression, dismissal, and tap routing all use the same reader.
### Dismissal recovery and desktop presence
Automatic acknowledgement of a visible agent pane requires confirmed desktop presence from the
same three-minute native idle check used for push delivery. A focused window alone is insufficient.
Trusted input in the renderer confirms the user has returned. Unknown presence preserves unread
attention; explicit mark-read actions remain available, including in browser clients.
The runtime persists notification identities and dismissal sequence fences in its own user-data
directory before fanout. History is bounded to 4,096 records retained for seven days. It stores no
notification text or push tokens. On reconnect, mobile optionally includes up to 256 `deliveredPushes`
identities in `notifications.getMissedSince`; updated hosts return optional `dismissedPushes` for
confirmed handled identities. This recovers dismissals after event replay eviction or host restart
within retained history. Unknown IDs, newer sequences and different epochs are preserved. Older
hosts ignore the optional request field, and older clients ignore the additional response field.
No new RPC method, stream opcode or gateway deployment is required.
On iOS, a local Expo module handles silent dismissals directly through the native notification
center, independent of JavaScript initialization. Native and JavaScript dismissal paths use the
same host/epoch/sequence fences; native watermarks retain up to 512 entries for 24 hours. Older
native shells and Android retain the JavaScript implementation. A native callback test proves
processing only when invoked: iOS background push delivery remains best-effort, including while
suspended or force-quit. Coalesced summaries are preserved because a single member's dismissal
cannot establish that every alert represented by the summary was handled.
@@ -0,0 +1,7 @@
{
"platforms": ["apple"],
"apple": {
"modules": ["OrcaNotificationDismissalModule"],
"appDelegateSubscribers": ["OrcaNotificationDismissalSubscriber"]
}
}
@@ -0,0 +1,15 @@
Pod::Spec.new do |s|
s.name = 'OrcaNotificationDismissal'
s.version = '0.0.1'
s.summary = 'Native notification dismissal and sequence fencing'
s.description = s.summary
s.license = { :type => 'MIT' }
s.author = 'Orca'
s.homepage = 'https://onorca.dev'
s.source = { :git => 'https://github.com/stablyai/orca.git' }
s.platforms = { :ios => '15.1' }
s.swift_version = '5.9'
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.source_files = '**/*.swift'
end
@@ -0,0 +1,14 @@
import ExpoModulesCore
public class OrcaNotificationDismissalModule: Module {
public func definition() -> ModuleDefinition {
Name("OrcaNotificationDismissal")
AsyncFunction("remember") { (payload: [String: Any]) in
if let identity = PushDismissalIdentity(payload) { PushDismissalLedger.shared.remember(identity) }
}
AsyncFunction("wasDismissed") { (payload: [String: Any]) -> Bool in
guard let identity = PushDismissalIdentity(payload) else { return false }
return PushDismissalLedger.shared.contains(identity)
}
}
}
@@ -0,0 +1,27 @@
import ExpoModulesCore
import UserNotifications
public class OrcaNotificationDismissalSubscriber: ExpoAppDelegateSubscriber {
public func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
guard let payload = userInfo["orca"] as? [String: Any],
payload["kind"] as? String == "dismiss", let fence = PushDismissalIdentity(payload)
else { completionHandler(.noData); return }
PushDismissalLedger.shared.remember(fence)
let center = UNUserNotificationCenter.current()
center.getDeliveredNotifications { notifications in
let ids = notifications.compactMap { notification -> String? in
guard let data = notification.request.content.userInfo["orca"] as? [String: Any],
((data["coalescedCount"] as? NSNumber)?.intValue ?? 1) <= 1,
let delivered = PushDismissalIdentity(data), fence.matches(delivered),
delivered.notificationSeq <= fence.notificationSeq else { return nil }
return notification.request.identifier
}
center.removeDeliveredNotifications(withIdentifiers: ids)
completionHandler(ids.isEmpty ? .noData : .newData)
}
}
}
@@ -0,0 +1,61 @@
import Foundation
import CoreFoundation
struct PushDismissalIdentity: Codable {
let hostFingerprint: String
let notificationId: String
let notificationEpoch: String
let notificationSeq: Int64
init?(_ value: [String: Any]) {
guard let host = value["hostFingerprint"] as? String, !host.isEmpty, host.count <= 512,
let id = value["notificationId"] as? String, !id.isEmpty, id.count <= 512,
let epoch = value["notificationEpoch"] as? String, !epoch.isEmpty, epoch.count <= 128,
let seq = value["notificationSeq"] as? NSNumber,
CFGetTypeID(seq) != CFBooleanGetTypeID(), seq.doubleValue.isFinite,
seq.doubleValue >= 0, seq.doubleValue <= 9_007_199_254_740_991,
seq.doubleValue.rounded(.down) == seq.doubleValue else { return nil }
hostFingerprint = host; notificationId = id; notificationEpoch = epoch
notificationSeq = seq.int64Value
}
func matches(_ other: PushDismissalIdentity) -> Bool {
hostFingerprint == other.hostFingerprint && notificationId == other.notificationId &&
notificationEpoch == other.notificationEpoch
}
}
final class PushDismissalLedger {
static let shared = PushDismissalLedger()
private struct Entry: Codable { let identity: PushDismissalIdentity; let expiresAt: TimeInterval }
private let defaults: UserDefaults
private let lock = NSLock()
private let storageKey = "orca.pushDismissals.v1"
init(defaults: UserDefaults = .standard) { self.defaults = defaults }
private func read(now: TimeInterval) -> [Entry] {
guard let data = defaults.data(forKey: storageKey),
let entries = try? JSONDecoder().decode([Entry].self, from: data) else { return [] }
return entries.filter { $0.expiresAt > now }
}
func remember(_ identity: PushDismissalIdentity, now: TimeInterval = Date().timeIntervalSince1970) {
lock.lock(); defer { lock.unlock() }
let entries = read(now: now)
let previous = entries.first { $0.identity.matches(identity) }
let newest = (previous?.identity.notificationSeq ?? -1) > identity.notificationSeq
? previous!.identity : identity
let next = entries.filter { !$0.identity.matches(identity) } +
[Entry(identity: newest, expiresAt: now + 86400)]
if let data = try? JSONEncoder().encode(Array(next.suffix(512))) {
defaults.set(data, forKey: storageKey)
}
}
func contains(_ identity: PushDismissalIdentity, now: TimeInterval = Date().timeIntervalSince1970) -> Bool {
lock.lock(); defer { lock.unlock() }
return read(now: now).contains {
$0.identity.matches(identity) && $0.identity.notificationSeq >= identity.notificationSeq
}
}
}
@@ -0,0 +1,5 @@
{
"name": "orca-notification-dismissal",
"version": "0.0.1",
"private": true
}
@@ -0,0 +1,23 @@
import Foundation
@main struct PushDismissalLedgerChecks {
static func main() {
let suite = "orca.qa.dismissal." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
func identity(_ seq: Int, _ host: String = "qa-host", _ epoch: String = "qa-epoch") -> PushDismissalIdentity {
PushDismissalIdentity(["hostFingerprint": host, "notificationId": "qa-alert", "notificationEpoch": epoch, "notificationSeq": seq])!
}
let ledger = PushDismissalLedger(defaults: defaults)
ledger.remember(identity(2), now: 100)
ledger.remember(identity(1), now: 101)
let restored = PushDismissalLedger(defaults: defaults)
precondition(restored.contains(identity(1), now: 102))
precondition(restored.contains(identity(2), now: 102))
precondition(!restored.contains(identity(3), now: 102))
precondition(!restored.contains(identity(1, "other"), now: 102))
precondition(!restored.contains(identity(1, "qa-host", "other"), now: 102))
precondition(!restored.contains(identity(1), now: 86501))
precondition(PushDismissalIdentity(["hostFingerprint":"h", "notificationId":"n", "notificationEpoch":"e", "notificationSeq":true]) == nil)
print("Native persisted fence: restart, ordering, identity isolation, expiry and invalid sequence checks passed")
}
}
@@ -1,3 +1,4 @@
import { requestNotificationCatchup } from './push-dismissal-reconciliation'
import { waitForSocketPushHandoff } from './socket-push-delivery-handoff'
import type { RpcClient } from '../transport/rpc-client'
export {
@@ -41,7 +42,6 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
let subscriptionId: string | null = null
let disposed = false
const deliveryAbort = new AbortController()
// Preserve the watermark across socket reconnects.
const session = getHostNotificationSession(hostId)
/**
@@ -148,14 +148,16 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
const askFrom = catchUpWatermarkSeq(session)
// Read concurrently; claim inside the queue after epoch adoption to avoid stale keys.
const presentedPushKeys = readPresentedPushSeenKeys(hostId)
const missed = await client
.sendRequest('notifications.getMissedSince', {
const missed = await requestNotificationCatchup(
client,
hostId,
{
lastSeenSeq: askFrom,
includeDesktopSuppressed: true,
// Why: sending the epoch lets the desktop reject a watermark from a counter
// it no longer has and return the whole retained buffer instead of nothing.
...(session.lastDeliveredEpoch != null ? { epoch: session.lastDeliveredEpoch } : {})
})
},
() => disposed
)
.then((response) => {
if (!response.ok) {
return null
@@ -0,0 +1,6 @@
import { requireOptionalNativeModule } from 'expo-modules-core'
import type { NativeDismissal } from './native-push-dismissal'
export const nativePushDismissal = requireOptionalNativeModule<NativeDismissal>(
'OrcaNotificationDismissal'
)
@@ -0,0 +1,8 @@
import type { OrcaPushPayload } from './push-payload'
export type NativeDismissal = {
remember(payload: OrcaPushPayload): Promise<void>
wasDismissed(payload: OrcaPushPayload): Promise<boolean>
}
// Web has no native notification center; native shells resolve the .native module.
export const nativePushDismissal: NativeDismissal | null = null
@@ -0,0 +1,82 @@
import { beforeEach, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { loadHostCatalog } from '../transport/host-store'
import { deriveHostFingerprint } from './push-host-fingerprint'
import { requestNotificationCatchup } from './push-dismissal-reconciliation'
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() }))
vi.mock('expo-notifications', () => ({
getPresentedNotificationsAsync: vi.fn(),
dismissNotificationAsync: vi.fn()
}))
vi.mock('@react-native-async-storage/async-storage', () => ({
default: { getItem: async () => null, setItem: async () => {} }
}))
const publicKeyB64 = Buffer.alloc(32, 1).toString('base64')
const hostFingerprint = deriveHostFingerprint(publicKeyB64)
const id = {
notificationId: 'old-alert',
notificationEpoch: 'previous-host-process',
notificationSeq: 12
}
function presented(identifier: string, overrides = {}) {
return { request: { identifier, content: { data: { hostFingerprint, ...id, ...overrides } } } }
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(loadHostCatalog).mockResolvedValue([{ id: 'host-a', publicKeyB64 }] as never)
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
presented('old'),
presented('new', { notificationSeq: 14 }),
presented('other', { hostFingerprint: 'other-host' }),
presented('summary', { coalescedCount: 2 })
] as never)
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
})
it('clears a confirmed prior-epoch alert even with empty replay and preserves newer, other-host and summary entries', async () => {
const sendRequest = vi.fn(async () => ({
ok: true,
result: { notifications: [], epoch: 'new-process', dismissedPushes: [id] }
}))
await requestNotificationCatchup(
{ sendRequest } as never,
'host-a',
{ lastSeenSeq: 20 },
() => false
)
expect(sendRequest).toHaveBeenCalledWith('notifications.getMissedSince', {
lastSeenSeq: 20,
deliveredPushes: [id, { ...id, notificationSeq: 14 }]
})
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledExactlyOnceWith('old')
})
it('keeps alerts when an old host omits reconciliation or the request fails', async () => {
for (const response of [{ ok: true, result: { notifications: [] } }, { ok: false }]) {
await requestNotificationCatchup(
{ sendRequest: async () => response } as never,
'host-a',
{ lastSeenSeq: 0 },
() => false
)
}
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled()
})
it('ignores unrequested identities and a response arriving after disconnect', async () => {
const sendRequest = vi.fn(async () => ({
ok: true,
result: { dismissedPushes: [{ ...id, notificationSeq: 99 }] }
}))
await requestNotificationCatchup(
{ sendRequest } as never,
'host-a',
{ lastSeenSeq: 0 },
() => false
)
sendRequest.mockResolvedValue({ ok: true, result: { dismissedPushes: [id] } })
await requestNotificationCatchup(
{ sendRequest } as never,
'host-a',
{ lastSeenSeq: 0 },
() => true
)
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled()
})
@@ -0,0 +1,92 @@
import * as Notifications from 'expo-notifications'
import type { RpcClient } from '../transport/rpc-client'
import { loadHostCatalog } from '../transport/host-store'
import { resolveHostIdForFingerprint } from './push-host-fingerprint'
import { readNativeNotificationData } from './native-notification-data'
import { readOrcaPushPayload, type OrcaPushPayload } from './push-payload'
import { dismissPresentedPushNotification } from './push-tray-dismissal'
type Identity = { notificationId: string; notificationEpoch: string; notificationSeq: number }
const key = (item: Identity) =>
JSON.stringify([item.notificationId, item.notificationEpoch, item.notificationSeq])
function identity(value: unknown): Identity | null {
if (!value || typeof value !== 'object') {
return null
}
const item = value as Identity
return typeof item.notificationId === 'string' &&
item.notificationId.length > 0 &&
item.notificationId.length <= 512 &&
typeof item.notificationEpoch === 'string' &&
item.notificationEpoch.length > 0 &&
item.notificationEpoch.length <= 128 &&
Number.isSafeInteger(item.notificationSeq) &&
item.notificationSeq >= 0
? {
notificationId: item.notificationId,
notificationEpoch: item.notificationEpoch,
notificationSeq: item.notificationSeq
}
: null
}
async function readDelivered(hostId: string): Promise<Map<string, OrcaPushPayload>> {
const selected = new Map<string, OrcaPushPayload>()
try {
const [presented, hosts] = await Promise.all([
Notifications.getPresentedNotificationsAsync(),
loadHostCatalog()
])
for (const notification of presented) {
const payload = readOrcaPushPayload(readNativeNotificationData(notification.request))
const id = identity(payload)
if (
!payload ||
!id ||
(payload.coalescedCount ?? 0) > 1 ||
resolveHostIdForFingerprint(payload.hostFingerprint, hosts) !== hostId
) {
continue
}
selected.set(key(id), payload)
if (selected.size === 256) {
break
}
}
} catch {
// Legacy shells can still use ordinary event replay without tray inspection.
}
return selected
}
export async function requestNotificationCatchup(
client: Pick<RpcClient, 'sendRequest'>,
hostId: string,
params: { lastSeenSeq: number; epoch?: string; includeDesktopSuppressed?: boolean },
isDisposed: () => boolean
) {
const delivered = await readDelivered(hostId)
const response = await client.sendRequest('notifications.getMissedSince', {
...params,
...(delivered.size
? { deliveredPushes: [...delivered.values()].map((payload) => identity(payload)!) }
: {})
})
if (!response.ok || isDisposed()) {
return response
}
const result = response.result as { dismissedPushes?: unknown } | undefined
// Older hosts ignore the optional request field and return no reconciliation result.
if (Array.isArray(result?.dismissedPushes)) {
for (const raw of result.dismissedPushes.slice(0, 256)) {
if (isDisposed()) {
break
}
const id = identity(raw)
const payload = id ? delivered.get(key(id)) : undefined
if (payload && id) {
await dismissPresentedPushNotification(id.notificationId, payload.hostFingerprint, id)
}
}
}
return response
}
@@ -1,5 +1,6 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { OrcaPushPayload } from './push-payload'
import { nativePushDismissal } from './native-push-dismissal'
const STORAGE_KEY = 'orca:pushDismissalWatermarks:v1'
const RETENTION_MS = 24 * 60 * 60 * 1000
@@ -45,10 +46,18 @@ async function readEntries(): Promise<Entry[]> {
}
}
export function rememberPushDismissal(payload: OrcaPushPayload): Promise<void> {
export async function rememberPushDismissal(payload: OrcaPushPayload): Promise<void> {
const key = eventKey(payload)
if (!key) {
return Promise.resolve()
return
}
if (nativePushDismissal) {
try {
await nativePushDismissal.remember(payload)
return
} catch {
// Keep recovery available if the native bridge is unavailable during reload.
}
}
const pending = writes.then(async () => {
const entries = await readEntries()
@@ -77,6 +86,9 @@ export async function wasPushDismissed(payload: OrcaPushPayload): Promise<boolea
return false
}
await writes
if (nativePushDismissal && (await nativePushDismissal.wasDismissed(payload).catch(() => false))) {
return true
}
return (await readEntries()).some(
(entry) => entry.key === key && entry.seq >= payload.notificationSeq!
)
+4 -1
View File
@@ -1,4 +1,5 @@
import { BrowserWindow, Notification, ipcMain } from 'electron'
import { BrowserWindow, Notification, ipcMain, powerMonitor } from 'electron'
import { readDesktopAwayState } from '../notifications/desktop-away-state'
import type { Store } from '../persistence'
import type {
NotificationDeliveryProbeResult,
@@ -26,6 +27,8 @@ import {
} from './notification-permission-probe'
export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntimeService): void {
ipcMain.removeHandler('notifications:getDesktopAwayState')
ipcMain.handle('notifications:getDesktopAwayState', () => readDesktopAwayState(powerMonitor))
const recentDesktopNotifications = new Map<string, number>()
const recentMobileNotifications = new Map<string, number>()
resetNotificationPermissionEvidence()
@@ -0,0 +1,57 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { MobileNotificationDismissalStore } from './mobile-notification-dismissal-store'
const paths: string[] = []
afterEach(() => {
paths.splice(0).forEach((path) => rmSync(path, { recursive: true, force: true }))
vi.restoreAllMocks()
})
function fixture() {
const path = mkdtempSync(join(tmpdir(), 'orca-dismissals-'))
paths.push(path)
return { path, store: new MobileNotificationDismissalStore(path) }
}
const shown = { notificationId: 'same', notificationEpoch: 'old', notificationSeq: 12 }
const alert = {
type: 'notification' as const,
source: 'terminal-bell' as const,
title: 'QA',
body: ''
}
it('reconciles an old delivered alert after desktop restart and preserves unrelated identities', () => {
const h = fixture()
h.store.record({ ...alert, ...shown })
const restarted = new MobileNotificationDismissalStore(h.path)
restarted.record({
type: 'dismiss',
notificationId: 'same',
notificationEpoch: 'new',
notificationSeq: 1
})
const loaded = new MobileNotificationDismissalStore(h.path)
expect(
loaded.reconcile([
shown,
{ ...shown, notificationEpoch: 'other' },
{ ...shown, notificationId: 'other' },
{ ...shown, notificationSeq: 13 }
])
).toEqual([shown])
})
it('does not dismiss a newer replacement and does not treat missing or expired history as dismissal', () => {
const h = fixture()
const now = Date.now()
vi.spyOn(Date, 'now').mockReturnValue(now)
h.store.record({ ...alert, ...shown })
h.store.record({ type: 'dismiss', ...shown, notificationSeq: 13 })
expect(h.store.reconcile([shown])).toEqual([shown])
h.store.record({ ...alert, ...shown, notificationSeq: 14 })
expect(h.store.reconcile([{ ...shown, notificationSeq: 14 }])).toEqual([])
expect(h.store.reconcile([shown])).toEqual([shown])
h.store.record({ type: 'dismiss', ...shown, notificationSeq: 15 })
vi.mocked(Date.now).mockReturnValue(now + 7 * 86400_000)
expect(h.store.reconcile([shown])).toEqual([])
expect(new MobileNotificationDismissalStore(`${h.path}-unknown`).reconcile([shown])).toEqual([])
})
@@ -0,0 +1,109 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { writeSecureJsonFile, hardenExistingSecureFile } from '../../shared/secure-file'
import type { MobileNotificationEvent } from './runtime-mobile-notification-controller'
export type DeliveredNotificationIdentity = {
notificationId: string
notificationEpoch: string
notificationSeq: number
}
type RecordEntry = DeliveredNotificationIdentity & { dismissedThrough: number; expiresAt: number }
const LIMIT = 4096
const RETENTION_MS = 7 * 86400_000
export class MobileNotificationDismissalStore {
private readonly path: string
private entries: RecordEntry[] = []
constructor(userDataPath: string) {
this.path = join(userDataPath, 'mobile-notification-dismissals.json')
if (!existsSync(this.path)) {
return
}
try {
hardenExistingSecureFile(this.path)
const value: unknown = JSON.parse(readFileSync(this.path, 'utf8'))
if (Array.isArray(value)) {
this.entries = value.filter(isEntry).slice(-LIMIT)
}
} catch {
// Missing history cannot establish that a delivered alert was dismissed.
}
}
record(
event: MobileNotificationEvent & { notificationEpoch: string; notificationSeq: number }
): void {
if (!event.notificationId) {
return
}
const now = Date.now()
const kept = this.entries.filter((entry) => entry.expiresAt > now)
const same = (entry: RecordEntry) =>
entry.notificationId === event.notificationId &&
entry.notificationEpoch === event.notificationEpoch
let next: RecordEntry[]
if (event.type === 'notification') {
next = [
...kept.filter((entry) => !same(entry)),
{
notificationId: event.notificationId,
notificationEpoch: event.notificationEpoch,
notificationSeq: event.notificationSeq,
dismissedThrough: kept.find(same)?.dismissedThrough ?? -1,
expiresAt: now + RETENTION_MS
}
]
} else {
next = kept
.filter((entry) => !same(entry))
.map((entry) =>
entry.notificationId === event.notificationId
? { ...entry, dismissedThrough: entry.notificationSeq, expiresAt: now + RETENTION_MS }
: entry
)
next.push({
notificationId: event.notificationId,
notificationEpoch: event.notificationEpoch,
notificationSeq: event.notificationSeq,
dismissedThrough: event.notificationSeq,
expiresAt: now + RETENTION_MS
})
}
next = next.slice(-LIMIT)
writeSecureJsonFile(this.path, next)
this.entries = next
}
reconcile(delivered: readonly DeliveredNotificationIdentity[]): DeliveredNotificationIdentity[] {
const now = Date.now()
return delivered.filter((item) =>
this.entries.some(
(entry) =>
entry.dismissedThrough >= 0 &&
entry.expiresAt > now &&
entry.notificationId === item.notificationId &&
entry.notificationEpoch === item.notificationEpoch &&
entry.dismissedThrough >= item.notificationSeq
)
)
}
}
function isEntry(value: unknown): value is RecordEntry {
if (!value || typeof value !== 'object') {
return false
}
const item = value as RecordEntry
return (
typeof item.notificationId === 'string' &&
item.notificationId.length > 0 &&
typeof item.notificationEpoch === 'string' &&
item.notificationEpoch.length > 0 &&
Number.isSafeInteger(item.notificationSeq) &&
item.notificationSeq >= 0 &&
Number.isSafeInteger(item.dismissedThrough) &&
item.dismissedThrough >= -1 &&
Number.isFinite(item.expiresAt)
)
}
+15 -2
View File
@@ -34,7 +34,17 @@ const NotificationUnsubscribeParams = z.object({
const NotificationGetMissedSinceParams = z.object({
lastSeenSeq: z.number().int().min(0, 'lastSeenSeq must be a non-negative integer'),
epoch: z.string().optional(),
includeDesktopSuppressed: z.boolean().optional()
includeDesktopSuppressed: z.boolean().optional(),
deliveredPushes: z
.array(
z.object({
notificationId: z.string().min(1).max(512),
notificationEpoch: z.string().min(1).max(128),
notificationSeq: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER)
})
)
.max(256)
.optional()
})
// Why: the phone owns which alerts are worth waking it for; the host stores the
@@ -122,7 +132,10 @@ export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [
notifications: missed.filter(
createNotificationStreamFilter(params.includeDesktopSuppressed)
),
epoch: runtime.getMobileNotificationEpoch()
epoch: runtime.getMobileNotificationEpoch(),
...(params.deliveredPushes
? { dismissedPushes: runtime.reconcileDismissedPushes(params.deliveredPushes) }
: {})
}
}
}),
@@ -6,6 +6,10 @@ import type {
import { MobileNotificationReplayBuffer } from './mobile-notification-replay'
import { notifyRuntimeListeners } from './runtime-async-boundaries'
import { getRuntimeDesktopSurface } from './runtime-desktop-surface'
import {
MobileNotificationDismissalStore,
type DeliveredNotificationIdentity
} from './mobile-notification-dismissal-store'
export type MobileNotificationDispatchEvent = {
type: 'notification'
@@ -45,6 +49,17 @@ export class RuntimeMobileNotificationController {
private readonly listeners = new Set<(event: MobileNotificationEvent) => void>()
private readonly replay = new MobileNotificationReplayBuffer()
private pushRegistrar: MobilePushRegistrar | null = null
private dismissalStore: MobileNotificationDismissalStore | null = null
configureDismissalStore(userDataPath: string): void {
this.dismissalStore = new MobileNotificationDismissalStore(userDataPath)
}
reconcileDismissedPushes(
delivered: readonly DeliveredNotificationIdentity[]
): DeliveredNotificationIdentity[] {
return this.dismissalStore?.reconcile(delivered) ?? []
}
setPushRegistrar(registrar: MobilePushRegistrar | null): void {
this.pushRegistrar = registrar
@@ -77,6 +92,15 @@ export class RuntimeMobileNotificationController {
event = { ...event, desktopAway: getRuntimeDesktopSurface().isAwayForMobileNotifications?.() }
}
const seq = this.replay.record(event)
try {
this.dismissalStore?.record({
...event,
notificationSeq: seq,
notificationEpoch: this.replay.epoch
})
} catch {
console.warn('[notifications] Could not persist dismissal recovery state')
}
notifyRuntimeListeners(
this.listeners,
(listener) =>
@@ -117,6 +117,7 @@ export function createMobileRpcSurfaceRuntime() {
.fn()
.mockResolvedValue({ ok: true, id: 'comment-1' })
const runtime = {
configureNotificationDismissalStore: () => {},
getRuntimeId: () => 'test-runtime',
getStatus,
pushRuntimeGit,
@@ -132,5 +132,6 @@ export class RuntimeRpcState {
this.specializedLongPollCap = Math.max(1, Math.floor(longPollCap * SPECIALIZED_LONG_POLL_SHARE))
this.relayRevokeOutbox = new RelayRevokeOutbox(userDataPath)
this.pushUnregisterOutbox = new PushUnregisterOutbox(userDataPath)
this.runtime.configureNotificationDismissalStore(userDataPath)
}
}
@@ -27,6 +27,8 @@ export type RuntimeServiceCommandSurface = {
getMobileNotificationListenerCount: RuntimeMobileNotificationController['getListenerCount']
dispatchMobileNotification: RuntimeMobileNotificationController['dispatch']
getMissedNotificationsSince: RuntimeMobileNotificationController['getMissedSince']
configureNotificationDismissalStore: RuntimeMobileNotificationController['configureDismissalStore']
reconcileDismissedPushes: RuntimeMobileNotificationController['reconcileDismissedPushes']
getMobileNotificationEpoch: RuntimeMobileNotificationController['getEpoch']
dismissMobileNotification: RuntimeMobileNotificationController['dismiss']
dispatchPluginNotification: RuntimeMobileNotificationController['dispatchPlugin']
@@ -110,6 +112,8 @@ export function installRuntimeServiceCommandSurface(
getMobileNotificationListenerCount: notifications.getListenerCount.bind(notifications),
dispatchMobileNotification: notifications.dispatch.bind(notifications),
getMissedNotificationsSince: notifications.getMissedSince.bind(notifications),
configureNotificationDismissalStore: notifications.configureDismissalStore.bind(notifications),
reconcileDismissedPushes: notifications.reconcileDismissedPushes.bind(notifications),
getMobileNotificationEpoch: notifications.getEpoch.bind(notifications),
dismissMobileNotification: notifications.dismiss.bind(notifications),
dispatchPluginNotification: notifications.dispatchPlugin.bind(notifications),
+2
View File
@@ -37,6 +37,8 @@ function disposeCachedNotificationSound(): void {
}
export const notificationsApi = {
getDesktopAwayState: (): Promise<boolean | undefined> =>
ipcRenderer.invoke('notifications:getDesktopAwayState'),
dispatch: (args: Record<string, unknown>): Promise<NotificationDispatchResult> =>
ipcRenderer.invoke('notifications:dispatch', args),
dismiss: (ids: string[]): Promise<NotificationDismissResult> =>
+1
View File
@@ -20,6 +20,7 @@ import type {
} from '../../shared/notification-settings-types'
export type NotificationsApi = {
getDesktopAwayState: () => Promise<boolean | undefined>
dispatch: (args: NotificationDispatchRequest) => Promise<NotificationDispatchResult>
dismiss: (ids: string[]) => Promise<NotificationDismissResult>
openSystemSettings: () => Promise<void>
@@ -0,0 +1,53 @@
export function createAutoAckPresenceCheck(
readAway: () => Promise<boolean | undefined>,
onPresent: () => void
): { request: () => void; dispose: () => void } {
let disposed = false
let pending = false
return {
request() {
if (disposed || pending) {
return
}
pending = true
void readAway()
.then((away) => {
// Unknown presence must not clear unread attention.
if (!disposed && away === false) {
onPresent()
}
})
.catch(() => {})
.finally(() => {
pending = false
})
},
dispose() {
disposed = true
}
}
}
export function subscribeAutoAckPresenceSignals(
onRescan: () => void,
onInput: () => void
): () => void {
const input = (event: Event): void => {
if (event.isTrusted) {
onInput()
}
}
document.addEventListener('visibilitychange', onRescan)
window.addEventListener('focus', onRescan)
const events = ['pointerdown', 'keydown', 'pointermove'] as const
for (const event of events) {
window.addEventListener(event, input)
}
return () => {
document.removeEventListener('visibilitychange', onRescan)
window.removeEventListener('focus', onRescan)
for (const event of events) {
window.removeEventListener(event, input)
}
}
}
@@ -0,0 +1,34 @@
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
export type AutoAckTabTarget = { tabId: string; worktreeId: string | null }
/**
* Tabs whose visible pane counts as "seen" right now, each paired with the worktree that owns it.
*
* Why the floating workspace is gated on panel visibility rather than `activeView`: the panel is an
* overlay that sits above every view and stays mounted while closed, and its active tab never
* becomes the global `activeTabId` — so neither the view nor the tab id can stand in for "on screen".
*/
export function resolveAutoAckTabTargets(
state: {
activeView: string
activeTabId: string | null
activeWorktreeId: string | null
activeTabIdByWorktree: Record<string, string | null>
},
options: { floatingPanelVisible: boolean }
): AutoAckTabTarget[] {
const targets: AutoAckTabTarget[] = []
if (state.activeView === 'terminal' && state.activeTabId) {
targets.push({ tabId: state.activeTabId, worktreeId: state.activeWorktreeId })
}
if (options.floatingPanelVisible) {
const floatingTabId = state.activeTabIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? null
// Why first-wins on a tab-id collision: tab ids can be claimed by two worktrees
// (see active-tab-owner-worktree), and acking under the wrong one strands its unread dot.
if (floatingTabId && !targets.some((target) => target.tabId === floatingTabId)) {
targets.push({ tabId: floatingTabId, worktreeId: FLOATING_TERMINAL_WORKTREE_ID })
}
}
return targets
}
@@ -0,0 +1,86 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { useAutoAckViewedAgent } from './useAutoAckViewedAgent'
import { useAppStore } from '../store'
import { makeTab } from '../store/slices/store-test-helpers'
import { makePaneKey } from '../../../shared/stable-pane-id'
const leaf = '11111111-1111-4111-8111-111111111111'
const pane = makePaneKey('away-tab', leaf)
const readAway = vi.fn<() => Promise<boolean | undefined>>()
const dismiss = vi.fn()
const previousApi = window.api
beforeEach(() => {
readAway.mockReset().mockResolvedValue(true)
dismiss.mockReset()
Object.assign(window, { api: { notifications: { getDesktopAwayState: readAway, dismiss } } })
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
useAppStore.setState({
activeView: 'terminal',
activeTabId: 'away-tab',
activeWorktreeId: 'away-workspace',
activeTabIdByWorktree: {},
tabsByWorktree: {
'away-workspace': [makeTab({ id: 'away-tab', worktreeId: 'away-workspace' })]
},
terminalLayoutsByTabId: {
'away-tab': { root: null, activeLeafId: leaf, expandedLeafId: null }
},
agentStatusByPaneKey: {},
retainedAgentsByPaneKey: {},
acknowledgedAgentsByPaneKey: {},
unreadAgentCompletionPanes: {},
unreadTerminalTabs: {},
manuallyUnreadTurnsByPaneKey: {}
})
useAppStore
.getState()
.setAgentStatus(pane, { state: 'done', prompt: 'away test', agentType: 'codex' })
useAppStore.getState().markAgentCompletionPaneUnread(pane)
})
afterEach(() => {
cleanup()
Object.assign(window, { api: previousApi })
vi.restoreAllMocks()
})
it('leaves the focused pane unread while desktop is away, then acknowledges on user return', async () => {
renderHook(() => useAutoAckViewedAgent(false))
await act(async () => {
await Promise.resolve()
})
expect(useAppStore.getState().unreadAgentCompletionPanes[pane]).toBe(true)
expect(dismiss).not.toHaveBeenCalled()
readAway.mockResolvedValue(false)
const input = new Event('pointerdown')
Object.defineProperty(input, 'isTrusted', { value: true })
act(() => window.dispatchEvent(input))
await waitFor(() =>
expect(useAppStore.getState().unreadAgentCompletionPanes[pane]).toBeUndefined()
)
expect(dismiss).toHaveBeenCalledTimes(1)
})
it('does not acknowledge when the presence query fails or the hook unmounts', async () => {
let resolve!: (away: boolean) => void
readAway.mockImplementation(
() =>
new Promise((r) => {
resolve = r
})
)
const hook = renderHook(() => useAutoAckViewedAgent(false))
hook.unmount()
await act(async () => {
resolve(false)
})
expect(useAppStore.getState().unreadAgentCompletionPanes[pane]).toBe(true)
readAway.mockRejectedValue(new Error('unavailable'))
renderHook(() => useAutoAckViewedAgent(false))
await act(async () => {
await Promise.resolve()
})
expect(useAppStore.getState().unreadAgentCompletionPanes[pane]).toBe(true)
expect(dismiss).not.toHaveBeenCalled()
})
@@ -2,6 +2,7 @@
import { cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as AgentAutoAckPresence from './agent-auto-ack-presence'
import { useAutoAckViewedAgent } from './useAutoAckViewedAgent'
import { useAppStore } from '../store'
import { makeTab } from '../store/slices/store-test-helpers'
@@ -13,6 +14,15 @@ import type { AgentStatusEntry } from '../../../shared/agent-status-types'
// acknowledgeAgents returned the same object within one millisecond — a scan costing >=1ms with a
// turn stamped ahead of the local clock (SSH/remote host) re-acked forever (React #185).
// These suites isolate synchronous acknowledgement and layout behavior.
vi.mock('./agent-auto-ack-presence', async (importOriginal) => ({
...(await importOriginal<typeof AgentAutoAckPresence>()),
createAutoAckPresenceCheck: (_read: unknown, onPresent: () => void) => ({
request: onPresent,
dispose() {}
})
}))
const TAB_ID = 'tab-main'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID)
@@ -2,6 +2,7 @@
import { cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as AgentAutoAckPresence from './agent-auto-ack-presence'
import { useAutoAckViewedAgent } from './useAutoAckViewedAgent'
import { useAppStore } from '../store'
import { selectFloatingWorkspaceHasUnread } from '../store/selectors'
@@ -9,6 +10,15 @@ import { makeTab } from '../store/slices/store-test-helpers'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
import { makePaneKey } from '../../../shared/stable-pane-id'
// These suites isolate synchronous acknowledgement and layout behavior.
vi.mock('./agent-auto-ack-presence', async (importOriginal) => ({
...(await importOriginal<typeof AgentAutoAckPresence>()),
createAutoAckPresenceCheck: (_read: unknown, onPresent: () => void) => ({
request: onPresent,
dispose() {}
})
}))
const FLOATING_TAB_ID = 'tab-floating'
const MAIN_TAB_ID = 'tab-main'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
+21 -41
View File
@@ -1,4 +1,10 @@
import { resolveAutoAckTabTargets } from './agent-auto-ack-targets'
export { resolveAutoAckTabTargets, type AutoAckTabTarget } from './agent-auto-ack-targets'
import { useEffect, useRef } from 'react'
import {
createAutoAckPresenceCheck,
subscribeAutoAckPresenceSignals
} from './agent-auto-ack-presence'
import { useAppStore } from '@/store'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
@@ -189,39 +195,6 @@ export function acknowledgeViewedAgentAttention(
}
}
export type AutoAckTabTarget = { tabId: string; worktreeId: string | null }
/**
* Tabs whose visible pane counts as "seen" right now, each paired with the worktree that owns it.
*
* Why the floating workspace is gated on panel visibility rather than `activeView`: the panel is an
* overlay that sits above every view and stays mounted while closed, and its active tab never
* becomes the global `activeTabId` — so neither the view nor the tab id can stand in for "on screen".
*/
export function resolveAutoAckTabTargets(
state: {
activeView: string
activeTabId: string | null
activeWorktreeId: string | null
activeTabIdByWorktree: Record<string, string | null>
},
options: { floatingPanelVisible: boolean }
): AutoAckTabTarget[] {
const targets: AutoAckTabTarget[] = []
if (state.activeView === 'terminal' && state.activeTabId) {
targets.push({ tabId: state.activeTabId, worktreeId: state.activeWorktreeId })
}
if (options.floatingPanelVisible) {
const floatingTabId = state.activeTabIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? null
// Why first-wins on a tab-id collision: tab ids can be claimed by two worktrees
// (see active-tab-owner-worktree), and acking under the wrong one strands its unread dot.
if (floatingTabId && !targets.some((target) => target.tabId === floatingTabId)) {
targets.push({ tabId: floatingTabId, worktreeId: FLOATING_TERMINAL_WORKTREE_ID })
}
}
return targets
}
// Auto-ack an agent row as "seen" when the user is already on its tab, so the dashboard/Dock don't stay bold for an event they watched happen.
// Scans live + retained maps: Codex's title-revert (pty-connection.ts:onAgentExited) migrates `done` rows to retained mid-race — see docs/codex-agent-row-bold-stuck.md.
export function useAutoAckViewedAgent(floatingPanelVisible: boolean): void {
@@ -243,7 +216,11 @@ export function useAutoAckViewedAgent(floatingPanelVisible: boolean): void {
let lastUnreadAgentCompletionPanes: unknown = undefined
// `force` re-scans after a signal the store never sees: panel open/closed is React-local state.
const maybeAck = (options?: { force?: boolean }): void => {
const presence = createAutoAckPresenceCheck(
async () => window.api?.notifications?.getDesktopAwayState?.(),
() => maybeAck({ force: true, presenceConfirmed: true })
)
const maybeAck = (options?: { force?: boolean; presenceConfirmed?: boolean }): void => {
const s = useAppStore.getState()
const floatingWorkspaceActiveTabId =
s.activeTabIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? null
@@ -279,6 +256,10 @@ export function useAutoAckViewedAgent(floatingPanelVisible: boolean): void {
if (targets.length === 0) {
return
}
if (!options?.presenceConfirmed) {
presence.request()
return
}
// Why: advance refs only after gates pass, else the diff is consumed and a gated-out transition never re-acks when focus returns.
lastActiveView = s.activeView
lastActiveTabId = s.activeTabId
@@ -341,16 +322,15 @@ export function useAutoAckViewedAgent(floatingPanelVisible: boolean): void {
maybeAck()
// Subscribe to all store changes; the ref-equality guard above skips unrelated updates.
const unsubscribe = useAppStore.subscribe(() => maybeAck())
// Why: focus/visibility don't flow through zustand, so re-run the scan on these DOM events when focus returns.
const onVisibility = (): void => maybeAck()
const onFocus = (): void => maybeAck()
document.addEventListener('visibilitychange', onVisibility)
window.addEventListener('focus', onFocus)
const stopPresenceSignals = subscribeAutoAckPresenceSignals(
() => maybeAck(),
() => maybeAck({ presenceConfirmed: true })
)
return () => {
presence.dispose()
rescanRef.current = null
unsubscribe()
document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('focus', onFocus)
stopPresenceSignals()
}
}, [])
@@ -3,6 +3,7 @@ import { getBrowserPlatform } from './web-storage'
export function createNotificationsApi(): NonNullable<Partial<PreloadApi>['notifications']> {
return {
getDesktopAwayState: async () => undefined,
dispatch: () => Promise.resolve({ delivered: false, reason: 'not-supported' }),
dismiss: () => Promise.resolve({ dismissed: 0 }),
openSystemSettings: () => Promise.resolve(),