From ff002f64fe9b7e1463833f5e3c90387ce2e4f5e9 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 06:51:28 -0400 Subject: [PATCH 1/6] feat: unify attention across desktop and iOS --- .gitignore | 4 + NOTICE | 36 +- apps/ade-cli/README.md | 2 + apps/ade-cli/src/adeRpcServer.test.ts | 60 + apps/ade-cli/src/bootstrap.ts | 42 +- apps/ade-cli/src/cli.test.ts | 22 +- apps/ade-cli/src/cli.ts | 24 +- .../ade-cli/src/multiProjectRpcServer.test.ts | 132 + apps/ade-cli/src/multiProjectRpcServer.ts | 133 +- .../push/pushPublisherService.test.ts | 157 +- .../src/services/push/pushPublisherService.ts | 407 ++- .../src/services/push/pushRelayClient.ts | 134 +- .../native/ADEAttentionNotch/DESIGN_NOTES.md | 43 + .../native/ADEAttentionNotch/Package.swift | 36 + .../ADEAttentionNotchApp.swift | 58 + .../NotchPanelController.swift | 379 +++ .../NotchStatusItemController.swift | 57 + .../ADEAttentionNotch/NotchSurfaceView.swift | 827 ++++++ .../ADEAttentionNotch/NotchViewModel.swift | 273 ++ .../ADEAttentionNotch/ProtocolTransport.swift | 46 + .../Resources/ProviderIcons/claude.svg | 1 + .../Resources/ProviderIcons/cursor.svg | 1 + .../Resources/ProviderIcons/github.svg | 1 + .../Resources/ProviderIcons/openai.svg | 1 + .../Resources/ProviderIcons/opencode.svg | 1 + .../AttentionModels.swift | 477 ++++ .../ADEAttentionNotchCore/NotchGeometry.swift | 72 + .../NotchInteractionState.swift | 213 ++ .../ADEAttentionNotch/THIRD_PARTY_NOTICES.md | 29 + .../NotchGeometryTests.swift | 47 + .../NotchInteractionStateTests.swift | 65 + .../NotchProtocolTests.swift | 165 ++ apps/desktop/package.json | 33 +- apps/desktop/resources/native/README.md | 11 + .../desktop/scripts/build-attention-notch.mjs | 92 + apps/desktop/src/main/main.ts | 239 +- .../main/services/adeActions/registry.test.ts | 5 + .../src/main/services/adeActions/registry.ts | 114 + .../attention/attentionNotchHelper.test.ts | 208 ++ .../attention/attentionNotchHelper.ts | 281 ++ .../attention/attentionNotchRouter.test.ts | 222 ++ .../attention/attentionNotchRouter.ts | 368 +++ .../src/main/services/ipc/registerIpc.ts | 171 ++ .../main/services/ipc/runtimeBridge.test.ts | 81 + .../src/main/services/ipc/runtimeBridge.ts | 90 +- .../localRuntimeConnectionPool.test.ts | 24 + .../localRuntimeConnectionPool.ts | 7 + apps/desktop/src/preload/global.d.ts | 37 + apps/desktop/src/preload/preload.test.ts | 106 + apps/desktop/src/preload/preload.ts | 56 + .../src/renderer/components/app/App.tsx | 24 +- .../src/renderer/components/app/AppShell.tsx | 8 +- .../src/renderer/components/app/TabNav.tsx | 26 +- .../components/attention/AttentionCenter.css | 1724 ++++++++++++ .../attention/AttentionCenter.test.tsx | 484 ++++ .../components/attention/AttentionCenter.tsx | 1150 ++++++++ .../attention/AttentionSettingsPopover.tsx | 424 +++ .../attention/attentionNotchLocalSettings.ts | 64 + .../attention/attentionPresentation.ts | 65 + .../attention/useAttentionSync.hook.test.tsx | 390 +++ .../attention/useAttentionSync.test.ts | 118 + .../components/attention/useAttentionSync.ts | 420 +++ .../src/renderer/state/attentionStore.test.ts | 334 +++ .../src/renderer/state/attentionStore.ts | 379 +++ apps/desktop/src/shared/ipc.ts | 9 + .../src/shared/types/attention.test.ts | 81 + apps/desktop/src/shared/types/attention.ts | 323 +++ apps/desktop/src/shared/types/index.ts | 1 + apps/ios/ADE.xcodeproj/project.pbxproj | 2 + apps/ios/ADE/App/ADEApp.swift | 1 + apps/ios/ADE/App/ContentView.swift | 12 +- apps/ios/ADE/App/DeepLinkRouter.swift | 143 +- apps/ios/ADE/Info.plist | 2 + apps/ios/ADE/Services/AccountDirectory.swift | 262 ++ apps/ios/ADE/Services/AccountService.swift | 711 ++++- .../ADE/Services/LiveActivityService.swift | 213 +- .../Services/PushNotificationService.swift | 182 +- apps/ios/ADE/Services/SyncService.swift | 128 +- .../Shared/ADEAgentActivityAttributes.swift | 96 +- apps/ios/ADE/Shared/ADESharedContainer.swift | 97 +- apps/ios/ADE/Shared/ADESharedModels.swift | 513 +++- apps/ios/ADE/Shared/ADESharedTheme.swift | 78 +- .../AttentionDrawerModel.swift | 482 +++- .../AttentionDrawerSheet.swift | 929 +++++-- apps/ios/ADE/Views/PRs/PrsRootScreen.swift | 4 + .../Settings/ConnectionSettingsView.swift | 7 + .../SettingsPushDeliverySection.swift | 36 +- .../Views/Work/WorkRootScreen+Actions.swift | 4 + apps/ios/ADETests/ADETests.swift | 149 +- .../ADETests/AttentionDrawerModelTests.swift | 403 +++ apps/ios/ADETests/PairingAndDpopTests.swift | 613 +++++ .../ADEWidgets/ADEAgentActivityWidget.swift | 488 +++- apps/ios/ADEWidgets/ADELockScreenWidget.swift | 323 ++- apps/push-relay/README.md | 69 +- .../migrations/0003_account_attention.sql | 190 ++ apps/push-relay/package-lock.json | 12 + apps/push-relay/package.json | 3 + apps/push-relay/src/attention.ts | 2443 +++++++++++++++++ apps/push-relay/src/relay.ts | 28 + apps/push-relay/test/attention.test.ts | 1078 ++++++++ docs/ARCHITECTURE.md | 6 +- .../sync-and-multi-device/ios-companion.md | 151 +- .../push-notifications.md | 489 ++-- 103 files changed, 21569 insertions(+), 892 deletions(-) create mode 100644 apps/desktop/native/ADEAttentionNotch/DESIGN_NOTES.md create mode 100644 apps/desktop/native/ADEAttentionNotch/Package.swift create mode 100644 apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/ADEAttentionNotchApp.swift create mode 100644 apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchPanelController.swift create mode 100644 apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchStatusItemController.swift create mode 100644 apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchSurfaceView.swift create mode 100644 apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchViewModel.swift create mode 100644 apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/ProtocolTransport.swift create mode 100644 apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/Resources/ProviderIcons/claude.svg create mode 100644 apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/Resources/ProviderIcons/cursor.svg create mode 100644 apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/Resources/ProviderIcons/github.svg create mode 100644 apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/Resources/ProviderIcons/openai.svg create mode 100644 apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/Resources/ProviderIcons/opencode.svg create mode 100644 apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/AttentionModels.swift create mode 100644 apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchGeometry.swift create mode 100644 apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchInteractionState.swift create mode 100644 apps/desktop/native/ADEAttentionNotch/THIRD_PARTY_NOTICES.md create mode 100644 apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchGeometryTests.swift create mode 100644 apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchInteractionStateTests.swift create mode 100644 apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchProtocolTests.swift create mode 100644 apps/desktop/resources/native/README.md create mode 100644 apps/desktop/scripts/build-attention-notch.mjs create mode 100644 apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts create mode 100644 apps/desktop/src/main/services/attention/attentionNotchHelper.ts create mode 100644 apps/desktop/src/main/services/attention/attentionNotchRouter.test.ts create mode 100644 apps/desktop/src/main/services/attention/attentionNotchRouter.ts create mode 100644 apps/desktop/src/renderer/components/attention/AttentionCenter.css create mode 100644 apps/desktop/src/renderer/components/attention/AttentionCenter.test.tsx create mode 100644 apps/desktop/src/renderer/components/attention/AttentionCenter.tsx create mode 100644 apps/desktop/src/renderer/components/attention/AttentionSettingsPopover.tsx create mode 100644 apps/desktop/src/renderer/components/attention/attentionNotchLocalSettings.ts create mode 100644 apps/desktop/src/renderer/components/attention/attentionPresentation.ts create mode 100644 apps/desktop/src/renderer/components/attention/useAttentionSync.hook.test.tsx create mode 100644 apps/desktop/src/renderer/components/attention/useAttentionSync.test.ts create mode 100644 apps/desktop/src/renderer/components/attention/useAttentionSync.ts create mode 100644 apps/desktop/src/renderer/state/attentionStore.test.ts create mode 100644 apps/desktop/src/renderer/state/attentionStore.ts create mode 100644 apps/desktop/src/shared/types/attention.test.ts create mode 100644 apps/desktop/src/shared/types/attention.ts create mode 100644 apps/push-relay/migrations/0003_account_attention.sql create mode 100644 apps/push-relay/src/attention.ts create mode 100644 apps/push-relay/test/attention.test.ts diff --git a/.gitignore b/.gitignore index 5e38e6d52..12189ba90 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,10 @@ package-lock.json /apps/desktop/release-alpha /apps/desktop/release-beta apps/desktop/resources/runtime/ade-* +apps/desktop/resources/native/ade-attention-notch +apps/desktop/resources/native/ADEAttentionNotch_ADEAttentionNotch.bundle/ +apps/desktop/native/ADEAttentionNotch/.build/ +apps/desktop/native/ADEAttentionNotch/.build-*/ # Large whisper.cpp binary + ~140 MB ggml model are materialized at build time, # never committed (see scripts/materialize-whisper-resources.mjs). Keep the dir + # its README/.gitkeep tracked; ignore the heavy binaries/model. diff --git a/NOTICE b/NOTICE index 8a4ca9d64..2439f7657 100644 --- a/NOTICE +++ b/NOTICE @@ -45,7 +45,39 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -2. whisper.cpp +2. Lobe Icons +================================================================================ + +Upstream: https://github.com/lobehub/lobe-icons +Copyright: Copyright (c) 2023 LobeHub +License: MIT +Bundled as: apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/ + Resources/ProviderIcons/*.svg + +MIT License + +Copyright (c) 2023 LobeHub + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ +3. whisper.cpp ================================================================================ Upstream: https://github.com/ggerganov/whisper.cpp @@ -76,7 +108,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -3. ggml +4. ggml ================================================================================ Upstream: https://github.com/ggerganov/ggml diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 6ca3c6b0f..905f6134a 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -480,6 +480,8 @@ ade storage compress --text # losslessly compress old c ade --role cto storage maintenance --text # run the policy-driven ledger maintenance sweep now (CTO) ade storage actions --text # raw storage service actions (cleanupPreview/cleanup live here) ade actions list --domain chat --text +ade --role cto actions list --domain attention --text # discover account-wide Attention actions +ade --role cto actions run attention.getSnapshot --input-json '{"since":0}' --json ade actions run git.stageFile --arg laneId=lane-id --arg path=src/index.ts ade actions run pty.resumeSession --arg sessionId=session-id ade cursor cloud agents list --text diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index a26c00f87..38a510b54 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -3043,6 +3043,66 @@ describe("adeRpcServer", () => { expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "graph_state")).toBe(true); }); + it("exposes account-wide Attention actions only to CTO callers with discoverable contracts", async () => { + const fixture = createRuntime(); + const getAttentionSnapshot = vi.fn(async (since: number, streamId: string | null) => ({ + contractVersion: 1, + streamId: streamId ?? "account-stream", + revision: since + 1, + generatedAt: "2026-07-28T12:00:00.000Z", + machines: [], + items: [], + tombstones: [], + })); + (fixture.runtime as any).pushPublisherService = { + getAttentionSnapshot, + acknowledgeAttention: vi.fn(async () => undefined), + reportAttentionPresence: vi.fn(async () => undefined), + getAttentionPreferences: vi.fn(async () => ({})), + putAttentionPreferences: vi.fn(async () => undefined), + }; + + const agentHandler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + await initialize(agentHandler, { callerId: "agent-1", role: "agent" }); + const hidden = await callTool(agentHandler, "list_ade_actions", { domain: "attention" }); + expect(hidden?.isError).toBeUndefined(); + expect(hidden.structuredContent).toMatchObject({ count: 0, actions: [] }); + + const ctoHandler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + await initialize(ctoHandler, { callerId: "cto-1", role: "cto" }); + const inventory = await callTool(ctoHandler, "list_ade_actions", { domain: "attention" }); + expect(inventory?.isError).toBeUndefined(); + expect(inventory.structuredContent.actions.map((entry: { name: string }) => entry.name)).toEqual( + expect.arrayContaining([ + "attention.getSnapshot", + "attention.acknowledge", + "attention.reportPresence", + "attention.getPreferences", + "attention.putPreferences", + ]), + ); + const getSnapshotAction = inventory.structuredContent.actions.find( + (entry: { name: string }) => entry.name === "attention.getSnapshot", + ); + expect(getSnapshotAction).toMatchObject({ + description: expect.stringContaining("account-wide Attention stream"), + input: expect.stringContaining("streamId"), + example: expect.stringContaining("attention.getSnapshot"), + }); + + const snapshot = await callTool(ctoHandler, "run_ade_action", { + domain: "attention", + action: "getSnapshot", + args: { since: 7, streamId: "account-stream" }, + }); + expect(snapshot?.isError).toBeUndefined(); + expect(snapshot.structuredContent.result).toMatchObject({ + streamId: "account-stream", + revision: 8, + }); + expect(getAttentionSnapshot).toHaveBeenCalledWith(7, "account-stream"); + }); + it("invokes ADE actions dynamically and returns status hints", async () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index bf3c4ae6c..b33f1c54c 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -1324,6 +1324,7 @@ export async function createAdeRuntime(args: { if (event.type === "pr-notification" && pushPrNotificationSubscribers.size > 0) { const notification: PushPrNotification = { kind: event.kind, + prId: event.prId, prNumber: event.prNumber, prTitle: event.prTitle ?? null, laneId: event.laneId ?? null, @@ -1426,14 +1427,45 @@ export async function createAdeRuntime(args: { // push-identity file), so a run in one project doesn't clobber the phone's // single "agent-runs" Live Activity for another. Each scope wires its own // chat/pty/PR signals via attachSources; the aggregate merges runs across all. + // This is also the canonical account-directory identity used to route an + // Attention click back to this exact machine, even when another machine has + // a project at the same path. + const { createSyncCloudRelayStore } = await import("./services/sync/syncCloudRelayStore"); + const cloudRelayFilePath = path.join( + resolvedArgs.syncRuntime?.phonePairingStateDir ?? resolveMachineAdeLayout().secretsDir, + "sync-cloud-relay.json", + ); + const cloudRelayStore = createSyncCloudRelayStore({ filePath: cloudRelayFilePath }); + const syncDeviceIdPath = path.join( + resolvedArgs.syncRuntime?.phonePairingStateDir ?? resolveMachineAdeLayout().secretsDir, + "sync-device-id", + ); const pushRelayFilePath = resolvePushRelayStateFile(resolveMachineAdeLayout().secretsDir); const pushPublisherService = getSharedPushPublisherService(pushRelayFilePath, () => { const store = createPushRegistrationStore({ filePath: pushRelayFilePath }); return { logger, store, - relayClient: createPushRelayClient({ store, logger }), + relayClient: createPushRelayClient({ + store, + logger, + getAccountAccessToken, + getAccountUserId: () => { + const status = accountAuthService.getStatus(); + return status.signedIn ? status.userId?.trim() || null : null; + }, + }), machineName: os.hostname(), + getAccountMachineIdentity: () => { + const { machineKey } = cloudRelayStore.getMachineIdentity(); + let deviceId: string | null = null; + try { + deviceId = fs.readFileSync(syncDeviceIdPath, "utf8").trim() || null; + } catch { + deviceId = null; + } + return { machineKey, deviceId }; + }, }; }); const detachPushSources = publishPushEvents @@ -1445,6 +1477,8 @@ export async function createAdeRuntime(args: { ? agentChatService : null, ptyService, + projectName: project.displayName, + projectRoot, subscribePrNotifications: (cb) => { pushPrNotificationSubscribers.add(cb); return () => pushPrNotificationSubscribers.delete(cb); @@ -1551,13 +1585,7 @@ export async function createAdeRuntime(args: { // Cloud tunnel relay (phone → Cloudflare DO → this brain). The store // instance is shared with the sync service so the relay candidate in // pairingConnectInfo and the tunnel client use one machine identity. - const { createSyncCloudRelayStore } = await import("./services/sync/syncCloudRelayStore"); const { createSyncTunnelClientService, getSharedSyncTunnelClientService } = await import("./services/sync/syncTunnelClientService"); - const cloudRelayFilePath = path.join( - resolvedArgs.syncRuntime?.phonePairingStateDir ?? resolveMachineAdeLayout().secretsDir, - "sync-cloud-relay.json", - ); - const cloudRelayStore = createSyncCloudRelayStore({ filePath: cloudRelayFilePath }); // ONE tunnel client per machine (keyed by the config file): per-scope // instances would re-register the same machineKey with the relay on every // project open and churn the connection paired phones dial through. diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 373b4f139..b75d007b6 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -2276,10 +2276,11 @@ describe("ADE CLI", () => { expect(sendParams).toMatchObject({ arguments: { domain: "chat", - action: "sendMessage", + action: "messageSession", args: { sessionId: "chat-new", text: "Fix the tests", + kind: "auto", }, }, }); @@ -2351,10 +2352,11 @@ describe("ADE CLI", () => { name: "run_ade_action", arguments: { domain: "chat", - action: "sendMessage", + action: "messageSession", args: { sessionId: "chat-new", text: "Fix login", + kind: "auto", }, }, }); @@ -2768,10 +2770,11 @@ describe("ADE CLI", () => { }, afterCreate: [ { - action: "chat.sendMessage", + action: "chat.messageSession", input: { sessionId: "", text: "Fix the tests", + kind: "auto", }, }, ], @@ -2811,9 +2814,10 @@ describe("ADE CLI", () => { }, }, { - action: "chat.sendMessage", + action: "chat.messageSession", input: { sessionId: "", + kind: "auto", }, }, ], @@ -5507,7 +5511,7 @@ describe("ADE CLI", () => { }, result: { domain: "chat", - action: "sendMessage", + action: "messageSession", result: { ok: true, accepted: true, sessionId: "chat-new" }, }, }, @@ -5530,7 +5534,7 @@ describe("ADE CLI", () => { }, result: { domain: "chat", - action: "sendMessage", + action: "messageSession", result: { ok: true, accepted: true, sessionId: "chat-new" }, }, }, @@ -7116,7 +7120,11 @@ describe("ADE CLI", () => { chat: { domain: "chat", action: "createSession", result: { id: "session-new" } }, }); expect(sendParams).toMatchObject({ - arguments: { domain: "chat", action: "sendMessage", args: { sessionId: "session-new" } }, + arguments: { + domain: "chat", + action: "messageSession", + args: { sessionId: "session-new", kind: "auto" }, + }, }); const sendArgs = (sendParams.arguments as { args: { text: string } }).args; expect(sendArgs.text).toContain("ENG-431"); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index b8954cdf8..b08e0d544 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -2274,6 +2274,10 @@ const HELP_BY_COMMAND: Record = { $ ade actions run pr.submitReview --args-list-json '["pr-1",{"event":"APPROVE"}]' $ ade actions list --text Domain-grouped action catalog $ ade actions list --domain git --text Narrow the catalog + $ ade --role cto actions list --domain attention --text + Discover account-wide Attention actions + $ ade --role cto actions run attention.getSnapshot --input-json '{"since":0}' --json + Read work across connected machines and projects $ ade actions run --input-json '{"key":"value"}' $ ade actions run --input-json '{"key":"value"}' $ ade actions status --text Runtime action availability @@ -4528,7 +4532,9 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan autoCreateLane: lane.autoCreateLane, ...(lane.createLaneArgs ? { createLane: lane.createLaneArgs } : { laneId: lane.laneId }), launch: compactPreviewObject(launchArgs), - ...(mode === "chat" && prompt ? { afterCreate: [{ action: "chat.sendMessage", text: prompt }] } : {}), + ...(mode === "chat" && prompt + ? { afterCreate: [{ action: "chat.messageSession", input: { text: prompt, kind: "auto" } }] } + : {}), }, }; } @@ -4598,10 +4604,11 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan name: "run_ade_action", arguments: { domain: "chat", - action: "sendMessage", + action: "messageSession", args: { sessionId: targetSession, text: prompt, + kind: "auto", }, }, }; @@ -4725,10 +4732,11 @@ function buildChatCreateConfigPreview( } if (!options.noKickoff && options.kickoffText) { afterCreate.push({ - action: "chat.sendMessage", + action: "chat.messageSession", input: { sessionId: "", text: options.kickoffText, + kind: "auto", }, }); } @@ -4834,8 +4842,8 @@ function buildCreateLaneFromLinearPlan(args: string[], issue: JsonObject): CliPl name: "run_ade_action", arguments: { domain: "chat", - action: "sendMessage", - args: { sessionId, text: kickoff }, + action: "messageSession", + args: { sessionId, text: kickoff, kind: "auto" }, }, }; }, @@ -7288,10 +7296,11 @@ function buildChatPlan(args: string[]): CliPlan { name: "run_ade_action", arguments: { domain: "chat", - action: "sendMessage", + action: "messageSession", args: { sessionId: targetSession, text: explicitKickoff, + kind: "auto", }, }, }; @@ -7340,10 +7349,11 @@ function buildChatPlan(args: string[]): CliPlan { name: "run_ade_action", arguments: { domain: "chat", - action: "sendMessage", + action: "messageSession", args: { sessionId: targetSession, text: explicitKickoff ?? deriveLinearKickoffPrompt(issueForKickoff), + kind: "auto", }, }, }; diff --git a/apps/ade-cli/src/multiProjectRpcServer.test.ts b/apps/ade-cli/src/multiProjectRpcServer.test.ts index ff8c32d21..139085a1f 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.test.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.test.ts @@ -142,6 +142,138 @@ function makeRuntime(label: string) { } describe("multi-project RPC server", () => { + it("serves account Attention from the machine scope without a project id", async () => { + const runtime = makeRuntime("attention") as ReturnType & { + pushPublisherService: Record>; + }; + runtime.pushPublisherService = { + getAttentionSnapshot: vi.fn(async () => ({ + contractVersion: 1, + streamId: "account-stream", + revision: 8, + generatedAt: "2026-07-28T12:00:00.000Z", + items: [], + tombstones: [], + })), + acknowledgeAttention: vi.fn(async () => undefined), + reportAttentionPresence: vi.fn(async () => undefined), + getAttentionPreferences: vi.fn(async () => ({ account: { hideDetails: true } })), + putAttentionPreferences: vi.fn(async () => undefined), + }; + const scopeRegistry = { + resolveActiveSyncHost: vi.fn(async () => ({ runtime })), + dispose: vi.fn(), + disposeAll: vi.fn(), + } as unknown as ProjectScopeRegistry; + const accountAuthService = makeAccountAuthServiceMock(); + (accountAuthService.getStatus as ReturnType).mockReturnValue({ + signedIn: true, + userId: "account-a", + email: null, + name: null, + expiresAt: null, + }); + const previousRole = process.env.ADE_DEFAULT_ROLE; + process.env.ADE_DEFAULT_ROLE = "cto"; + try { + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "test", + scopeRegistry, + accountAuthService, + }); + await handler({ + jsonrpc: "2.0", + id: 1, + method: "ade/initialize", + params: { identity: { role: "cto" } }, + }); + const result = await handler({ + jsonrpc: "2.0", + id: 2, + method: "attention.call", + params: { + action: "getSnapshot", + args: { since: 7, streamId: "account-stream" }, + }, + }); + expect(result).toMatchObject({ + streamId: "account-stream", + revision: 8, + }); + expect(runtime.pushPublisherService.getAttentionSnapshot) + .toHaveBeenCalledWith(7, "account-stream"); + await handler({ + jsonrpc: "2.0", + id: 5, + method: "attention.call", + params: { + action: "getPreferences", + args: { accountOwnerId: "account-a" }, + }, + }); + await handler({ + jsonrpc: "2.0", + id: 6, + method: "attention.call", + params: { + action: "putPreferences", + args: { + accountOwnerId: "account-a", + preferences: { account: { hideDetails: true } }, + }, + }, + }); + expect(runtime.pushPublisherService.getAttentionPreferences) + .toHaveBeenCalledWith("account-a"); + expect(runtime.pushPublisherService.putAttentionPreferences) + .toHaveBeenCalledWith( + "account-a", + { account: { hideDetails: true } }, + ); + (accountAuthService.getStatus as ReturnType).mockReturnValue({ + signedIn: true, + userId: "account-b", + email: null, + name: null, + expiresAt: null, + }); + await expect(handler({ + jsonrpc: "2.0", + id: 7, + method: "attention.call", + params: { + action: "putPreferences", + args: { + accountOwnerId: "account-a", + preferences: { account: { hideDetails: false } }, + }, + }, + })).rejects.toThrow(/account changed/i); + handler.dispose(); + + const agentHandler = createMultiProjectRpcRequestHandler({ + serverVersion: "test", + scopeRegistry, + accountAuthService, + }); + await agentHandler({ + jsonrpc: "2.0", + id: 3, + method: "ade/initialize", + params: { identity: { role: "agent" } }, + }); + await expect(agentHandler({ + jsonrpc: "2.0", + id: 4, + method: "attention.call", + params: { action: "getSnapshot", args: {} }, + })).rejects.toThrow(/requires the cto role/i); + agentHandler.dispose(); + } finally { + restoreEnvVar("ADE_DEFAULT_ROLE", previousRole); + } + }); + it("prewarms the production shared personal-chat scope only at creation", () => { const warmExisting = vi.spyOn(PersonalChatScope.prototype, "warmExisting") .mockResolvedValue(); diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index 0480bd081..723242d88 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -14,6 +14,8 @@ import { createProjectScaffoldService } from "../../desktop/src/main/services/pr import { runGit } from "../../desktop/src/main/services/git/git"; import type { Logger } from "../../desktop/src/main/services/logging/logger"; import type { + AttentionPreferences, + AttentionPresence, CloneProjectInput, CreateProjectInput, ListMyGitHubReposInput, @@ -157,6 +159,7 @@ const RUNTIME_METHODS = new Set([ "runtime/info", "runtime.activitySummary", "account.call", + "attention.call", "personalChats.call", "personalChats.streamEvents", "machineInfo.get", @@ -719,6 +722,21 @@ export function createMultiProjectRpcRequestHandler( let initializedParams: Record | null = null; let notifier: JsonRpcNotifier | null = null; let nextSubscriptionId = 1; + const callerRole = () => { + const identityRecord = + isRecord(initializedParams) && isRecord(initializedParams.identity) + ? (initializedParams.identity as Record) + : null; + return resolveSessionBoundRole({ + defaultRole: normalizeAdeRuntimeRole(process.env.ADE_DEFAULT_ROLE), + requestedRole: normalizeAdeRuntimeRole( + identityRecord ? identityRecord.role : null, + ), + chatSessionId: identityRecord && typeof identityRecord.chatSessionId === "string" + ? identityRecord.chatSessionId.trim() || null + : null, + }); + }; const emitRuntimeEvent = ( subscriptionId: string, @@ -1142,21 +1160,8 @@ export function createMultiProjectRpcRequestHandler( // honestly asserts a non-cto role cannot reach these actions. `status` // stays open to any role, with identity fields removed below for non-CTO // callers. - const identityRecord = - isRecord(initializedParams) && isRecord(initializedParams.identity) - ? (initializedParams.identity as Record) - : null; - const requestedRole = normalizeAdeRuntimeRole( - identityRecord ? identityRecord.role : null, - ); - const callerRole = resolveSessionBoundRole({ - defaultRole: normalizeAdeRuntimeRole(process.env.ADE_DEFAULT_ROLE), - requestedRole, - chatSessionId: identityRecord && typeof identityRecord.chatSessionId === "string" - ? identityRecord.chatSessionId.trim() || null - : null, - }); - if (isCtoOnlyAdeAction("account", action) && !callerHasRoleAtLeast(callerRole, "cto")) { + const role = callerRole(); + if (isCtoOnlyAdeAction("account", action) && !callerHasRoleAtLeast(role, "cto")) { throw new JsonRpcError( JsonRpcErrorCode.invalidRequest, `account.${action} requires the cto role.`, @@ -1213,10 +1218,106 @@ export function createMultiProjectRpcRequestHandler( reconcileAccountOwnership(accountOwnerUserIdFromStatus(status)); } return action === "status" - ? { ...response, result: scopeAccountStatusForRole(response.result, callerRole) } + ? { ...response, result: scopeAccountStatusForRole(response.result, role) } : response; } + if (method === "attention.call") { + if (!callerHasRoleAtLeast(callerRole(), "cto")) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidRequest, + "attention.call requires the cto role.", + ); + } + const action = typeof params.action === "string" ? params.action.trim() : ""; + const args = isRecord(params.args) ? params.args : {}; + const activeScope = await scopeRegistry.resolveActiveSyncHost(); + const publisher = activeScope?.runtime.pushPublisherService; + if (!publisher) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidRequest, + "Account Attention is unavailable until the ADE brain is ready.", + ); + } + if (action === "getSnapshot") { + const since = Number.isFinite(Number(args.since)) + ? Math.max(0, Math.trunc(Number(args.since))) + : 0; + const streamId = + typeof args.streamId === "string" && args.streamId.trim() + ? args.streamId.trim() + : null; + return await publisher.getAttentionSnapshot(since, streamId); + } + if (action === "acknowledge") { + const itemIds = Array.isArray(args.itemIds) + ? args.itemIds + .filter((value): value is string => + typeof value === "string" && value.trim().length > 0) + .map((value) => value.trim()) + .slice(0, 64) + : []; + if (itemIds.length === 0) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "attention.acknowledge requires at least one item id.", + ); + } + await publisher.acknowledgeAttention({ + itemIds, + ...(typeof args.seenAt === "string" ? { seenAt: args.seenAt } : {}), + ...(args.dismissedAt === null || typeof args.dismissedAt === "string" + ? { dismissedAt: args.dismissedAt } + : {}), + }); + return null; + } + if (action === "reportPresence") { + if (typeof args.deviceId !== "string" || !args.deviceId.trim()) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "attention.reportPresence requires a device id.", + ); + } + await publisher.reportAttentionPresence(args as AttentionPresence); + return null; + } + if (action === "getPreferences") { + const accountOwnerId = + typeof args.accountOwnerId === "string" ? args.accountOwnerId.trim() : ""; + if (!accountOwnerId || currentAccountOwnerUserId() !== accountOwnerId) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidRequest, + "The ADE account changed before Attention preferences could be read.", + ); + } + return await publisher.getAttentionPreferences(accountOwnerId); + } + if (action === "putPreferences") { + const accountOwnerId = + typeof args.accountOwnerId === "string" ? args.accountOwnerId.trim() : ""; + if ( + !accountOwnerId + || currentAccountOwnerUserId() !== accountOwnerId + || !isRecord(args.preferences) + ) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidRequest, + "The ADE account changed before Attention preferences could be saved.", + ); + } + await publisher.putAttentionPreferences( + accountOwnerId, + args.preferences as AttentionPreferences, + ); + return null; + } + throw new JsonRpcError( + JsonRpcErrorCode.methodNotFound, + `Unknown Attention action: ${action || "(empty)"}`, + ); + } + if (method === "personalChats.call") { return await personalChatScope.call(params.action, params.args); } diff --git a/apps/ade-cli/src/services/push/pushPublisherService.test.ts b/apps/ade-cli/src/services/push/pushPublisherService.test.ts index 94a7105dc..ce6f4257d 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.test.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { createHash, createHmac } from "node:crypto"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_ATTENTION_PREFERENCES } from "../../../../desktop/src/shared/types/attention"; import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; import type { PushQuietHours } from "../../../../desktop/src/shared/types/push"; import { createPushRegistrationStore, type PushRegistrationStore } from "./pushRegistrationStore"; @@ -136,15 +137,22 @@ describe("createPushPublisherService flush", () => { function makeHarness(deviceOverride = device) { const publish = vi.fn().mockResolvedValue({ ok: true }); + const publishAttention = vi.fn().mockResolvedValue(null); const store = { hasRegisteredDevices: () => true, + getOrCreateIdentity: () => ({ machineKey: "a".repeat(40), machineSecret: "secret" }), getStatusSnapshot: () => ({ enabled: true, claimed: true, registeredDeviceCount: 1, lastPublishAt: null, lastPublishError: null, lastRelayContactAt: null }), listDevices: () => [deviceOverride], getDevice: () => deviceOverride, recordPublishResult: vi.fn(), recordRelayContact: vi.fn(), }; - const relayClient = { publish, health: vi.fn().mockResolvedValue({ ok: true, apnsConfigured: true }), baseUrl: "https://relay.test" }; + const relayClient = { + publish, + publishAttention, + health: vi.fn().mockResolvedValue({ ok: true, apnsConfigured: true }), + baseUrl: "https://relay.test", + }; let chatCb: ((env: AgentChatEventEnvelope) => void) | null = null; const agentChatService = { subscribeToEvents: (cb: (env: AgentChatEventEnvelope) => void) => { @@ -170,16 +178,30 @@ describe("createPushPublisherService flush", () => { store: store as never, relayClient: relayClient as never, machineName: "MacBook", + getAccountMachineIdentity: () => ({ + machineKey: "b".repeat(32), + deviceId: "desktop-device", + }), flushDebounceMs: 2_000, promptFlushMs: 150, }); const cliSessions = new Map(); - publisher.attachSources("scope-1", { + const detach = publisher.attachSources("scope-1", { agentChatService: agentChatService as never, + projectName: "ADE", + projectRoot: "/projects/ADE", resolveLaneName: (laneId: string) => laneId, resolveCliSession: (sessionId: string) => cliSessions.get(sessionId) ?? null, }); - return { publisher, publish, emit: (env: AgentChatEventEnvelope) => chatCb?.(env), store, cliSessions }; + return { + publisher, + publish, + publishAttention, + emit: (env: AgentChatEventEnvelope) => chatCb?.(env), + store, + cliSessions, + detach, + }; } const approval: AgentChatEventEnvelope = { @@ -227,6 +249,43 @@ describe("createPushPublisherService flush", () => { publisher.dispose(); }); + it("publishes one account-wide Attention item with exact project and approval actions", async () => { + const { publisher, publishAttention, emit } = makeHarness(); + publishAttention.mockResolvedValue({ ok: true, revision: 1 }); + emit(approval); + await vi.advanceTimersByTimeAsync(200); + + expect(publishAttention).toHaveBeenCalledTimes(1); + const payload = publishAttention.mock.calls[0][0]; + expect(payload.fullSnapshot).toBe(true); + expect(payload.machineName).toBe("MacBook"); + expect(payload.items).toHaveLength(1); + expect(payload.items[0]).toMatchObject({ + kind: "agent", + eventKind: "agent_needs_you", + phase: "needs_you", + machine: { + machineKey: "a".repeat(40), + accountMachineKey: "b".repeat(32), + deviceId: "desktop-device", + }, + project: { + projectId: "scope-1", + name: "ADE", + rootPath: "/projects/ADE", + }, + destination: { + kind: "session", + sessionId: "s-1", + itemId: "i-1", + }, + }); + expect(payload.items[0].actions.map((action: { kind: string }) => action.kind)) + .toEqual(["approve", "deny", "open"]); + + publisher.dispose(); + }); + it("alerts native structured questions with the unified needs-you copy immediately", async () => { const { publisher, publish, emit } = makeHarness(); await publisher.start(); @@ -1010,6 +1069,29 @@ describe("createPushPublisherService flush", () => { publisher.dispose(); }); + + it("publishes an empty full Attention snapshot when the last contributing scope detaches", async () => { + const { publisher, publishAttention, emit, detach } = makeHarness(); + publishAttention.mockResolvedValue({ ok: true, revision: 1 }); + emit(approval); + await vi.advanceTimersByTimeAsync(200); + expect(publishAttention.mock.calls[0]?.[0].items).toHaveLength(1); + + publishAttention.mockClear(); + detach(); + await vi.runAllTicks(); + await Promise.resolve(); + + expect(publishAttention).toHaveBeenCalledTimes(1); + expect(publishAttention).toHaveBeenCalledWith({ + machineName: "MacBook", + fullSnapshot: true, + items: [], + }); + expect(vi.getTimerCount()).toBe(0); + + publisher.dispose(); + }); }); describe("createPushRegistrationStore", () => { @@ -1235,4 +1317,73 @@ describe("createPushRelayClient", () => { ); expect(signature).toBe("sha256=5c5c3a3081a0c6bec96c4191a88ab17b59382b902c6071672ea6d8daa30764f3"); // gitleaks:allow }); + + it("publishes Attention with both machine HMAC and account bearer authorization", async () => { + const client = createPushRelayClient({ + store: makeStore(), + logger, + baseUrl: "https://relay.test", + getAccountAccessToken: async () => "account-access-token", + }); + await client.publishAttention({ + machineName: "MacBook", + fullSnapshot: true, + items: [], + }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`https://relay.test/machines/${MACHINE_KEY}/attention`); + expect(init.headers.authorization).toBe("Bearer account-access-token"); + expect(init.headers["x-ade-push-signature"]).toBe( + expectedSignature( + MACHINE_SECRET, + init.headers["x-ade-push-timestamp"], + "POST", + new URL(url).pathname, + init.body, + ), + ); + }); + + it("binds incremental snapshot cursors to the authenticated stream", async () => { + const client = createPushRelayClient({ + store: makeStore(), + logger, + baseUrl: "https://relay.test", + getAccountAccessToken: async () => "account-access-token", + }); + await client.getAttentionSnapshot(12, "account-a"); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe( + "https://relay.test/attention/account/snapshot?since=12&streamId=account-a", + ); + expect(init.headers.authorization).toBe("Bearer account-access-token"); + }); + + it("does not authorize an account-A preference write with account B's refreshed token", async () => { + let currentAccountUserId: string | null = "account-a"; + let resolveToken: (token: string | null) => void = () => {}; + const tokenPromise = new Promise((resolve) => { + resolveToken = resolve; + }); + const client = createPushRelayClient({ + store: makeStore(), + logger, + baseUrl: "https://relay.test", + getAccountAccessToken: () => tokenPromise, + getAccountUserId: () => currentAccountUserId, + }); + + const write = client.putAttentionPreferences( + "account-a", + DEFAULT_ATTENTION_PREFERENCES, + ); + await Promise.resolve(); + currentAccountUserId = "account-b"; + resolveToken("account-b-access-token"); + + await expect(write).rejects.toThrow(/account changed/i); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index 43ee6b6a6..63e4b727a 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -1,6 +1,18 @@ import path from "node:path"; +import { createHash } from "node:crypto"; import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; +import { + ATTENTION_CONTRACT_VERSION, + DEFAULT_ATTENTION_PREFERENCES, + sanitizeAttentionPreview, + type AttentionEventKind, + type AttentionItem, + type AttentionPhase, + type AttentionPreferences, + type AttentionPresence, + type AttentionSnapshot, +} from "../../../../desktop/src/shared/types/attention"; import type { PtyExitEvent } from "../../../../desktop/src/shared/types/sessions"; import type { PrNotificationKind } from "../../../../desktop/src/shared/types/prs"; import type { @@ -41,9 +53,11 @@ const FAILED_DETAIL = "Run failed"; const RUNNING_TTL_MS = 2 * 60 * 60 * 1000; // 2h for running/starting const WAITING_TTL_MS = 24 * 60 * 60 * 1000; // 24h for waiting_for_* const PR_LIVE_ACTIVITY_TTL_MS = 45 * 60 * 1000; // keep recent PR status visible, then age it out +const ATTENTION_RECENT_TTL_MS = 24 * 60 * 60 * 1000; const DEFAULT_FLUSH_DEBOUNCE_MS = 2_000; const DEFAULT_PROMPT_FLUSH_MS = 150; const PUBLISH_RETRY_MS = 30_000; +const ATTENTION_HEARTBEAT_MS = 30_000; const APNS_HEALTH_CACHE_MS = 24 * 60 * 60 * 1000; export type AgentRunPhase = @@ -91,6 +105,7 @@ export type PushSessionAttentionRequest = { export type PushPrNotification = { kind: PrNotificationKind; + prId?: string | null; prNumber: number; prTitle: string | null; laneId: string | null; @@ -100,6 +115,8 @@ export type PushPrNotification = { export type PrLiveActivityState = { id: string; + scopeKey: string; + prId: string | null; prNumber: number; title: string; phase: PrNotificationKind; @@ -140,6 +157,10 @@ export type PushPublisherDeps = { store: PushRegistrationStore; relayClient: PushRelayClient; machineName: string; + getAccountMachineIdentity?: () => { + machineKey: string; + deviceId?: string | null; + } | null; /** Test seams. */ now?: () => number; flushDebounceMs?: number; @@ -158,6 +179,8 @@ export type PushPublisherSources = { /** Injected bridge over prPollingService's `pr-notification` events. */ subscribePrNotifications?: (cb: (event: PushPrNotification) => void) => () => void; resolveLaneName?: (laneId: string) => string | null | undefined; + projectName?: string; + projectRoot?: string; /** * Resolves a tracked terminal session for CLI-run metadata. Returning a * record with a `chatSessionId` (a chat-attached shell) or null (unknown / @@ -399,16 +422,59 @@ function providerDisplayName(provider: string | null | undefined): string | null } } +function fingerprintAttentionItem(value: Omit): string { + return createHash("sha256").update(JSON.stringify(value), "utf8").digest("hex"); +} + +function agentAttentionPhase(phase: AgentRunPhase): AttentionPhase { + if (phase === "waiting_for_approval" || phase === "waiting_for_input") return "needs_you"; + return phase; +} + +function agentAttentionEventKind(phase: AgentRunPhase): AttentionEventKind { + if (phase === "waiting_for_approval" || phase === "waiting_for_input") return "agent_needs_you"; + if (phase === "failed") return "agent_failed"; + if (phase === "completed") return "agent_completed"; + return "agent_running"; +} + +function prAttentionState(kind: PrNotificationKind): { + phase: AttentionPhase; + eventKind: AttentionEventKind; + tab: "overview" | "activity" | "checks"; +} { + switch (kind) { + case "checks_failing": + return { phase: "checks_failing", eventKind: "pr_checks_failing", tab: "checks" }; + case "review_requested": + return { phase: "review_requested", eventKind: "pr_review_requested", tab: "activity" }; + case "changes_requested": + return { phase: "changes_requested", eventKind: "pr_changes_requested", tab: "activity" }; + case "merge_ready": + return { phase: "merge_ready", eventKind: "pr_merge_ready", tab: "overview" }; + case "merged": + return { phase: "merged", eventKind: "pr_merged", tab: "overview" }; + case "closed": + return { phase: "closed", eventKind: "pr_closed", tab: "overview" }; + case "opened": + case "reopened": + return { phase: "open", eventKind: "pr_opened", tab: "overview" }; + } +} + export function createPushPublisherService(deps: PushPublisherDeps) { const now = deps.now ?? (() => Date.now()); const flushDebounceMs = deps.flushDebounceMs ?? DEFAULT_FLUSH_DEBOUNCE_MS; const promptFlushMs = deps.promptFlushMs ?? DEFAULT_PROMPT_FLUSH_MS; const runs = new Map(); + const recentRuns = new Map(); const prActivities = new Map(); let pendingAlerts: PendingAlert[] = []; const lastAlertFingerprintByKey = new Map(); let lastLiveActivityFingerprint: string | null = null; + let lastAttentionFingerprint: string | null = null; + let lastAttentionPublishedAt = 0; let liveActivityStarted = false; /** * Last app-icon badge count delivered per device (absent = never sent). @@ -420,8 +486,11 @@ export function createPushPublisherService(deps: PushPublisherDeps) { let flushTimer: NodeJS.Timeout | null = null; let prExpiryTimer: NodeJS.Timeout | null = null; + let attentionHeartbeatTimer: NodeJS.Timeout | null = null; let flushFireAt = 0; let flushing = false; + let finalAttentionSnapshotPending = false; + let finalAttentionSnapshotQueued = false; let disposed = false; let warmed = false; @@ -429,6 +498,8 @@ export function createPushPublisherService(deps: PushPublisherDeps) { agentChatService?: PushAgentChatService | null; resolveLaneName?: (laneId: string) => string | null | undefined; resolveCliSession?: PushPublisherSources["resolveCliSession"]; + projectName: string; + projectRoot: string | null; unsubscribes: Array<() => void>; }; const scopes = new Map(); @@ -466,6 +537,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { }; runs.set(sessionId, run); } + recentRuns.delete(sessionId); return run; }; @@ -476,6 +548,184 @@ export function createPushPublisherService(deps: PushPublisherDeps) { return parts.length > 0 ? parts.join(" · ") : run.title?.trim() || "Agent run"; }; + const buildAttentionItems = (nowMs: number): AttentionItem[] => { + const { machineKey } = deps.store.getOrCreateIdentity(); + const accountMachineIdentity = deps.getAccountMachineIdentity?.() ?? null; + const machine = { + machineKey, + accountMachineKey: accountMachineIdentity?.machineKey ?? null, + deviceId: accountMachineIdentity?.deviceId ?? null, + name: deps.machineName, + online: true, + lastSeenAt: null, + }; + const attentionRuns = new Map([ + ...recentRuns, + ...runs, + ]); + const runItems = [...attentionRuns.values()].map((run): AttentionItem => { + const scope = scopes.get(run.scopeKey); + const phase = agentAttentionPhase(run.phase); + const eventKind = agentAttentionEventKind(run.phase); + const subject = runSubject(run); + const preview = sanitizeAttentionPreview( + run.detail?.trim() || laneTitleLine(run), + ); + const expiresAt = new Date( + run.lastActiveAt + + ( + run.phase === "running" || run.phase === "starting" || run.phase === "stale" + ? RUNNING_TTL_MS + : ATTENTION_RECENT_TTL_MS + ), + ).toISOString(); + const actions: AttentionItem["actions"] = [ + { id: "open", kind: "open", label: "Open" }, + ]; + if (run.phase === "waiting_for_approval" && run.itemId) { + actions.unshift( + { + id: "approve", + kind: "approve", + label: "Approve", + payload: { sessionId: run.sessionId, itemId: run.itemId }, + }, + { + id: "deny", + kind: "deny", + label: "Deny", + destructive: true, + payload: { sessionId: run.sessionId, itemId: run.itemId }, + }, + ); + } else if (run.phase === "waiting_for_input") { + actions.unshift({ + id: "answer", + kind: "answer", + label: "Answer", + payload: { sessionId: run.sessionId }, + }); + } + const withoutFingerprint: Omit = { + contractVersion: ATTENTION_CONTRACT_VERSION, + id: `agent:${machineKey}:${run.sessionId}`, + revision: run.lastActiveAt, + kind: "agent", + eventKind, + phase, + machine, + project: { + projectId: run.scopeKey, + name: scope?.projectName ?? "ADE project", + rootPath: scope?.projectRoot ?? null, + }, + laneId: null, + laneName: run.lane, + provider: run.agent, + model: run.model, + title: phase === "needs_you" + ? `${subject} needs you` + : phase === "failed" + ? `${subject} failed` + : phase === "completed" + ? `${subject} finished` + : `${subject} is working`, + preview, + privacyPreview: phase === "needs_you" + ? "An ADE agent needs your input." + : phase === "failed" + ? "An ADE agent run failed." + : phase === "completed" + ? "An ADE agent finished." + : "An ADE agent is working.", + detail: run.detail ? sanitizeAttentionPreview(run.detail, 1_000) : null, + recentActivity: run.detail ? [sanitizeAttentionPreview(run.detail)] : [], + planProgress: null, + destination: { + kind: "session", + sessionId: run.sessionId, + itemId: run.itemId, + }, + actions, + occurredAt: new Date(run.startedAt).toISOString(), + updatedAt: new Date(run.lastActiveAt).toISOString(), + seenAt: null, + dismissedAt: null, + expiresAt, + }; + return { + ...withoutFingerprint, + fingerprint: fingerprintAttentionItem(withoutFingerprint), + }; + }); + + const prItems = [...prActivities.values()].map((pr): AttentionItem => { + const scopeKey = pr.scopeKey; + const scope = scopes.get(scopeKey); + const mapped = prAttentionState(pr.phase); + const title = prNotificationCopy({ + kind: pr.phase, + prNumber: pr.prNumber, + prTitle: pr.title, + laneId: null, + }).title; + const actions: AttentionItem["actions"] = [ + { id: "open", kind: "open", label: "Open pull request" }, + ]; + if (pr.phase === "checks_failing" && pr.prId) { + actions.unshift({ + id: "rerun_checks", + kind: "rerun_checks", + label: "Rerun checks", + payload: { prId: pr.prId, prNumber: pr.prNumber }, + }); + } + const withoutFingerprint: Omit = { + contractVersion: ATTENTION_CONTRACT_VERSION, + id: `pull-request:${machineKey}:${pr.id}`, + revision: pr.updatedAt, + kind: "pull_request", + eventKind: mapped.eventKind, + phase: mapped.phase, + machine, + project: { + projectId: scopeKey, + name: scope?.projectName ?? "ADE project", + rootPath: scope?.projectRoot ?? null, + }, + laneId: null, + laneName: pr.lane, + provider: "GitHub", + model: null, + title, + preview: sanitizeAttentionPreview(pr.title), + privacyPreview: `Pull request #${pr.prNumber} changed state.`, + detail: null, + recentActivity: [], + planProgress: null, + destination: { + kind: "pull_request", + prId: pr.prId, + repoOwner: pr.repoOwner, + repoName: pr.repoName, + number: pr.prNumber, + tab: mapped.tab, + }, + actions, + occurredAt: new Date(pr.updatedAt).toISOString(), + updatedAt: new Date(pr.updatedAt).toISOString(), + seenAt: null, + dismissedAt: null, + expiresAt: new Date(pr.updatedAt + ATTENTION_RECENT_TTL_MS).toISOString(), + }; + return { + ...withoutFingerprint, + fingerprint: fingerprintAttentionItem(withoutFingerprint), + }; + }); + return [...runItems, ...prItems]; + }; + const enqueueAlert = (alert: PendingAlert): void => { pendingAlerts = pendingAlerts.filter((existing) => existing.dedupeKey !== alert.dedupeKey); pendingAlerts.push(alert); @@ -514,13 +764,20 @@ export function createPushPublisherService(deps: PushPublisherDeps) { && age > WAITING_TTL_MS ) { runs.delete(sessionId); + } else if (isTerminalPhase(run.phase) && age > ATTENTION_RECENT_TTL_MS) { + runs.delete(sessionId); + } + } + for (const [sessionId, run] of recentRuns) { + if (nowMs - run.lastActiveAt > ATTENTION_RECENT_TTL_MS) { + recentRuns.delete(sessionId); } } }; const prunePrActivities = (nowMs: number): void => { for (const [id, pr] of prActivities) { - if (nowMs - pr.updatedAt > PR_LIVE_ACTIVITY_TTL_MS) { + if (nowMs - pr.updatedAt > ATTENTION_RECENT_TTL_MS) { prActivities.delete(id); } } @@ -528,7 +785,9 @@ export function createPushPublisherService(deps: PushPublisherDeps) { const dropTerminalRuns = (): void => { for (const [sessionId, run] of runs) { - if (isTerminalPhase(run.phase)) runs.delete(sessionId); + if (!isTerminalPhase(run.phase)) continue; + recentRuns.set(sessionId, { ...run }); + runs.delete(sessionId); } }; @@ -539,7 +798,8 @@ export function createPushPublisherService(deps: PushPublisherDeps) { } let nextExpiryMs = Number.POSITIVE_INFINITY; for (const pr of prActivities.values()) { - nextExpiryMs = Math.min(nextExpiryMs, pr.updatedAt + PR_LIVE_ACTIVITY_TTL_MS); + const expiryMs = pr.updatedAt + PR_LIVE_ACTIVITY_TTL_MS; + if (expiryMs > nowMs) nextExpiryMs = Math.min(nextExpiryMs, expiryMs); } if (!Number.isFinite(nextExpiryMs)) return; const delayMs = Math.max(250, nextExpiryMs - nowMs + 250); @@ -598,9 +858,11 @@ export function createPushPublisherService(deps: PushPublisherDeps) { if (deviceIds.length === 0) return null; prunePrActivities(nowMs); schedulePrActivityExpiry(nowMs); - if (prActivities.size > 0) dropTerminalRuns(); + const recentPrActivities = [...prActivities.values()] + .filter((pr) => nowMs - pr.updatedAt <= PR_LIVE_ACTIVITY_TTL_MS); + if (recentPrActivities.length > 0) dropTerminalRuns(); const allRuns = [...runs.values()]; - const allPrActivities = [...prActivities.values()]; + const allPrActivities = recentPrActivities; const contentState = buildAgentRunsContentState(allRuns, nowMs, allPrActivities); const activeCount = contentState.activeCount; const prActivityCount = allPrActivities.length; @@ -663,7 +925,6 @@ export function createPushPublisherService(deps: PushPublisherDeps) { if (event === "end") { liveActivityStarted = false; lastLiveActivityFingerprint = null; - // Once the aggregate ends, drop terminal rows so the next run starts fresh. dropTerminalRuns(); } }; @@ -671,14 +932,49 @@ export function createPushPublisherService(deps: PushPublisherDeps) { return { item, commit }; }; + const publishAttentionSnapshot = async (nowMs: number): Promise => { + if (typeof deps.relayClient.publishAttention !== "function") return false; + const items = buildAttentionItems(nowMs); + const fingerprint = JSON.stringify(items.map((item) => ({ + id: item.id, + revision: item.revision, + fingerprint: item.fingerprint, + }))); + if ( + fingerprint === lastAttentionFingerprint + && nowMs - lastAttentionPublishedAt < ATTENTION_HEARTBEAT_MS + ) { + return true; + } + try { + const result = await deps.relayClient.publishAttention({ + machineName: deps.machineName, + fullSnapshot: true, + items, + }); + if (result) { + lastAttentionFingerprint = fingerprint; + lastAttentionPublishedAt = nowMs; + return true; + } + return false; + } catch (error) { + logWarn("attention.publish_failed", error); + scheduleRetry(); + return false; + } + }; + const flush = async (): Promise => { const nowMs = now(); + pruneRuns(nowMs); + prunePrActivities(nowMs); + await resolveMissingMeta(); + const accountAttentionPublished = await publishAttentionSnapshot(nowMs); if (isGated()) { pendingAlerts = []; return; } - pruneRuns(nowMs); - await resolveMissingMeta(); const devices = deps.store.listDevices(); const consumedAlerts = pendingAlerts; @@ -688,7 +984,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { const alertItems: PushRelayAlertItem[] = []; const alertCommits: Array<[string, string]> = []; - for (const alert of consumedAlerts) { + for (const alert of accountAttentionPublished ? [] : consumedAlerts) { const eligibleIds = devices .filter((device) => Boolean(device.apnsToken) && shouldDeliverAlertForPrefs(device.prefs, alert.sessionId, nowMs)) .map((device) => device.deviceId); @@ -757,7 +1053,9 @@ export function createPushPublisherService(deps: PushPublisherDeps) { const liveActivityDeviceIds = devices .filter((device) => Boolean(device.pushToStartToken) && shouldDeliverLiveActivityForPrefs(device.prefs)) .map((device) => device.deviceId); - const laPlan = planLiveActivity(liveActivityDeviceIds, nowMs); + const laPlan = accountAttentionPublished + ? null + : planLiveActivity(liveActivityDeviceIds, nowMs); if (alertItems.length === 0 && !laPlan) return; @@ -835,7 +1133,10 @@ export function createPushPublisherService(deps: PushPublisherDeps) { }; const scheduleRetry = (): void => { - if (disposed) return; + // With no attached scope there is no active work loop to retry. The final + // detach snapshot is deliberately one-shot/nonblocking; a later attach or + // heartbeat will naturally reconcile if that best-effort publish failed. + if (disposed || scopes.size === 0) return; const fireAt = now() + PUBLISH_RETRY_MS; if (flushTimer && flushFireAt > 0 && flushFireAt <= fireAt) return; if (flushTimer) clearTimeout(flushTimer); @@ -850,7 +1151,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { const runFlush = async (): Promise => { if (disposed) return; if (flushing) { - scheduleFlush(false); + if (scopes.size > 0) scheduleFlush(false); return; } flushing = true; @@ -860,9 +1161,25 @@ export function createPushPublisherService(deps: PushPublisherDeps) { logWarn("push.flush_failed", error); } finally { flushing = false; + if (finalAttentionSnapshotPending) scheduleFinalAttentionSnapshot(); } }; + const scheduleFinalAttentionSnapshot = (): void => { + if (disposed || !finalAttentionSnapshotPending || finalAttentionSnapshotQueued) return; + finalAttentionSnapshotQueued = true; + queueMicrotask(() => { + finalAttentionSnapshotQueued = false; + if (disposed || !finalAttentionSnapshotPending) return; + // An in-flight flush observes the already-pruned aggregate. Let it + // complete, then the finally block above schedules this final pass only + // if another authoritative snapshot is still needed. + if (flushing) return; + finalAttentionSnapshotPending = false; + void runFlush(); + }); + }; + const onChatEvent = (scopeKey: string, envelope: AgentChatEventEnvelope): void => { const sessionId = envelope.sessionId; if (!sessionId) return; @@ -951,6 +1268,9 @@ export function createPushPublisherService(deps: PushPublisherDeps) { break; } } + if (isTerminalPhase(run.phase)) { + recentRuns.set(sessionId, { ...run }); + } scheduleFlush(immediate); }; @@ -1016,6 +1336,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { if (run && run.kind === "cli") { run.phase = event.exitCode == null || event.exitCode === 0 ? "completed" : "failed"; run.lastActiveAt = now(); + recentRuns.set(run.sessionId, { ...run }); scheduleFlush(false); } // Keep it simple: only surface non-clean exits of tracked CLI sessions. @@ -1097,6 +1418,8 @@ export function createPushPublisherService(deps: PushPublisherDeps) { const activityId = prActivityId(scopeKey, notification); prActivities.set(activityId, { id: activityId, + scopeKey, + prId: notification.prId?.trim() || null, prNumber: notification.prNumber, title: notification.prTitle?.trim() || `Pull request #${notification.prNumber}`, phase: notification.kind, @@ -1168,11 +1491,19 @@ export function createPushPublisherService(deps: PushPublisherDeps) { } scopes.delete(scopeKey); // A closed project stops contributing to the aggregate Live Activity. + let removedContribution = false; for (const [sessionId, run] of runs) { - if (run.scopeKey === scopeKey) runs.delete(sessionId); + if (run.scopeKey === scopeKey) { + runs.delete(sessionId); + removedContribution = true; + } } const removedPrActivities = removePrActivitiesForScope(scopeKey); + removedContribution = removedContribution || removedPrActivities; if (scopes.size === 0) { + if (removedContribution && !disposed) { + finalAttentionSnapshotPending = true; + } // Nothing attached — stop the flush loop so no timer lingers. The shared // instance stays memoized and resumes when a project re-attaches. if (flushTimer) clearTimeout(flushTimer); @@ -1180,8 +1511,11 @@ export function createPushPublisherService(deps: PushPublisherDeps) { flushFireAt = 0; if (prExpiryTimer) clearTimeout(prExpiryTimer); prExpiryTimer = null; + if (attentionHeartbeatTimer) clearInterval(attentionHeartbeatTimer); + attentionHeartbeatTimer = null; prActivities.clear(); pendingAlerts = []; + scheduleFinalAttentionSnapshot(); } else if (removedPrActivities) { scheduleFlush(false); } @@ -1192,8 +1526,10 @@ export function createPushPublisherService(deps: PushPublisherDeps) { for (const scopeKey of [...scopes.keys()]) detachScope(scopeKey); if (flushTimer) clearTimeout(flushTimer); if (prExpiryTimer) clearTimeout(prExpiryTimer); + if (attentionHeartbeatTimer) clearInterval(attentionHeartbeatTimer); flushTimer = null; prExpiryTimer = null; + attentionHeartbeatTimer = null; }; return { @@ -1228,8 +1564,14 @@ export function createPushPublisherService(deps: PushPublisherDeps) { agentChatService: sources.agentChatService ?? null, resolveLaneName: sources.resolveLaneName, resolveCliSession: sources.resolveCliSession, + projectName: sources.projectName?.trim() || "ADE project", + projectRoot: sources.projectRoot?.trim() || null, unsubscribes: scopeUnsubscribes, }); + if (!attentionHeartbeatTimer) { + attentionHeartbeatTimer = setInterval(() => scheduleFlush(false), ATTENTION_HEARTBEAT_MS); + attentionHeartbeatTimer.unref?.(); + } return () => detachScope(scopeKey); }, @@ -1326,6 +1668,45 @@ export function createPushPublisherService(deps: PushPublisherDeps) { return buildDeliveryStatus(deviceId); }, + async getAttentionSnapshot( + since = 0, + streamId?: string | null, + ): Promise { + const snapshot = await deps.relayClient.getAttentionSnapshot?.(since, streamId); + return snapshot ?? { + contractVersion: ATTENTION_CONTRACT_VERSION, + streamId: null, + revision: 0, + generatedAt: new Date(now()).toISOString(), + items: [], + tombstones: [], + }; + }, + + async acknowledgeAttention(args: { + itemIds: string[]; + seenAt?: string; + dismissedAt?: string | null; + }): Promise { + await deps.relayClient.acknowledgeAttention?.(args); + }, + + async reportAttentionPresence(presence: AttentionPresence): Promise { + await deps.relayClient.reportAttentionPresence?.(presence); + }, + + async getAttentionPreferences(accountOwnerId: string): Promise { + return await deps.relayClient.getAttentionPreferences?.(accountOwnerId) + ?? DEFAULT_ATTENTION_PREFERENCES; + }, + + async putAttentionPreferences( + accountOwnerId: string, + preferences: AttentionPreferences, + ): Promise { + await deps.relayClient.putAttentionPreferences?.(accountOwnerId, preferences); + }, + dispose, /** diff --git a/apps/ade-cli/src/services/push/pushRelayClient.ts b/apps/ade-cli/src/services/push/pushRelayClient.ts index 3bf471f90..b5015532c 100644 --- a/apps/ade-cli/src/services/push/pushRelayClient.ts +++ b/apps/ade-cli/src/services/push/pushRelayClient.ts @@ -1,5 +1,11 @@ import { createHash, createHmac } from "node:crypto"; import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; +import type { + AttentionItem, + AttentionPreferences, + AttentionPresence, + AttentionSnapshot, +} from "../../../../desktop/src/shared/types/attention"; import type { PushDeviceRegistration } from "../../../../desktop/src/shared/types/push"; import type { PushRegistrationStore } from "./pushRegistrationStore"; @@ -56,6 +62,13 @@ export type PushRelayHealth = { apnsConfigured: boolean; }; +export type AttentionRelayPublishPayload = { + machineName: string; + fullSnapshot: true; + items: AttentionItem[]; + tombstones?: Array<{ id: string; revision: number }>; +}; + /** * Canonical string the relay commits every signed call to. Binding method, * path and body hash prevents replaying a captured signature against another @@ -78,13 +91,20 @@ export function createPushRelayClient(args: { store: PushRegistrationStore; logger: Logger; baseUrl?: string; + getAccountAccessToken?: () => Promise; + getAccountUserId?: () => string | null; }) { const baseUrl = (args.baseUrl ?? process.env.ADE_PUSH_RELAY_URL?.trim() ?? "").trim() || DEFAULT_RELAY_URL; const request = async ( method: string, pathSuffix: string, - options?: { body?: unknown; signed?: boolean }, + options?: { + body?: unknown; + signed?: boolean; + accountAuthorized?: boolean; + expectedAccountUserId?: string; + }, ): Promise => { const url = new URL(`${baseUrl}${pathSuffix}`); const bodyString = options?.body === undefined ? "" : JSON.stringify(options.body); @@ -104,6 +124,33 @@ export function createPushRelayClient(args: { body: bodyString, }); } + if (options?.accountAuthorized) { + if ( + options.expectedAccountUserId + && args.getAccountUserId?.() !== options.expectedAccountUserId + ) { + return { + ok: false, + status: 409, + body: { error: "ADE account changed before the request was authorized" }, + }; + } + const token = await args.getAccountAccessToken?.(); + if (!token) { + return { ok: false, status: 401, body: { error: "ADE account is not signed in" } }; + } + if ( + options.expectedAccountUserId + && args.getAccountUserId?.() !== options.expectedAccountUserId + ) { + return { + ok: false, + status: 409, + body: { error: "ADE account changed while the request was authorized" }, + }; + } + headers.authorization = `Bearer ${token}`; + } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); @@ -198,6 +245,91 @@ export function createPushRelayClient(args: { return requireOk("publish", response); }, + async publishAttention(payload: AttentionRelayPublishPayload): Promise | null> { + if (!args.getAccountAccessToken) return null; + const response = await request("POST", machinePath("/attention"), { + body: payload, + signed: true, + accountAuthorized: true, + }); + if (response.status === 401 && response.body?.error === "ADE account is not signed in") { + return null; + } + return requireOk("publishAttention", response); + }, + + async getAttentionSnapshot( + since = 0, + streamId?: string | null, + ): Promise { + if (!args.getAccountAccessToken) return null; + const query = new URLSearchParams({ + since: String(Math.max(0, Math.trunc(since))), + }); + if (streamId?.trim()) query.set("streamId", streamId.trim()); + const response = await request( + "GET", + `/attention/account/snapshot?${query.toString()}`, + { accountAuthorized: true }, + ); + if (response.status === 401 && response.body?.error === "ADE account is not signed in") { + return null; + } + return requireOk("getAttentionSnapshot", response) as unknown as AttentionSnapshot; + }, + + async acknowledgeAttention(acknowledgment: { + itemIds: string[]; + seenAt?: string; + dismissedAt?: string | null; + }): Promise | null> { + if (!args.getAccountAccessToken) return null; + const response = await request("POST", "/attention/account/ack", { + body: acknowledgment, + accountAuthorized: true, + }); + if (response.status === 401 && response.body?.error === "ADE account is not signed in") { + return null; + } + return requireOk("acknowledgeAttention", response); + }, + + async reportAttentionPresence(presence: AttentionPresence): Promise { + const response = await request("POST", "/attention/account/presence", { + body: presence, + accountAuthorized: true, + }); + if (response.status === 401 && response.body?.error === "ADE account is not signed in") return; + requireOk("reportAttentionPresence", response); + }, + + async getAttentionPreferences( + expectedAccountUserId: string, + ): Promise { + const response = await request("GET", "/attention/account/preferences", { + accountAuthorized: true, + expectedAccountUserId, + }); + if (response.status === 401 && response.body?.error === "ADE account is not signed in") { + return null; + } + const body = requireOk("getAttentionPreferences", response); + return (body.preferences ?? null) as AttentionPreferences | null; + }, + + async putAttentionPreferences( + expectedAccountUserId: string, + preferences: AttentionPreferences, + ): Promise { + const response = await request("PUT", "/attention/account/preferences", { + body: preferences, + accountAuthorized: true, + expectedAccountUserId, + }); + if (response.status === 401 && response.body?.error === "ADE account is not signed in") return; + requireOk("putAttentionPreferences", response); + }, + async health(): Promise { const response = await request("GET", "/health"); const body = response.body ?? {}; diff --git a/apps/desktop/native/ADEAttentionNotch/DESIGN_NOTES.md b/apps/desktop/native/ADEAttentionNotch/DESIGN_NOTES.md new file mode 100644 index 000000000..ced0e3b79 --- /dev/null +++ b/apps/desktop/native/ADEAttentionNotch/DESIGN_NOTES.md @@ -0,0 +1,43 @@ +# ADE Attention Notch design notes + +The helper is a clean ADE implementation built on public macOS AppKit and +SwiftUI APIs. No source from T3Notch was copied because that repository did not +publish a license when this implementation was created. + +Interaction and geometry research included these permissively licensed projects: + +- [DynamicNotchKit](https://github.com/MrKai77/DynamicNotchKit) — MIT +- [OpenNook](https://github.com/MrKai77/OpenNook) — MIT +- [CodeIsland](https://github.com/norech/CodeIsland) — MIT +- [codex-island](https://github.com/jordond/codex-island) — MIT + +The ADE implementation owns its protocol, state reducer, panel geometry, hit +testing, view hierarchy, animations, and particle rendering. These repositories +are design references only and are not bundled dependencies. + +Provider marks are adapted from the MIT-licensed LobeHub Lobe Icons package. +Only the five SVGs used by the helper are bundled. See +`THIRD_PARTY_NOTICES.md` for attribution and license terms. + +## Local validation + +```bash +swift test --package-path apps/desktop/native/ADEAttentionNotch +npm --prefix apps/desktop run build:notch +npx --prefix apps/desktop vitest run \ + src/main/services/attention/attentionNotchHelper.test.ts +``` + +The helper reads one JSON object per line from standard input. It accepts raw +`AttentionSnapshot` objects or command envelopes: + +```json +{"type":"snapshot","snapshot":{"contractVersion":1,"revision":1,"generatedAt":"...","items":[]}} +{"type":"settings","settings":{"enabled":true,"hideDetails":false,"celebrationsEnabled":true,"soundsEnabled":true}} +{"type":"visibility","visible":false} +{"type":"reanchor"} +{"type":"quit"} +``` + +It emits `open`, `action`, `surface`, and `protocol_error` JSON lines on standard +output. Diagnostic text is written only to standard error. diff --git a/apps/desktop/native/ADEAttentionNotch/Package.swift b/apps/desktop/native/ADEAttentionNotch/Package.swift new file mode 100644 index 000000000..fcf452026 --- /dev/null +++ b/apps/desktop/native/ADEAttentionNotch/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "ADEAttentionNotch", + platforms: [ + .macOS(.v13), + ], + products: [ + .library( + name: "ADEAttentionNotchCore", + targets: ["ADEAttentionNotchCore"] + ), + .executable( + name: "ade-attention-notch", + targets: ["ADEAttentionNotch"] + ), + ], + targets: [ + .target( + name: "ADEAttentionNotchCore" + ), + .executableTarget( + name: "ADEAttentionNotch", + dependencies: ["ADEAttentionNotchCore"], + resources: [ + .process("Resources"), + ] + ), + .testTarget( + name: "ADEAttentionNotchCoreTests", + dependencies: ["ADEAttentionNotchCore"] + ), + ] +) diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/ADEAttentionNotchApp.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/ADEAttentionNotchApp.swift new file mode 100644 index 000000000..e6463828a --- /dev/null +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/ADEAttentionNotchApp.swift @@ -0,0 +1,58 @@ +import AppKit +import SwiftUI +import ADEAttentionNotchCore + +@main +struct ADEAttentionNotchApp: App { + @NSApplicationDelegateAdaptor(NotchAppDelegate.self) private var appDelegate + + var body: some Scene { + Settings { + EmptyView() + } + } +} + +@MainActor +final class NotchAppDelegate: NSObject, NSApplicationDelegate { + private let model = NotchViewModel() + private let transport = StandardIOTransport() + private var panelController: NotchPanelController? + private var statusController: NotchStatusItemController? + + func applicationDidFinishLaunching(_ notification: Notification) { + NSApplication.shared.setActivationPolicy(.accessory) + + let panelController = NotchPanelController(model: model) + self.panelController = panelController + let statusController = NotchStatusItemController(model: model, panelController: panelController) + self.statusController = statusController + panelController.surfaceChanged = { [weak transport, weak statusController] displayId, physical in + statusController?.refresh() + transport?.send(NotchOutput( + type: "surface", + displayId: displayId, + surface: physical ? "physical_notch" : "menu_bar" + )) + } + panelController.reanchor() + + model.emit = { [weak transport] output in + transport?.send(output) + } + model.requestReanchor = { [weak panelController, weak statusController] in + panelController?.reanchor() + statusController?.refresh() + } + model.requestQuit = { + NSApplication.shared.terminate(nil) + } + transport.start { [weak model] input in + model?.handle(input) + } + } + + func applicationWillTerminate(_ notification: Notification) { + panelController?.close() + } +} diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchPanelController.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchPanelController.swift new file mode 100644 index 000000000..d02ddb0d4 --- /dev/null +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchPanelController.swift @@ -0,0 +1,379 @@ +import AppKit +import Combine +import SwiftUI +import ADEAttentionNotchCore + +final class AttentionNotchPanel: NSPanel { + var allowsKeyActivation = false + + override var canBecomeKey: Bool { allowsKeyActivation } + override var canBecomeMain: Bool { false } +} + +final class ShapeHostingView: NSHostingView { + var interactivePath: (() -> NSBezierPath?)? + + override func hitTest(_ point: NSPoint) -> NSView? { + guard interactivePath?().map({ $0.contains(point) }) == true else { return nil } + return super.hitTest(point) + } +} + +@MainActor +final class NotchPanelController { + private let model: NotchViewModel + private let panel: AttentionNotchPanel + private var hostingView: ShapeHostingView? + private var cancellables = Set() + private var eventMonitors: [Any] = [] + private var lastPointerInside = false + + private(set) var hasPhysicalNotch = false + private(set) var displayId: UInt32? + private var physicalNotchWidth: Double? + private var safeAreaTop: Double = 0 + var surfaceChanged: ((UInt32, Bool) -> Void)? + + init(model: NotchViewModel) { + self.model = model + panel = AttentionNotchPanel( + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + panel.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue + 8) + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle] + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = false + panel.hidesOnDeactivate = false + panel.isMovable = false + panel.acceptsMouseMovedEvents = true + panel.animationBehavior = .none + panel.ignoresMouseEvents = true + + observeModel() + installEventMonitors() + installScreenObservers() + reanchor() + } + + deinit { + for monitor in eventMonitors { + NSEvent.removeMonitor(monitor) + } + NotificationCenter.default.removeObserver(self) + NSWorkspace.shared.notificationCenter.removeObserver(self) + } + + func reanchor() { + guard let selected = selectedScreen() else { + panel.orderOut(nil) + return + } + let geometry = geometry(for: selected) + hasPhysicalNotch = geometry.hasPhysicalNotch + physicalNotchWidth = geometry.physicalNotchWidth + safeAreaTop = geometry.hasPhysicalNotch ? geometry.safeAreaTop : 0 + displayId = geometry.displayId + let target = geometry.panelFrame + panel.setFrame( + NSRect(x: target.x, y: target.y, width: target.width, height: target.height), + display: false + ) + rebuildContent() + updatePresentation() + updatePointer(at: NSEvent.mouseLocation) + surfaceChanged?(geometry.displayId, geometry.hasPhysicalNotch) + } + + func explicitToggle() { + model.toggleExpanded() + panel.allowsKeyActivation = model.interaction.isExplicitlyInteractive + if panel.allowsKeyActivation { + panel.makeKey() + } + updatePresentation() + } + + func close() { + panel.orderOut(nil) + } + + private func observeModel() { + Publishers.CombineLatest3(model.$interaction, model.$items, model.$settings) + .receive(on: RunLoop.main) + .sink { [weak self] _, _, _ in + guard let self else { return } + self.rebuildContent() + self.updatePresentation() + self.updatePointer(at: NSEvent.mouseLocation) + } + .store(in: &cancellables) + } + + private func rebuildContent() { + let root = NotchSurfaceView( + model: model, + hasPhysicalNotch: hasPhysicalNotch, + physicalNotchWidth: physicalNotchWidth, + safeAreaTop: safeAreaTop + ) + if let hostingView { + hostingView.rootView = root + return + } + let view = ShapeHostingView(rootView: root) + view.frame = NSRect( + x: 0, + y: 0, + width: NotchDisplayGeometry.panelSize.width, + height: NotchDisplayGeometry.panelSize.height + ) + view.autoresizingMask = [.width, .height] + view.interactivePath = { [weak self, weak view] in + guard let self, let view else { return nil } + return self.interactivePath(in: view.bounds) + } + panel.contentView = view + hostingView = view + } + + private func updatePresentation() { + let shouldShow = model.shouldPresentSurface + if shouldShow { + panel.orderFrontRegardless() + } else { + panel.orderOut(nil) + } + panel.allowsKeyActivation = model.interaction.isExplicitlyInteractive + if !panel.allowsKeyActivation, panel.isKeyWindow { + panel.resignKey() + } + } + + private func installEventMonitors() { + var lastGlobalMoveAt = 0.0 + if let global = NSEvent.addGlobalMonitorForEvents( + matching: [.mouseMoved, .leftMouseDragged, .leftMouseDown], + handler: { [weak self] event in + if event.type != .leftMouseDown { + let now = ProcessInfo.processInfo.systemUptime + guard now - lastGlobalMoveAt >= 1 / 30 else { return } + lastGlobalMoveAt = now + } + Task { @MainActor in + self?.handleMouseEvent(event, global: true) + } + }) { + eventMonitors.append(global) + } + if let local = NSEvent.addLocalMonitorForEvents( + matching: [.mouseMoved, .leftMouseDragged, .leftMouseDown, .keyDown], + handler: { [weak self] event in + guard let self else { return event } + return self.handleLocalEvent(event) + }) { + eventMonitors.append(local) + } + } + + private func handleLocalEvent(_ event: NSEvent) -> NSEvent? { + if event.type == .keyDown, model.interaction.isExplicitlyInteractive { + switch event.keyCode { + case 53: + model.dismissExpanded() + return nil + case 123: + model.navigate(delta: -1) + return nil + case 124: + model.navigate(delta: 1) + return nil + case 36, 76: + model.openSelected() + return nil + default: + break + } + } + handleMouseEvent(event, global: false) + return event + } + + private func handleMouseEvent(_ event: NSEvent, global: Bool) { + let location = NSEvent.mouseLocation + let inside = isInsideInteractiveShape(screenPoint: location) + if event.type == .leftMouseDown { + if inside { + panel.allowsKeyActivation = true + panel.makeKey() + if !global { + switch model.interaction.presentation { + case .compact, .prehover, .peek: + model.toggleExpanded() + case .celebration: + model.openSelected() + case .expanded, .attention: + break + } + } + } else if model.interaction.isExplicitlyInteractive { + model.dismissExpanded() + } + } + updatePointer(at: location) + } + + private func updatePointer(at screenPoint: NSPoint) { + let inside = model.shouldPresentSurface && isInsideInteractiveShape(screenPoint: screenPoint) + panel.ignoresMouseEvents = !inside + if inside != lastPointerInside { + lastPointerInside = inside + model.pointerChanged(isInside: inside) + } + } + + private func isInsideInteractiveShape(screenPoint: NSPoint) -> Bool { + guard let hostingView else { return false } + let windowPoint = panel.convertPoint(fromScreen: screenPoint) + let viewPoint = hostingView.convert(windowPoint, from: nil) + return interactivePath(in: hostingView.bounds).contains(viewPoint) + } + + private func interactivePath(in bounds: NSRect) -> NSBezierPath { + let size = notchSurfaceSize( + presentation: model.interaction.presentation, + physicalNotchWidth: hasPhysicalNotch ? physicalNotchWidth : nil, + safeAreaTop: safeAreaTop + ) + let y = hostingView?.isFlipped == true + ? bounds.minY + : bounds.maxY - size.height + let rect = NSRect( + x: bounds.midX - size.width / 2, + y: y, + width: size.width, + height: size.height + ) + if hasPhysicalNotch, let physicalNotchWidth { + return physicalInteractivePath( + in: rect, + notchWidth: physicalNotchWidth, + isFlipped: hostingView?.isFlipped == true + ) + } + return NSBezierPath( + roundedRect: rect, + xRadius: min(22, size.height / 3), + yRadius: min(22, size.height / 3) + ) + } + + private func physicalInteractivePath( + in rect: NSRect, + notchWidth: Double, + isFlipped: Bool + ) -> NSBezierPath { + let path = NSBezierPath() + let resolvedNotchWidth = min(rect.width - 20, max(120, notchWidth)) + let notchLeft = rect.midX - resolvedNotchWidth / 2 + let notchRight = rect.midX + resolvedNotchWidth / 2 + let shoulder = min(24, max(15, rect.height * 0.18)) + let bottomRadius = min(25, max(12, rect.height * 0.16)) + let y: (Double) -> Double = { offset in + isFlipped ? rect.minY + offset : rect.maxY - offset + } + path.move(to: NSPoint(x: notchLeft, y: y(0))) + path.line(to: NSPoint(x: notchRight, y: y(0))) + path.line(to: NSPoint(x: notchRight, y: y(shoulder * 0.34))) + path.curve( + to: NSPoint(x: rect.maxX - 7, y: y(shoulder)), + controlPoint1: NSPoint(x: notchRight + 2, y: y(shoulder * 0.72)), + controlPoint2: NSPoint(x: rect.maxX - 18, y: y(shoulder * 0.84)) + ) + path.curve( + to: NSPoint(x: rect.maxX, y: y(shoulder + 7)), + controlPoint1: NSPoint(x: rect.maxX - 2, y: y(shoulder)), + controlPoint2: NSPoint(x: rect.maxX, y: y(shoulder + 2)) + ) + path.line(to: NSPoint(x: rect.maxX, y: y(rect.height - bottomRadius))) + path.curve( + to: NSPoint(x: rect.maxX - bottomRadius, y: y(rect.height)), + controlPoint1: NSPoint(x: rect.maxX, y: y(rect.height)), + controlPoint2: NSPoint(x: rect.maxX, y: y(rect.height)) + ) + path.line(to: NSPoint(x: rect.minX + bottomRadius, y: y(rect.height))) + path.curve( + to: NSPoint(x: rect.minX, y: y(rect.height - bottomRadius)), + controlPoint1: NSPoint(x: rect.minX, y: y(rect.height)), + controlPoint2: NSPoint(x: rect.minX, y: y(rect.height)) + ) + path.line(to: NSPoint(x: rect.minX, y: y(shoulder + 7))) + path.curve( + to: NSPoint(x: rect.minX + 7, y: y(shoulder)), + controlPoint1: NSPoint(x: rect.minX, y: y(shoulder + 2)), + controlPoint2: NSPoint(x: rect.minX + 2, y: y(shoulder)) + ) + path.curve( + to: NSPoint(x: notchLeft, y: y(shoulder * 0.34)), + controlPoint1: NSPoint(x: rect.minX + 18, y: y(shoulder * 0.84)), + controlPoint2: NSPoint(x: notchLeft - 2, y: y(shoulder * 0.72)) + ) + path.close() + return path + } + + private func installScreenObservers() { + NotificationCenter.default.addObserver( + self, + selector: #selector(screenParametersChanged), + name: NSApplication.didChangeScreenParametersNotification, + object: nil + ) + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(screenParametersChanged), + name: NSWorkspace.didWakeNotification, + object: nil + ) + } + + @objc private func screenParametersChanged() { + reanchor() + } + + private func selectedScreen() -> NSScreen? { + if let preferred = model.settings.preferredDisplayId, + let preferredScreen = NSScreen.screens.first(where: { displayId(for: $0) == preferred }) { + return preferredScreen + } + if let notchedBuiltIn = NSScreen.screens.first(where: { geometry(for: $0).hasPhysicalNotch }) { + return notchedBuiltIn + } + return NSScreen.main ?? NSScreen.screens.first + } + + private func geometry(for screen: NSScreen) -> NotchDisplayGeometry { + let id = displayId(for: screen) + return NotchDisplayGeometry( + displayId: id, + frame: rect(screen.frame), + visibleFrame: rect(screen.visibleFrame), + safeAreaTop: screen.safeAreaInsets.top, + auxiliaryLeft: screen.auxiliaryTopLeftArea.map(rect), + auxiliaryRight: screen.auxiliaryTopRightArea.map(rect), + isBuiltIn: CGDisplayIsBuiltin(id) != 0 + ) + } + + private func displayId(for screen: NSScreen) -> CGDirectDisplayID { + (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value + ?? CGMainDisplayID() + } + + private func rect(_ value: NSRect) -> NotchRect { + NotchRect(x: value.minX, y: value.minY, width: value.width, height: value.height) + } +} diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchStatusItemController.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchStatusItemController.swift new file mode 100644 index 000000000..f3431788a --- /dev/null +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchStatusItemController.swift @@ -0,0 +1,57 @@ +import AppKit +import Combine + +@MainActor +final class NotchStatusItemController { + private let model: NotchViewModel + private let panelController: NotchPanelController + private var statusItem: NSStatusItem? + private var cancellables = Set() + + init(model: NotchViewModel, panelController: NotchPanelController) { + self.model = model + self.panelController = panelController + Publishers.CombineLatest3(model.$items, model.$interaction, model.$settings) + .sink { [weak self] _, _, _ in self?.refresh() } + .store(in: &cancellables) + refresh() + } + + func refresh() { + if !model.settings.enabled || panelController.hasPhysicalNotch { + if let statusItem { + NSStatusBar.system.removeStatusItem(statusItem) + self.statusItem = nil + } + return + } + + let item = statusItem ?? NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + statusItem = item + guard let button = item.button else { return } + button.target = self + button.action = #selector(togglePanel) + button.image = NSImage(systemSymbolName: statusSymbol, accessibilityDescription: "ADE Attention Center") + button.imagePosition = .imageOnly + button.toolTip = statusToolTip + button.setAccessibilityLabel("ADE Attention Center, \(statusToolTip)") + } + + private var statusSymbol: String { + if model.items.contains(where: \.isAttention) { return "bell.badge.fill" } + if model.items.contains(where: { $0.phase == "running" || $0.phase == "starting" }) { + return "sparkles" + } + return "checkmark.circle" + } + + private var statusToolTip: String { + guard let item = model.selectedItem else { return "No active attention items" } + let presentation = item.presentation(hideDetails: model.settings.hideDetails) + return "\(item.statusLabel): \(presentation.title)" + } + + @objc private func togglePanel() { + panelController.explicitToggle() + } +} diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchSurfaceView.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchSurfaceView.swift new file mode 100644 index 000000000..813c2befb --- /dev/null +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchSurfaceView.swift @@ -0,0 +1,827 @@ +import SwiftUI +import ADEAttentionNotchCore + +struct NotchSurfaceView: View { + @ObservedObject var model: NotchViewModel + let hasPhysicalNotch: Bool + let physicalNotchWidth: Double? + let safeAreaTop: Double + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + + private var state: NotchPresentationState { model.interaction.presentation } + private var size: NotchSize { + notchSurfaceSize( + presentation: state, + physicalNotchWidth: hasPhysicalNotch ? physicalNotchWidth : nil, + safeAreaTop: hasPhysicalNotch ? safeAreaTop : 0 + ) + } + private var item: AttentionItem? { model.selectedItem } + private var itemPresentation: AttentionItemPresentation? { + item?.presentation(hideDetails: model.settings.hideDetails) + } + + var body: some View { + ZStack(alignment: .top) { + Color.clear + surface + .frame(width: size.width, height: size.height) + .animation(surfaceAnimation, value: state) + .animation(surfaceAnimation, value: size) + } + .frame( + width: NotchDisplayGeometry.panelSize.width, + height: NotchDisplayGeometry.panelSize.height, + alignment: .top + ) + } + + private var surface: some View { + ZStack(alignment: .top) { + NotchContainerShape( + floating: !hasPhysicalNotch, + physicalNotchWidth: physicalNotchWidth + ) + .fill(backgroundStyle) + .overlay { + if !hasPhysicalNotch { + NotchContainerShape(floating: true, physicalNotchWidth: nil) + .strokeBorder( + LinearGradient( + colors: [.white.opacity(0.20), .white.opacity(0.035)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 0.8 + ) + } else { + VStack { + Spacer() + LinearGradient( + colors: [.clear, .white.opacity(0.18), .clear], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: min(size.width * 0.68, 230), height: 0.7) + } + } + } + .shadow( + color: .black.opacity(hasPhysicalNotch ? 0.34 : 0.46), + radius: hasPhysicalNotch ? 20 : 28, + y: hasPhysicalNotch ? 8 : 14 + ) + + Group { + switch state { + case .compact, .prehover: + compactContent + case .peek: + peekContent + case .expanded: + expandedContent + case .attention: + attentionContent + case .celebration: + celebrationContent + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.top, physicalContentTopInset) + .transition(.opacity.combined(with: .scale(scale: 0.965, anchor: .top))) + + if !reduceMotion, state == .prehover || state == .peek { + SurfaceSheen() + .clipShape(NotchContainerShape( + floating: !hasPhysicalNotch, + physicalNotchWidth: physicalNotchWidth + )) + .allowsHitTesting(false) + } + } + .contentShape(NotchContainerShape( + floating: !hasPhysicalNotch, + physicalNotchWidth: physicalNotchWidth + )) + .accessibilityElement(children: .contain) + .accessibilityLabel(accessibilitySummary) + .accessibilityHint("Click to expand ADE Attention Center") + } + + private var compactContent: some View { + Group { + if hasPhysicalNotch, let physicalNotchWidth { + physicalCompactContent(notchWidth: physicalNotchWidth) + } else { + floatingCompactContent + } + } + } + + private var floatingCompactContent: some View { + HStack(spacing: 8) { + ProviderMark( + provider: item?.provider, + kind: item?.kind ?? "agent", + phase: item?.phase ?? "running", + active: item?.isAttention == true || state == .prehover, + reducedMotion: reduceMotion + ) + Text(compactIdentityLabel) + .font(.system(size: 11.5, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.82) + .layoutPriority(1) + Spacer(minLength: 4) + Circle() + .fill(statusColor) + .frame(width: 5, height: 5) + .shadow(color: statusColor.opacity(0.6), radius: reduceMotion ? 0 : 2) + Text(compactStatusLabel) + .font(.system(size: 9.5, weight: .semibold, design: .rounded)) + .foregroundStyle(statusColor.opacity(0.92)) + .lineLimit(1) + if let item { + ElapsedTimeLabel(isoDate: item.occurredAt) + } + } + .padding(.horizontal, 13) + } + + private func physicalCompactContent(notchWidth: Double) -> some View { + let reservedWidth = max(120, min(size.width - 40, notchWidth + 16)) + let earWidth = max(74, (size.width - reservedWidth) / 2) + return HStack(spacing: 0) { + HStack(spacing: 7) { + Text(physicalCompactIdentityLabel) + .font(.system(size: 10.5, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.78) + .frame(maxWidth: .infinity, alignment: .trailing) + ProviderMark( + provider: item?.provider, + kind: item?.kind ?? "agent", + phase: item?.phase ?? "running", + active: item?.isAttention == true || state == .prehover, + reducedMotion: reduceMotion + ) + } + .padding(.leading, 9) + .padding(.trailing, 7) + .frame(width: earWidth) + + Color.clear + .frame(width: reservedWidth) + .accessibilityHidden(true) + + HStack(spacing: 6) { + Circle() + .fill(statusColor) + .frame(width: 5, height: 5) + .shadow(color: statusColor.opacity(0.62), radius: reduceMotion ? 0 : 2) + Text(compactEarStatusLabel) + .font(.system(size: 9.5, weight: .semibold, design: .rounded)) + .foregroundStyle(statusColor.opacity(0.94)) + .lineLimit(1) + .minimumScaleFactor(0.82) + if let item { + ElapsedTimeLabel(isoDate: item.occurredAt) + } + } + .padding(.leading, 7) + .padding(.trailing, 9) + .frame(width: earWidth, alignment: .leading) + } + .frame(width: size.width, height: max(34, min(size.height, safeAreaTop))) + } + + private var peekContent: some View { + HStack(spacing: 12) { + ProviderMark( + provider: item?.provider, + kind: item?.kind ?? "agent", + phase: item?.phase ?? "running", + active: item?.isAttention == true, + reducedMotion: reduceMotion + ) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(itemPresentation?.title ?? "ADE Attention Center") + .font(.system(size: 12.5, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + Spacer(minLength: 4) + Text(item?.statusLabel ?? "Ready") + .font(.system(size: 9.5, weight: .bold, design: .rounded)) + .foregroundStyle(statusColor) + } + Text(model.visiblePreview) + .font(.system(size: 10.5, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.62)) + .lineLimit(1) + if let progress = itemPresentation?.planProgress, progress.total > 0 { + ProgressView(value: Double(progress.completed), total: Double(progress.total)) + .tint(statusColor) + .scaleEffect(x: 1, y: 0.58) + } + } + } + .padding(.horizontal, 17) + .padding(.top, hasPhysicalNotch ? 5 : 0) + } + + private var expandedContent: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + ProviderMark( + provider: item?.provider, + kind: item?.kind ?? "agent", + phase: item?.phase ?? "running", + active: item?.isAttention == true, + reducedMotion: reduceMotion + ) + VStack(alignment: .leading, spacing: 2) { + Text("ADE Attention Center") + .font(.system(size: 12.5, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + Text(accountScopeLabel) + .font(.system(size: 9.5, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.48)) + } + Spacer() + navigationControls + } + .padding(.horizontal, 18) + .padding(.top, hasPhysicalNotch ? 10 : 14) + .padding(.bottom, 13) + + Divider().overlay(.white.opacity(0.08)) + + if let item { + VStack(alignment: .leading, spacing: 11) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(itemPresentation?.title ?? "ADE attention") + .font(.system(size: 15, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(2) + Text(itemPresentation?.scopeLabel ?? "Account-wide activity") + .font(.system(size: 10.5, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.48)) + } + Spacer(minLength: 10) + StatusChip(label: item.statusLabel, color: statusColor) + } + + Text(model.visiblePreview) + .font(.system(size: 11.5, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.72)) + .lineLimit(3) + .frame(maxWidth: .infinity, alignment: .leading) + + if let progress = itemPresentation?.planProgress, progress.total > 0 { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(progress.current ?? "Plan progress") + .lineLimit(1) + Spacer() + Text("\(progress.completed)/\(progress.total)") + } + .font(.system(size: 9.5, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.48)) + ProgressView(value: Double(progress.completed), total: Double(progress.total)) + .tint(statusColor) + } + } else if let activity = itemPresentation?.recentActivity, !activity.isEmpty { + VStack(alignment: .leading, spacing: 5) { + ForEach(Array(activity.prefix(3).enumerated()), id: \.offset) { _, line in + HStack(alignment: .firstTextBaseline, spacing: 7) { + Circle().fill(statusColor.opacity(0.8)).frame(width: 4, height: 4) + Text(line) + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.56)) + .lineLimit(1) + } + } + } + } + } + .padding(.horizontal, 18) + .padding(.vertical, 15) + + Spacer(minLength: 6) + actionBar + .padding(.horizontal, 15) + .padding(.bottom, 15) + } else { + Spacer() + Text("Nothing needs your attention.") + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.5)) + Spacer() + } + } + } + + private var attentionContent: some View { + VStack(alignment: .leading, spacing: 11) { + HStack(spacing: 9) { + ProviderMark( + provider: item?.provider, + kind: item?.kind ?? "agent", + phase: item?.phase ?? "needs_you", + active: true, + reducedMotion: reduceMotion + ) + VStack(alignment: .leading, spacing: 2) { + Text(item?.statusLabel ?? "Needs you") + .font(.system(size: 11, weight: .bold, design: .rounded)) + .foregroundStyle(statusColor) + Text(itemPresentation?.title ?? "ADE needs your attention") + .font(.system(size: 14, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + } + Spacer() + } + Text(model.visiblePreview) + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.66)) + .lineLimit(2) + HStack { + Spacer() + compactActionButtons + } + } + .padding(.horizontal, 17) + .padding(.top, hasPhysicalNotch ? 10 : 14) + .padding(.bottom, 13) + } + + private var celebrationContent: some View { + ZStack { + CelebrationParticles(active: state == .celebration, reducedMotion: reduceMotion) + .allowsHitTesting(false) + VStack(spacing: 8) { + Image(systemName: "checkmark.seal.fill") + .font(.system(size: 28, weight: .semibold)) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, Color.green) + Text("Merged") + .font(.system(size: 17, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + Text(itemPresentation?.celebrationTitle ?? "Pull request merged") + .font(.system(size: 10.5, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.58)) + .lineLimit(1) + } + .padding(.top, hasPhysicalNotch ? 6 : 10) + } + } + + private var actionBar: some View { + HStack(spacing: 8) { + Text("\(model.interaction.selectedIndex + 1) of \(max(model.items.count, 1))") + .font(.system(size: 9.5, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.36)) + .padding(.leading, 4) + Spacer() + compactActionButtons + Button { + model.openSelected() + } label: { + Label("Open in ADE", systemImage: "arrow.up.forward.app.fill") + .font(.system(size: 10.5, weight: .bold, design: .rounded)) + } + .buttonStyle(NotchButtonStyle(prominent: true)) + .accessibilityHint("Opens the exact agent or pull request in ADE") + } + } + + private var compactActionButtons: some View { + ForEach(Array(model.navigationActions.prefix(2))) { action in + Button(action.navigationLabel) { + model.openFor(action) + } + .buttonStyle(NotchButtonStyle(prominent: action.kind == "approve")) + .accessibilityLabel(action.navigationLabel) + .accessibilityHint(action.navigationAccessibilityHint) + } + } + + private var navigationControls: some View { + HStack(spacing: 5) { + Button { + model.navigate(delta: -1) + } label: { + Image(systemName: "chevron.left") + } + .accessibilityLabel("Previous attention item") + Button { + model.navigate(delta: 1) + } label: { + Image(systemName: "chevron.right") + } + .accessibilityLabel("Next attention item") + } + .buttonStyle(NotchIconButtonStyle()) + } + + private var accountScopeLabel: String { + if model.settings.hideDetails { + return "Account-wide activity" + } + let projectCount = Set(model.items.map(\.project.projectId)).count + let machineCount = Set(model.items.map(\.machine.machineKey)).count + return "\(model.items.count) items · \(projectCount) projects · \(machineCount) machines" + } + + private var statusColor: Color { + notchStatusColor(for: item?.phase) + } + + private var compactIdentityLabel: String { + itemPresentation?.compactIdentity ?? "ADE" + } + + private var physicalCompactIdentityLabel: String { + compactIdentityLabel.count > 12 + ? String(compactIdentityLabel.prefix(12)) + : compactIdentityLabel + } + + private var compactStatusLabel: String { + switch item?.phase { + case "starting": return "Starting" + case "running": return "Working" + case "needs_you": return "Needs you" + case "completed": return "Done" + case "merged": return "Merged" + case "checks_failing": return "Checks failed" + case "merge_ready": return "Merge ready" + default: return item?.statusLabel ?? "Ready" + } + } + + private var compactEarStatusLabel: String { + switch item?.phase { + case "checks_failing", "failed": return "Failed" + case "changes_requested": return "Changes" + case "review_requested": return "Review" + case "merge_ready": return "Ready" + default: return compactStatusLabel + } + } + + private var backgroundStyle: AnyShapeStyle { + if reduceTransparency { + return AnyShapeStyle(Color(red: 0.025, green: 0.028, blue: 0.04)) + } + return AnyShapeStyle( + LinearGradient( + colors: [ + Color.black.opacity(hasPhysicalNotch ? 0.985 : 0.92), + Color(red: 0.035, green: 0.038, blue: 0.058).opacity(0.96), + ], + startPoint: .top, + endPoint: .bottomTrailing + ) + ) + } + + private var surfaceAnimation: Animation? { + if reduceMotion { + return .linear(duration: 0.01) + } + if state == .compact { + return .spring(response: 0.45, dampingFraction: 1, blendDuration: 0.08) + } + return .spring(response: 0.42, dampingFraction: 0.8, blendDuration: 0.12) + } + + private var physicalContentTopInset: CGFloat { + guard hasPhysicalNotch else { return 0 } + switch state { + case .compact, .prehover: + return 0 + case .peek, .expanded, .attention, .celebration: + return CGFloat(max(0, safeAreaTop)) + } + } + + private var accessibilitySummary: String { + itemPresentation?.accessibilitySummary ?? "ADE Attention Center" + } +} + +private struct NotchContainerShape: InsettableShape { + let floating: Bool + let physicalNotchWidth: Double? + var insetAmount: CGFloat = 0 + + func path(in rect: CGRect) -> Path { + let rect = rect.insetBy(dx: insetAmount, dy: insetAmount) + if !floating, let physicalNotchWidth { + return physicalPath(in: rect, notchWidth: physicalNotchWidth) + } + let bottomRadius = min(24, rect.height * 0.24) + let topRadius: CGFloat = floating ? min(18, bottomRadius) : 5 + var path = Path() + path.move(to: CGPoint(x: rect.minX + topRadius, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX - topRadius, y: rect.minY)) + path.addQuadCurve( + to: CGPoint(x: rect.maxX, y: rect.minY + topRadius), + control: CGPoint(x: rect.maxX, y: rect.minY) + ) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - bottomRadius)) + path.addQuadCurve( + to: CGPoint(x: rect.maxX - bottomRadius, y: rect.maxY), + control: CGPoint(x: rect.maxX, y: rect.maxY) + ) + path.addLine(to: CGPoint(x: rect.minX + bottomRadius, y: rect.maxY)) + path.addQuadCurve( + to: CGPoint(x: rect.minX, y: rect.maxY - bottomRadius), + control: CGPoint(x: rect.minX, y: rect.maxY) + ) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + topRadius)) + path.addQuadCurve( + to: CGPoint(x: rect.minX + topRadius, y: rect.minY), + control: CGPoint(x: rect.minX, y: rect.minY) + ) + path.closeSubpath() + return path + } + + private func physicalPath(in rect: CGRect, notchWidth: Double) -> Path { + let centerX = rect.midX + let resolvedNotchWidth = min(rect.width - 20, max(120, notchWidth - insetAmount * 2)) + let notchLeft = centerX - resolvedNotchWidth / 2 + let notchRight = centerX + resolvedNotchWidth / 2 + let shoulderDepth = min(24, max(15, rect.height * 0.18)) + let bottomRadius = min(25, max(12, rect.height * 0.16)) + var path = Path() + path.move(to: CGPoint(x: notchLeft, y: rect.minY)) + path.addLine(to: CGPoint(x: notchRight, y: rect.minY)) + path.addLine(to: CGPoint(x: notchRight, y: rect.minY + shoulderDepth * 0.34)) + path.addCurve( + to: CGPoint(x: rect.maxX - 7, y: rect.minY + shoulderDepth), + control1: CGPoint(x: notchRight + 2, y: rect.minY + shoulderDepth * 0.72), + control2: CGPoint(x: rect.maxX - 18, y: rect.minY + shoulderDepth * 0.84) + ) + path.addQuadCurve( + to: CGPoint(x: rect.maxX, y: rect.minY + shoulderDepth + 7), + control: CGPoint(x: rect.maxX, y: rect.minY + shoulderDepth) + ) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - bottomRadius)) + path.addQuadCurve( + to: CGPoint(x: rect.maxX - bottomRadius, y: rect.maxY), + control: CGPoint(x: rect.maxX, y: rect.maxY) + ) + path.addLine(to: CGPoint(x: rect.minX + bottomRadius, y: rect.maxY)) + path.addQuadCurve( + to: CGPoint(x: rect.minX, y: rect.maxY - bottomRadius), + control: CGPoint(x: rect.minX, y: rect.maxY) + ) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + shoulderDepth + 7)) + path.addQuadCurve( + to: CGPoint(x: rect.minX + 7, y: rect.minY + shoulderDepth), + control: CGPoint(x: rect.minX, y: rect.minY + shoulderDepth) + ) + path.addCurve( + to: CGPoint(x: notchLeft, y: rect.minY + shoulderDepth * 0.34), + control1: CGPoint(x: rect.minX + 18, y: rect.minY + shoulderDepth * 0.84), + control2: CGPoint(x: notchLeft - 2, y: rect.minY + shoulderDepth * 0.72) + ) + path.addLine(to: CGPoint(x: notchLeft, y: rect.minY)) + path.closeSubpath() + return path + } + + func inset(by amount: CGFloat) -> some InsettableShape { + var copy = self + copy.insetAmount += amount + return copy + } +} + +private struct ProviderMark: View { + let provider: String? + let kind: String + let phase: String + let active: Bool + let reducedMotion: Bool + + var body: some View { + TimelineView(.animation(minimumInterval: 1 / 24, paused: !active || reducedMotion)) { timeline in + let t = timeline.date.timeIntervalSinceReferenceDate + let pulse = active && !reducedMotion ? (sin(t * 3.4) + 1) / 2 : 0 + ZStack { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(providerGradient) + .overlay { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(.white.opacity(0.16), lineWidth: 0.7) + } + Group { + if let providerIconName { + Image(providerIconName, bundle: .module) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 13, height: 13) + } else if kind == "pull_request" { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 10, weight: .bold)) + } else { + Text(monogram) + .font(.system(size: monogram.count > 1 ? 7.5 : 10.5, weight: .heavy, design: .rounded)) + .tracking(monogram.count > 1 ? -0.4 : 0) + } + } + .foregroundStyle(.white.opacity(0.92)) + + Circle() + .fill(statusColor) + .frame(width: 5.5, height: 5.5) + .overlay(Circle().stroke(Color.black.opacity(0.88), lineWidth: 1.2)) + .shadow(color: statusColor.opacity(0.72), radius: 2 + pulse * 2) + .offset(x: 8.5, y: 8.5) + } + .frame(width: 23, height: 23) + } + .accessibilityLabel("\(providerName) provider, \(phase.replacingOccurrences(of: "_", with: " "))") + } + + private var statusColor: Color { + notchStatusColor(for: phase) + } + + private var providerName: String { + let value = provider?.lowercased() ?? "" + if value.contains("codex") || value.contains("openai") { return "Codex" } + if value.contains("claude") || value.contains("anthropic") { return "Claude" } + if value.contains("cursor") { return "Cursor" } + if value.contains("opencode") { return "OpenCode" } + if value.contains("droid") || value.contains("factory") { return "Droid" } + if let provider, !provider.isEmpty { return provider } + return "ADE" + } + + private var providerIconName: String? { + let value = providerName.lowercased() + if value.contains("codex") || value.contains("openai") { return "openai" } + if value.contains("claude") || value.contains("anthropic") { return "claude" } + if value.contains("cursor") { return "cursor" } + if value.contains("opencode") { return "opencode" } + if value.contains("github") { return "github" } + return nil + } + + private var monogram: String { + String(providerName.prefix(1)).uppercased() + } + + private var providerGradient: LinearGradient { + let colors: [Color] + switch providerName { + case "Claude": colors = [Color(red: 0.88, green: 0.42, blue: 0.25), Color(red: 0.52, green: 0.22, blue: 0.17)] + case "Cursor": colors = [Color(red: 0.35, green: 0.36, blue: 0.42), Color(red: 0.10, green: 0.11, blue: 0.15)] + case "OpenCode": colors = [Color(red: 0.26, green: 0.76, blue: 0.56), Color(red: 0.09, green: 0.34, blue: 0.27)] + case "Droid": colors = [Color(red: 0.96, green: 0.69, blue: 0.25), Color(red: 0.51, green: 0.29, blue: 0.09)] + case "ADE": colors = [Color(red: 0.48, green: 0.30, blue: 0.94), Color(red: 0.18, green: 0.12, blue: 0.38)] + default: colors = [Color(red: 0.32, green: 0.36, blue: 0.44), Color(red: 0.10, green: 0.12, blue: 0.16)] + } + return LinearGradient(colors: colors, startPoint: .topLeading, endPoint: .bottomTrailing) + } +} + +private func notchStatusColor(for phase: String?) -> Color { + switch notchStatusTone(for: phase) { + case .blue: + return Color(red: 0.35, green: 0.66, blue: 1) + case .amber: + return Color(red: 1, green: 0.69, blue: 0.32) + case .red: + return Color(red: 1, green: 0.39, blue: 0.44) + case .violet: + return Color(red: 0.68, green: 0.52, blue: 1) + case .green: + return Color(red: 0.37, green: 0.88, blue: 0.58) + case .neutral: + return Color.white.opacity(0.44) + } +} + +private struct SurfaceSheen: View { + var body: some View { + TimelineView(.animation(minimumInterval: 1 / 24)) { timeline in + let t = timeline.date.timeIntervalSinceReferenceDate + LinearGradient( + colors: [.clear, .white.opacity(0.075), .clear], + startPoint: .leading, + endPoint: .trailing + ) + .rotationEffect(.degrees(-18)) + .offset(x: sin(t * 1.8) * 110) + } + } +} + +private struct StatusChip: View { + let label: String + let color: Color + + var body: some View { + Text(label.uppercased()) + .font(.system(size: 8.5, weight: .heavy, design: .rounded)) + .tracking(0.6) + .foregroundStyle(color) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(color.opacity(0.12), in: Capsule()) + .overlay(Capsule().stroke(color.opacity(0.22), lineWidth: 0.7)) + } +} + +private struct ElapsedTimeLabel: View { + let isoDate: String + + var body: some View { + TimelineView(.periodic(from: .now, by: 1)) { timeline in + Text(elapsed(at: timeline.date)) + .font(.system(size: 9.5, weight: .semibold, design: .monospaced)) + .foregroundStyle(.white.opacity(0.36)) + } + .accessibilityLabel("Elapsed time") + } + + private func elapsed(at now: Date) -> String { + attentionElapsedLabel(since: isoDate, now: now) + } +} + +private struct NotchButtonStyle: ButtonStyle { + let prominent: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 10.5, weight: .bold, design: .rounded)) + .foregroundStyle(prominent ? Color.black.opacity(0.82) : .white.opacity(0.75)) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background( + prominent + ? AnyShapeStyle(Color.white) + : AnyShapeStyle(Color.white.opacity(configuration.isPressed ? 0.14 : 0.08)), + in: Capsule() + ) + .scaleEffect(configuration.isPressed ? 0.96 : 1) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + } +} + +private struct NotchIconButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white.opacity(0.62)) + .frame(width: 25, height: 24) + .background(.white.opacity(configuration.isPressed ? 0.15 : 0.07), in: Circle()) + } +} + +private struct CelebrationParticles: View { + let active: Bool + let reducedMotion: Bool + + var body: some View { + TimelineView(.animation(minimumInterval: 1 / 30, paused: !active || reducedMotion)) { timeline in + Canvas { context, size in + let elapsed = timeline.date.timeIntervalSinceReferenceDate + for index in 0..<18 { + let seed = Double((index * 47) % 101) / 101 + let phase = reducedMotion ? 0.42 : elapsed.truncatingRemainder(dividingBy: 1.65) / 1.65 + let x = size.width * (0.12 + 0.76 * Double((index * 37) % 97) / 97) + let drift = sin(seed * 19 + phase * 7) * 13 + let y = size.height * (0.10 + phase * 0.84) + let opacity = max(0, 1 - phase * 1.18) + let rect = CGRect(x: x + drift, y: y, width: 4 + seed * 3, height: 7 + seed * 4) + context.opacity = opacity + context.fill( + Path(roundedRect: rect, cornerRadius: 1.5), + with: .color(palette[index % palette.count]) + ) + } + } + } + .accessibilityHidden(true) + } + + private var palette: [Color] { + [.cyan, .indigo, .purple, .pink, .green, .yellow] + } +} diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchViewModel.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchViewModel.swift new file mode 100644 index 000000000..809334b71 --- /dev/null +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchViewModel.swift @@ -0,0 +1,273 @@ +import AppKit +import Combine +import Foundation +import ADEAttentionNotchCore + +@MainActor +final class NotchViewModel: ObservableObject { + private enum DeferredTransient { + case attention(itemId: String) + case celebration(itemId: String) + } + + @Published private(set) var items: [AttentionItem] = [] + @Published private(set) var interaction = NotchInteractionState() + @Published private(set) var pointerInside = false + @Published private(set) var settings = NotchSettings() + + var emit: (NotchOutput) -> Void = { _ in } + var requestReanchor: () -> Void = {} + var requestQuit: () -> Void = {} + + private var peekTask: Task? + private var closeTask: Task? + private var transientTask: Task? + private var fingerprintsById: [String: String] = [:] + private var hostVisibilityRequested = true + private var hoveredItemId: String? + private var deferredTransient: DeferredTransient? + + var selectedItem: AttentionItem? { + guard items.indices.contains(interaction.selectedIndex) else { return nil } + return items[interaction.selectedIndex] + } + + var hasPhysicalItems: Bool { !items.isEmpty } + + var shouldPresentSurface: Bool { + settings.enabled && interaction.isVisible && !items.isEmpty + } + + var visiblePreview: String { + selectedItem?.presentation(hideDetails: settings.hideDetails).preview ?? "ADE is ready" + } + + var navigationActions: [AttentionAction] { + selectedItem?.actions.filter(\.opensDestination) ?? [] + } + + func handle(_ input: NotchInput) { + switch input { + case .snapshot(let snapshot): + apply(snapshot) + case .settings(let settings): + self.settings = settings + setVisible(settings.enabled && hostVisibilityRequested) + requestReanchor() + case .visibility(let visible): + hostVisibilityRequested = visible + setVisible(settings.enabled && visible) + case .reanchor: + requestReanchor() + case .quit: + requestQuit() + } + } + + func apply(_ snapshot: AttentionSnapshot) { + let focusedItemId = pointerInside ? hoveredItemId : selectedItem?.id + var deduplicated: [String: AttentionItem] = [:] + for item in snapshot.items where item.contractVersion == 1 { + if let current = deduplicated[item.id], current.revision > item.revision { + continue + } + deduplicated[item.id] = item + } + let sorted = sortedAttentionItems(Array(deduplicated.values)) + let changed = sorted.filter { fingerprintsById[$0.id] != $0.fingerprint } + let initialSnapshot = fingerprintsById.isEmpty + fingerprintsById = Dictionary(uniqueKeysWithValues: sorted.map { ($0.id, $0.fingerprint) }) + items = sorted + + var next = interaction + if let focusedItemId, + let focusedIndex = sorted.firstIndex(where: { $0.id == focusedItemId }) { + next.select(index: focusedIndex, itemCount: sorted.count) + } else { + next.clampSelection(itemCount: sorted.count) + } + interaction = next + + guard !sorted.isEmpty else { + transientTask?.cancel() + deferredTransient = nil + hoveredItemId = nil + var empty = interaction + empty.dismissExplicitInteraction() + interaction = empty + return + } + + if settings.celebrationsEnabled, + let merged = changed.first(where: \.isCelebration), + (!initialSnapshot || isRecent(merged.occurredAt, within: 120)) { + if pointerInside { + deferredTransient = .celebration(itemId: merged.id) + return + } + selectItem(id: merged.id) + beginCelebration() + return + } + + if let attention = changed.first(where: \.isAttention) { + if pointerInside { + deferredTransient = .attention(itemId: attention.id) + return + } + selectItem(id: attention.id) + beginAttention() + } + } + + func pointerChanged(isInside: Bool) { + peekTask?.cancel() + closeTask?.cancel() + + if isInside { + guard !pointerInside else { return } + pointerInside = true + hoveredItemId = selectedItem?.id + var next = interaction + let token = next.pointerEntered(hasItems: !items.isEmpty) + interaction = next + peekTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(145)) + guard !Task.isCancelled, let self else { return } + var delayed = self.interaction + delayed.applyPeek(generation: token, pointerInside: self.pointerInside) + self.interaction = delayed + } + } else { + guard pointerInside else { return } + closeTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(100)) + guard !Task.isCancelled, let self else { return } + self.pointerInside = false + self.hoveredItemId = nil + var next = self.interaction + next.pointerExited() + self.interaction = next + self.presentDeferredTransientIfNeeded() + } + } + } + + func toggleExpanded() { + peekTask?.cancel() + transientTask?.cancel() + var next = interaction + next.explicitToggle(hasItems: !items.isEmpty) + interaction = next + } + + func dismissExpanded() { + guard interaction.isExplicitlyInteractive else { return } + var next = interaction + next.dismissExplicitInteraction() + interaction = next + } + + func navigate(delta: Int) { + var next = interaction + next.navigate(delta: delta, itemCount: items.count) + interaction = next + if pointerInside { + hoveredItemId = selectedItem?.id + } + } + + func openSelected() { + guard let item = selectedItem else { return } + emit(NotchOutput( + type: "open", + itemId: item.id, + destination: item.destination, + deepLink: item.destination.deepLink + )) + } + + func openFor(_ action: AttentionAction) { + guard let item = selectedItem else { return } + emit(NotchOutput( + type: "action", + itemId: item.id, + action: action, + destination: item.destination, + deepLink: item.destination.deepLink + )) + } + + private func setVisible(_ visible: Bool) { + peekTask?.cancel() + closeTask?.cancel() + transientTask?.cancel() + pointerInside = false + hoveredItemId = nil + deferredTransient = nil + var next = interaction + next.setVisible(visible) + interaction = next + } + + private func beginAttention() { + transientTask?.cancel() + var next = interaction + next.setAttention() + interaction = next + if settings.soundsEnabled { + NSSound(named: "Glass")?.play() + } + transientTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(5)) + guard !Task.isCancelled, let self else { return } + var finished = self.interaction + finished.finishTransient(pointerInside: self.pointerInside) + self.interaction = finished + } + } + + private func beginCelebration() { + transientTask?.cancel() + var next = interaction + next.setCelebration() + interaction = next + if settings.soundsEnabled { + NSSound(named: "Hero")?.play() + } + transientTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(1_650)) + guard !Task.isCancelled, let self else { return } + var finished = self.interaction + finished.finishTransient(pointerInside: self.pointerInside) + self.interaction = finished + } + } + + private func selectItem(id: String) { + guard let index = items.firstIndex(where: { $0.id == id }) else { return } + var next = interaction + next.select(index: index, itemCount: items.count) + interaction = next + } + + private func presentDeferredTransientIfNeeded() { + guard let deferredTransient else { return } + self.deferredTransient = nil + switch deferredTransient { + case .attention(let itemId): + guard items.contains(where: { $0.id == itemId }) else { return } + selectItem(id: itemId) + beginAttention() + case .celebration(let itemId): + guard items.contains(where: { $0.id == itemId }) else { return } + selectItem(id: itemId) + beginCelebration() + } + } + + private func isRecent(_ value: String, within seconds: TimeInterval) -> Bool { + guard let date = parseAttentionDate(value) else { return false } + return abs(date.timeIntervalSinceNow) <= seconds + } +} diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/ProtocolTransport.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/ProtocolTransport.swift new file mode 100644 index 000000000..42b2750e1 --- /dev/null +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/ProtocolTransport.swift @@ -0,0 +1,46 @@ +import AppKit +import Foundation +import ADEAttentionNotchCore + +final class StandardIOTransport { + private let decoder = JSONDecoder() + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return encoder + }() + private let outputLock = NSLock() + + @MainActor + func start(deliver: @escaping @MainActor (NotchInput) -> Void) { + Task.detached(priority: .utility) { [decoder] in + while let line = readLine(strippingNewline: true) { + guard !line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } + do { + let input = try NotchInputDecoder.decode(line: line, decoder: decoder) + await MainActor.run { deliver(input) } + } catch { + await MainActor.run { + self.send(NotchOutput(type: "protocol_error", message: String(describing: error))) + } + } + } + await MainActor.run { + NSApplication.shared.terminate(nil) + } + } + } + + func send(_ output: NotchOutput) { + do { + var data = try encoder.encode(output) + data.append(0x0A) + outputLock.lock() + defer { outputLock.unlock() } + FileHandle.standardOutput.write(data) + } catch { + let message = "ADE Attention Notch could not encode output: \(error)\n" + FileHandle.standardError.write(Data(message.utf8)) + } + } +} diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/Resources/ProviderIcons/claude.svg b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/Resources/ProviderIcons/claude.svg new file mode 100644 index 000000000..f93267ec5 --- /dev/null +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/Resources/ProviderIcons/claude.svg @@ -0,0 +1 @@ +Claude diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/Resources/ProviderIcons/cursor.svg b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/Resources/ProviderIcons/cursor.svg new file mode 100644 index 000000000..79b44c5e8 --- /dev/null +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/Resources/ProviderIcons/cursor.svg @@ -0,0 +1 @@ +Cursor diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/Resources/ProviderIcons/github.svg b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/Resources/ProviderIcons/github.svg new file mode 100644 index 000000000..b6dfa3d5a --- /dev/null +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/Resources/ProviderIcons/github.svg @@ -0,0 +1 @@ +Github diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/Resources/ProviderIcons/openai.svg b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/Resources/ProviderIcons/openai.svg new file mode 100644 index 000000000..8c3e68d52 --- /dev/null +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/Resources/ProviderIcons/openai.svg @@ -0,0 +1 @@ +OpenAI diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/Resources/ProviderIcons/opencode.svg b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/Resources/ProviderIcons/opencode.svg new file mode 100644 index 000000000..5c1bb8535 --- /dev/null +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/Resources/ProviderIcons/opencode.svg @@ -0,0 +1 @@ +opencode diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/AttentionModels.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/AttentionModels.swift new file mode 100644 index 000000000..b2671a629 --- /dev/null +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/AttentionModels.swift @@ -0,0 +1,477 @@ +import Foundation + +public struct AttentionMachine: Codable, Equatable, Sendable { + public let machineKey: String + public let name: String + public let online: Bool + public let lastSeenAt: String? + + public init(machineKey: String, name: String, online: Bool, lastSeenAt: String?) { + self.machineKey = machineKey + self.name = name + self.online = online + self.lastSeenAt = lastSeenAt + } +} + +public struct AttentionProject: Codable, Equatable, Sendable { + public let projectId: String + public let name: String + public let rootPath: String? + + public init(projectId: String, name: String, rootPath: String? = nil) { + self.projectId = projectId + self.name = name + self.rootPath = rootPath + } +} + +public struct AttentionPlanProgress: Codable, Equatable, Sendable { + public let completed: Int + public let total: Int + public let current: String? + + public init(completed: Int, total: Int, current: String? = nil) { + self.completed = completed + self.total = total + self.current = current + } +} + +/// Mirrors ADE's tagged AttentionDestination contract while remaining forward-compatible. +public struct AttentionDestination: Codable, Equatable, Sendable { + public let kind: String + public let sessionId: String? + public let itemId: String? + public let eventId: String? + public let prId: String? + public let repoOwner: String? + public let repoName: String? + public let number: Int? + public let tab: String? + + public init( + kind: String, + sessionId: String? = nil, + itemId: String? = nil, + eventId: String? = nil, + prId: String? = nil, + repoOwner: String? = nil, + repoName: String? = nil, + number: Int? = nil, + tab: String? = nil + ) { + self.kind = kind + self.sessionId = sessionId + self.itemId = itemId + self.eventId = eventId + self.prId = prId + self.repoOwner = repoOwner + self.repoName = repoName + self.number = number + self.tab = tab + } + + public var deepLink: String? { + var components = URLComponents() + components.scheme = "ade" + + if kind == "session", let sessionId { + components.host = "session" + components.path = "/\(sessionId)" + components.queryItems = [ + itemId.map { URLQueryItem(name: "item", value: $0) }, + eventId.map { URLQueryItem(name: "event", value: $0) }, + ].compactMap { $0 } + return components.url?.absoluteString + } + + guard kind == "pull_request", let number else { return nil } + components.host = "pr" + if let repoOwner, let repoName { + components.path = "/\(repoOwner)/\(repoName)/\(number)" + } else { + components.path = "/\(number)" + } + components.queryItems = [ + (tab != nil && tab != "overview") ? URLQueryItem(name: "tab", value: tab) : nil, + eventId.map { URLQueryItem(name: "event", value: $0) }, + ].compactMap { $0 } + return components.url?.absoluteString + } +} + +public struct AttentionAction: Codable, Equatable, Sendable, Identifiable { + public let id: String + public let kind: String + public let label: String + public let destructive: Bool? + public let payload: [String: AttentionJSONValue]? + + public init( + id: String, + kind: String, + label: String, + destructive: Bool? = nil, + payload: [String: AttentionJSONValue]? = nil + ) { + self.id = id + self.kind = kind + self.label = label + self.destructive = destructive + self.payload = payload + } + + public var opensDestination: Bool { + switch kind { + case "approve", "deny", "answer", "restart", "rerun_checks", "open": + return true + default: + return false + } + } + + public var navigationLabel: String { + switch kind { + case "approve": return "Open to approve" + case "deny": return "Open to deny" + case "answer": return "Open to answer" + case "restart": return "Open to restart" + case "rerun_checks": return "Open to rerun checks" + case "open": return "Open in ADE" + default: return "Open in ADE" + } + } + + public var navigationAccessibilityHint: String { + kind == "open" + ? "Opens the exact item in ADE" + : "\(navigationLabel) in ADE" + } +} + +public enum AttentionJSONValue: Codable, Equatable, Sendable { + case string(String) + case number(Double) + case bool(Bool) + case null + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else { + self = .string(try container.decode(String.self)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): try container.encode(value) + case .number(let value): try container.encode(value) + case .bool(let value): try container.encode(value) + case .null: try container.encodeNil() + } + } +} + +public struct AttentionItem: Codable, Equatable, Sendable, Identifiable { + public let contractVersion: Int + public let id: String + public let revision: Int + public let fingerprint: String + public let kind: String + public let eventKind: String + public let phase: String + public let machine: AttentionMachine + public let project: AttentionProject + public let laneId: String? + public let laneName: String? + public let provider: String? + public let model: String? + public let title: String + public let preview: String + public let privacyPreview: String + public let detail: String? + public let recentActivity: [String]? + public let planProgress: AttentionPlanProgress? + public let destination: AttentionDestination + public let actions: [AttentionAction] + public let occurredAt: String + public let updatedAt: String + public let seenAt: String? + public let dismissedAt: String? + public let expiresAt: String? + + public init( + contractVersion: Int = 1, + id: String, + revision: Int = 1, + fingerprint: String, + kind: String, + eventKind: String, + phase: String, + machine: AttentionMachine, + project: AttentionProject, + laneId: String? = nil, + laneName: String? = nil, + provider: String? = nil, + model: String? = nil, + title: String, + preview: String, + privacyPreview: String, + detail: String? = nil, + recentActivity: [String]? = nil, + planProgress: AttentionPlanProgress? = nil, + destination: AttentionDestination, + actions: [AttentionAction] = [], + occurredAt: String, + updatedAt: String, + seenAt: String? = nil, + dismissedAt: String? = nil, + expiresAt: String? = nil + ) { + self.contractVersion = contractVersion + self.id = id + self.revision = revision + self.fingerprint = fingerprint + self.kind = kind + self.eventKind = eventKind + self.phase = phase + self.machine = machine + self.project = project + self.laneId = laneId + self.laneName = laneName + self.provider = provider + self.model = model + self.title = title + self.preview = preview + self.privacyPreview = privacyPreview + self.detail = detail + self.recentActivity = recentActivity + self.planProgress = planProgress + self.destination = destination + self.actions = actions + self.occurredAt = occurredAt + self.updatedAt = updatedAt + self.seenAt = seenAt + self.dismissedAt = dismissedAt + self.expiresAt = expiresAt + } + + public var isAttention: Bool { + switch phase { + case "needs_you", "failed", "checks_failing", "changes_requested", + "review_requested", "merge_ready": + return true + default: + return false + } + } + + public var isCelebration: Bool { + eventKind == "pr_merged" && phase == "merged" && seenAt == nil + } + + public var statusLabel: String { + switch phase { + case "starting": return "Starting" + case "running": return "Working" + case "needs_you": return "Needs you" + case "blocked": return "Blocked" + case "failed": return "Failed" + case "checks_failing": return "Checks failing" + case "changes_requested": return "Changes requested" + case "review_requested": return "Review requested" + case "merge_ready": return "Ready to merge" + case "completed": return "Completed" + case "merged": return "Merged" + default: + return phase.replacingOccurrences(of: "_", with: " ").capitalized + } + } + + public func presentation(hideDetails: Bool) -> AttentionItemPresentation { + if hideDetails { + let genericTitle = kind == "pull_request" ? "Pull request update" : "Agent update" + return AttentionItemPresentation( + title: genericTitle, + preview: privacyPreview, + compactIdentity: "ADE", + scopeLabel: "Private details hidden", + recentActivity: [], + planProgress: nil, + celebrationTitle: genericTitle, + accessibilitySummary: "\(statusLabel). \(privacyPreview)" + ) + } + + let trimmedLane = laneName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let trimmedProject = project.name.trimmingCharacters(in: .whitespacesAndNewlines) + let compactIdentity = !trimmedLane.isEmpty + ? trimmedLane + : (!trimmedProject.isEmpty ? trimmedProject : title) + return AttentionItemPresentation( + title: title, + preview: preview, + compactIdentity: compactIdentity, + scopeLabel: "\(project.name) · \(machine.name)", + recentActivity: recentActivity ?? [], + planProgress: planProgress, + celebrationTitle: title, + accessibilitySummary: "\(statusLabel), \(title), \(project.name) on \(machine.name)" + ) + } +} + +public struct AttentionItemPresentation: Equatable, Sendable { + public let title: String + public let preview: String + public let compactIdentity: String + public let scopeLabel: String + public let recentActivity: [String] + public let planProgress: AttentionPlanProgress? + public let celebrationTitle: String + public let accessibilitySummary: String +} + +public enum NotchStatusTone: String, Equatable, Sendable { + case blue + case amber + case red + case violet + case green + case neutral +} + +public func notchStatusTone(for phase: String?) -> NotchStatusTone { + switch phase { + case "starting", "running", "open": + return .blue + case "needs_you", "blocked": + return .amber + case "failed", "checks_failing", "changes_requested": + return .red + case "review_requested": + return .violet + case "completed", "merged", "merge_ready": + return .green + default: + return .neutral + } +} + +public struct AttentionSnapshot: Codable, Equatable, Sendable { + public let contractVersion: Int + public let revision: Int + public let generatedAt: String + public let items: [AttentionItem] + + public init(contractVersion: Int = 1, revision: Int, generatedAt: String, items: [AttentionItem]) { + self.contractVersion = contractVersion + self.revision = revision + self.generatedAt = generatedAt + self.items = items + } +} + +public struct NotchSettings: Codable, Equatable, Sendable { + public var enabled: Bool + public var preferredDisplayId: UInt32? + public var hideDetails: Bool + public var celebrationsEnabled: Bool + public var soundsEnabled: Bool + + public init( + enabled: Bool = false, + preferredDisplayId: UInt32? = nil, + hideDetails: Bool = true, + celebrationsEnabled: Bool = true, + soundsEnabled: Bool = false + ) { + self.enabled = enabled + self.preferredDisplayId = preferredDisplayId + self.hideDetails = hideDetails + self.celebrationsEnabled = celebrationsEnabled + self.soundsEnabled = soundsEnabled + } +} + +public enum NotchInput: Equatable, Sendable { + case snapshot(AttentionSnapshot) + case settings(NotchSettings) + case visibility(Bool) + case reanchor + case quit +} + +private struct CommandEnvelope: Decodable { + let type: String + let snapshot: AttentionSnapshot? + let settings: NotchSettings? + let visible: Bool? +} + +public enum NotchInputDecoder { + public static func decode(line: String, decoder: JSONDecoder = JSONDecoder()) throws -> NotchInput { + let data = Data(line.utf8) + if let envelope = try? decoder.decode(CommandEnvelope.self, from: data) { + switch envelope.type { + case "snapshot": + guard let snapshot = envelope.snapshot else { throw NotchProtocolError.missingPayload("snapshot") } + return .snapshot(snapshot) + case "settings": + guard let settings = envelope.settings else { throw NotchProtocolError.missingPayload("settings") } + return .settings(settings) + case "visibility": + guard let visible = envelope.visible else { throw NotchProtocolError.missingPayload("visible") } + return .visibility(visible) + case "reanchor": return .reanchor + case "quit": return .quit + default: throw NotchProtocolError.unknownCommand(envelope.type) + } + } + return .snapshot(try decoder.decode(AttentionSnapshot.self, from: data)) + } +} + +public enum NotchProtocolError: Error, Equatable { + case missingPayload(String) + case unknownCommand(String) +} + +public struct NotchOutput: Encodable, Equatable, Sendable { + public let type: String + public let itemId: String? + public let action: AttentionAction? + public let destination: AttentionDestination? + public let deepLink: String? + public let message: String? + public let displayId: UInt32? + public let surface: String? + + public init( + type: String, + itemId: String? = nil, + action: AttentionAction? = nil, + destination: AttentionDestination? = nil, + deepLink: String? = nil, + message: String? = nil, + displayId: UInt32? = nil, + surface: String? = nil + ) { + self.type = type + self.itemId = itemId + self.action = action + self.destination = destination + self.deepLink = deepLink + self.message = message + self.displayId = displayId + self.surface = surface + } +} diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchGeometry.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchGeometry.swift new file mode 100644 index 000000000..49c6d8aaa --- /dev/null +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchGeometry.swift @@ -0,0 +1,72 @@ +import Foundation + +public struct NotchRect: Equatable, Sendable { + public let x: Double + public let y: Double + public let width: Double + public let height: Double + + public init(x: Double, y: Double, width: Double, height: Double) { + self.x = x + self.y = y + self.width = width + self.height = height + } + + public var maxX: Double { x + width } + public var maxY: Double { y + height } +} + +public struct NotchDisplayGeometry: Equatable, Sendable { + public static let panelSize = NotchSize(width: 720, height: 460) + + public let displayId: UInt32 + public let frame: NotchRect + public let visibleFrame: NotchRect + public let safeAreaTop: Double + public let auxiliaryLeft: NotchRect? + public let auxiliaryRight: NotchRect? + public let isBuiltIn: Bool + + public init( + displayId: UInt32, + frame: NotchRect, + visibleFrame: NotchRect, + safeAreaTop: Double, + auxiliaryLeft: NotchRect?, + auxiliaryRight: NotchRect?, + isBuiltIn: Bool + ) { + self.displayId = displayId + self.frame = frame + self.visibleFrame = visibleFrame + self.safeAreaTop = safeAreaTop + self.auxiliaryLeft = auxiliaryLeft + self.auxiliaryRight = auxiliaryRight + self.isBuiltIn = isBuiltIn + } + + public var hasPhysicalNotch: Bool { + guard isBuiltIn, safeAreaTop >= 22 else { return false } + guard let auxiliaryLeft, let auxiliaryRight else { return false } + return auxiliaryRight.x - auxiliaryLeft.maxX >= 80 + } + + public var physicalNotchWidth: Double? { + guard hasPhysicalNotch, let auxiliaryLeft, let auxiliaryRight else { return nil } + return auxiliaryRight.x - auxiliaryLeft.maxX + } + + public var panelFrame: NotchRect { + let x = frame.x + (frame.width - Self.panelSize.width) / 2 + let anchorY = hasPhysicalNotch + ? frame.maxY + : min(frame.maxY - 4, visibleFrame.maxY - 6) + return NotchRect( + x: x, + y: anchorY - Self.panelSize.height, + width: Self.panelSize.width, + height: Self.panelSize.height + ) + } +} diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchInteractionState.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchInteractionState.swift new file mode 100644 index 000000000..01aadd39e --- /dev/null +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchInteractionState.swift @@ -0,0 +1,213 @@ +import Foundation + +public enum NotchPresentationState: String, Codable, Equatable, Sendable { + case compact + case prehover + case peek + case expanded + case attention + case celebration +} + +public struct NotchSize: Equatable, Sendable { + public let width: Double + public let height: Double + + public init(width: Double, height: Double) { + self.width = width + self.height = height + } +} + +public struct NotchInteractionState: Equatable, Sendable { + public private(set) var presentation: NotchPresentationState = .compact + public private(set) var generation: UInt64 = 0 + public private(set) var selectedIndex: Int = 0 + public private(set) var isVisible = true + public private(set) var isExplicitlyInteractive = false + + public init() {} + + @discardableResult + public mutating func pointerEntered(hasItems: Bool) -> UInt64 { + generation &+= 1 + guard isVisible, hasItems, presentation != .celebration else { return generation } + if presentation == .compact { + presentation = .prehover + } + return generation + } + + public mutating func applyPeek(generation token: UInt64, pointerInside: Bool) { + guard token == generation, pointerInside, isVisible, presentation == .prehover else { return } + presentation = .peek + } + + @discardableResult + public mutating func pointerExited() -> UInt64 { + generation &+= 1 + if isExplicitlyInteractive { + return generation + } + if presentation != .attention && presentation != .celebration { + presentation = .compact + } + return generation + } + + public mutating func explicitToggle(hasItems: Bool) { + generation &+= 1 + guard isVisible, hasItems else { return } + if presentation == .expanded { + presentation = .compact + isExplicitlyInteractive = false + } else { + presentation = .expanded + isExplicitlyInteractive = true + } + } + + public mutating func setAttention() { + generation &+= 1 + guard isVisible else { return } + presentation = .attention + } + + public mutating func setCelebration() { + generation &+= 1 + guard isVisible else { return } + presentation = .celebration + } + + public mutating func finishTransient(pointerInside: Bool) { + generation &+= 1 + presentation = pointerInside ? .peek : .compact + } + + public mutating func navigate(delta: Int, itemCount: Int) { + guard itemCount > 0 else { + selectedIndex = 0 + return + } + selectedIndex = (selectedIndex + delta % itemCount + itemCount) % itemCount + } + + public mutating func select(index: Int, itemCount: Int) { + guard itemCount > 0 else { + selectedIndex = 0 + return + } + selectedIndex = min(max(0, index), itemCount - 1) + } + + public mutating func clampSelection(itemCount: Int) { + selectedIndex = itemCount == 0 ? 0 : min(selectedIndex, itemCount - 1) + } + + public mutating func setVisible(_ visible: Bool) { + generation &+= 1 + isVisible = visible + if !visible { + presentation = .compact + isExplicitlyInteractive = false + } + } + + public mutating func dismissExplicitInteraction() { + generation &+= 1 + isExplicitlyInteractive = false + presentation = .compact + } + +} + +private let phasePriorities: [String: Int] = [ + "needs_you": 0, + "failed": 1, + "checks_failing": 1, + "changes_requested": 1, + "review_requested": 2, + "merge_ready": 2, + "blocked": 2, + "starting": 3, + "running": 3, + "open": 4, + "stale": 4, + "completed": 5, + "merged": 5, + "closed": 6, +] + +public func sortedAttentionItems(_ items: [AttentionItem]) -> [AttentionItem] { + items + .filter { $0.dismissedAt == nil } + .sorted { left, right in + let leftPriority = phasePriorities[left.phase] ?? 99 + let rightPriority = phasePriorities[right.phase] ?? 99 + if leftPriority != rightPriority { return leftPriority < rightPriority } + if left.updatedAt != right.updatedAt { return left.updatedAt > right.updatedAt } + return left.id < right.id + } +} + +public func notchSurfaceSize( + presentation: NotchPresentationState, + physicalNotchWidth: Double?, + safeAreaTop: Double = 0 +) -> NotchSize { + guard let physicalNotchWidth else { + switch presentation { + case .compact: return NotchSize(width: 260, height: 38) + case .prehover: return NotchSize(width: 276, height: 43) + case .peek: return NotchSize(width: 320, height: 82) + case .expanded: return NotchSize(width: 414, height: 284) + case .attention: return NotchSize(width: 352, height: 136) + case .celebration: return NotchSize(width: 372, height: 180) + } + } + + let base = max(150, min(230, physicalNotchWidth)) + let reservedTop = max(0, min(64, safeAreaTop)) + switch presentation { + case .compact: + return NotchSize(width: max(388, base + 208), height: max(38, reservedTop)) + case .prehover: + return NotchSize(width: max(400, base + 220), height: max(44, reservedTop + 8)) + case .peek: + return NotchSize(width: max(404, base + 224), height: 82 + reservedTop) + case .expanded: + return NotchSize(width: 414, height: 284 + reservedTop) + case .attention: + return NotchSize(width: max(390, base + 210), height: 136 + reservedTop) + case .celebration: + return NotchSize(width: max(392, base + 212), height: 180 + reservedTop) + } +} + +public func attentionElapsedLabel(since value: String, now: Date = Date()) -> String { + guard let date = parseAttentionDate(value) else { return "now" } + let seconds = max(0, Int(now.timeIntervalSince(date))) + if seconds < 5 { return "now" } + if seconds < 60 { return "\(seconds)s" } + if seconds < 3_600 { return "\(seconds / 60)m" } + return "\(seconds / 3_600)h \(seconds % 3_600 / 60)m" +} + +public func parseAttentionDate(_ value: String) -> Date? { + if let date = attentionISO8601WithFractionalSeconds.date(from: value) { + return date + } + return attentionISO8601.date(from: value) +} + +private let attentionISO8601WithFractionalSeconds: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter +}() + +private let attentionISO8601: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter +}() diff --git a/apps/desktop/native/ADEAttentionNotch/THIRD_PARTY_NOTICES.md b/apps/desktop/native/ADEAttentionNotch/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..b29abd82a --- /dev/null +++ b/apps/desktop/native/ADEAttentionNotch/THIRD_PARTY_NOTICES.md @@ -0,0 +1,29 @@ +# Third-party notices + +## Lobe Icons + +The provider SVG marks in +`Sources/ADEAttentionNotch/Resources/ProviderIcons` are adapted from +[Lobe Icons](https://github.com/lobehub/lobe-icons), version 1.84.0. + +MIT License + +Copyright (c) 2023 LobeHub + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchGeometryTests.swift b/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchGeometryTests.swift new file mode 100644 index 000000000..0b4dd3fa3 --- /dev/null +++ b/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchGeometryTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import ADEAttentionNotchCore + +final class NotchGeometryTests: XCTestCase { + func testPhysicalNotchRequiresBuiltInSafeAreaAndAuxiliaryRegions() { + let physical = geometry( + safeAreaTop: 34, + left: NotchRect(x: 0, y: 866, width: 650, height: 34), + right: NotchRect(x: 830, y: 866, width: 650, height: 34), + isBuiltIn: true + ) + XCTAssertTrue(physical.hasPhysicalNotch) + XCTAssertEqual(physical.physicalNotchWidth, 180) + XCTAssertEqual(physical.panelFrame.maxY, physical.frame.maxY) + + let external = geometry( + safeAreaTop: 34, + left: NotchRect(x: 0, y: 866, width: 650, height: 34), + right: NotchRect(x: 830, y: 866, width: 650, height: 34), + isBuiltIn: false + ) + XCTAssertFalse(external.hasPhysicalNotch) + } + + func testFallbackPanelAnchorsBelowMenuBar() { + let display = geometry(safeAreaTop: 0, left: nil, right: nil, isBuiltIn: false) + XCTAssertLessThan(display.panelFrame.maxY, display.frame.maxY) + XCTAssertLessThan(display.panelFrame.maxY, display.visibleFrame.maxY) + } + + private func geometry( + safeAreaTop: Double, + left: NotchRect?, + right: NotchRect?, + isBuiltIn: Bool + ) -> NotchDisplayGeometry { + NotchDisplayGeometry( + displayId: 1, + frame: NotchRect(x: 0, y: 0, width: 1480, height: 900), + visibleFrame: NotchRect(x: 0, y: 0, width: 1480, height: 875), + safeAreaTop: safeAreaTop, + auxiliaryLeft: left, + auxiliaryRight: right, + isBuiltIn: isBuiltIn + ) + } +} diff --git a/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchInteractionStateTests.swift b/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchInteractionStateTests.swift new file mode 100644 index 000000000..5b83a87d9 --- /dev/null +++ b/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchInteractionStateTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import ADEAttentionNotchCore + +final class NotchInteractionStateTests: XCTestCase { + func testStaleHoverGenerationCannotOpenPeekAfterPointerExit() { + var state = NotchInteractionState() + let hoverGeneration = state.pointerEntered(hasItems: true) + XCTAssertEqual(state.presentation, .prehover) + + state.pointerExited() + state.applyPeek(generation: hoverGeneration, pointerInside: true) + + XCTAssertEqual(state.presentation, .compact) + } + + func testExplicitExpansionOnlyHappensWithItems() { + var state = NotchInteractionState() + state.explicitToggle(hasItems: false) + XCTAssertEqual(state.presentation, .compact) + XCTAssertFalse(state.isExplicitlyInteractive) + + state.explicitToggle(hasItems: true) + XCTAssertEqual(state.presentation, .expanded) + XCTAssertTrue(state.isExplicitlyInteractive) + } + + func testHidingStopsTransientPresentation() { + var state = NotchInteractionState() + state.setCelebration() + XCTAssertEqual(state.presentation, .celebration) + + state.setVisible(false) + + XCTAssertEqual(state.presentation, .compact) + XCTAssertFalse(state.isVisible) + } + + func testNavigationWrapsInBothDirections() { + var state = NotchInteractionState() + state.navigate(delta: -1, itemCount: 3) + XCTAssertEqual(state.selectedIndex, 2) + state.navigate(delta: 1, itemCount: 3) + XCTAssertEqual(state.selectedIndex, 0) + state.select(index: 9, itemCount: 3) + XCTAssertEqual(state.selectedIndex, 2) + } + + func testPhysicalSurfaceReservesHardwareAndSideEars() { + let compact = notchSurfaceSize( + presentation: .compact, + physicalNotchWidth: 182, + safeAreaTop: 34 + ) + let peek = notchSurfaceSize( + presentation: .peek, + physicalNotchWidth: 182, + safeAreaTop: 34 + ) + XCTAssertEqual(compact.width, 390) + XCTAssertEqual(compact.height, 38) + XCTAssertGreaterThan(peek.width, compact.width) + XCTAssertEqual(peek.height, 116) + XCTAssertEqual(notchSurfaceSize(presentation: .compact, physicalNotchWidth: nil).width, 260) + } +} diff --git a/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchProtocolTests.swift b/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchProtocolTests.swift new file mode 100644 index 000000000..912604b00 --- /dev/null +++ b/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchProtocolTests.swift @@ -0,0 +1,165 @@ +import XCTest +@testable import ADEAttentionNotchCore + +final class NotchProtocolTests: XCTestCase { + func testDecodesRawSnapshotAndEnvelope() throws { + let item = fixtureItem() + let snapshot = AttentionSnapshot( + revision: 2, + generatedAt: "2026-07-28T12:00:00Z", + items: [item] + ) + let encoder = JSONEncoder() + let raw = String(decoding: try encoder.encode(snapshot), as: UTF8.self) + XCTAssertEqual(try NotchInputDecoder.decode(line: raw), .snapshot(snapshot)) + + let envelope = """ + {"type":"snapshot","snapshot":\(raw)} + """ + XCTAssertEqual(try NotchInputDecoder.decode(line: envelope), .snapshot(snapshot)) + } + + func testDestinationBuildsExactDeepLink() { + let destination = AttentionDestination( + kind: "pull_request", + repoOwner: "ade", + repoName: "desktop", + number: 42, + tab: "checks" + ) + XCTAssertEqual(destination.deepLink, "ade://pr/ade/desktop/42?tab=checks") + } + + func testAttentionSortingIsPriorityThenRecency() { + let running = fixtureItem(id: "running", phase: "running", updatedAt: "2026-07-28T12:10:00Z") + let failed = fixtureItem(id: "failed", phase: "failed", updatedAt: "2026-07-28T12:00:00Z") + let needsYou = fixtureItem(id: "needs", phase: "needs_you", updatedAt: "2026-07-28T11:00:00Z") + XCTAssertEqual(sortedAttentionItems([running, failed, needsYou]).map(\.id), ["needs", "failed", "running"]) + } + + func testDateParserAcceptsFractionalAndWholeSeconds() { + XCTAssertNotNil(parseAttentionDate("2026-07-28T12:00:00.123Z")) + XCTAssertNotNil(parseAttentionDate("2026-07-28T12:00:00Z")) + } + + func testFreshElapsedTimeReadsNow() throws { + let now = try XCTUnwrap(parseAttentionDate("2026-07-28T12:00:03Z")) + XCTAssertEqual( + attentionElapsedLabel(since: "2026-07-28T12:00:00.000Z", now: now), + "now" + ) + } + + func testNativeSettingsFailClosedWithPrivateSilentDefaults() { + let settings = NotchSettings() + XCTAssertFalse(settings.enabled) + XCTAssertTrue(settings.hideDetails) + XCTAssertFalse(settings.soundsEnabled) + } + + func testPrivacyPresentationRedactsEverySensitiveSurfaceAndAccessibilitySummary() { + let item = AttentionItem( + id: "private-agent", + fingerprint: "private-fingerprint", + kind: "agent", + eventKind: "agent_running", + phase: "running", + machine: AttentionMachine( + machineKey: "secret-machine-key", + name: "Arul’s Mac Studio", + online: true, + lastSeenAt: nil + ), + project: AttentionProject( + projectId: "secret-project-id", + name: "Stealth Project", + rootPath: "/Users/arul/Stealth" + ), + laneId: "secret-lane", + laneName: "launch-secret-feature", + provider: "codex", + model: "secret-model", + title: "Implement unreleased billing", + preview: "Editing SecretBilling.swift", + privacyPreview: "Agent is working", + detail: "Customer Alpha requires a private migration", + recentActivity: ["Read SecretBilling.swift", "Changed CustomerAlpha.swift"], + planProgress: AttentionPlanProgress( + completed: 2, + total: 4, + current: "Migrate Customer Alpha" + ), + destination: AttentionDestination(kind: "session", sessionId: "session-private"), + occurredAt: "2026-07-28T12:00:00Z", + updatedAt: "2026-07-28T12:00:00Z" + ) + + let presentation = item.presentation(hideDetails: true) + + XCTAssertEqual(presentation.title, "Agent update") + XCTAssertEqual(presentation.preview, "Agent is working") + XCTAssertEqual(presentation.compactIdentity, "ADE") + XCTAssertEqual(presentation.scopeLabel, "Private details hidden") + XCTAssertTrue(presentation.recentActivity.isEmpty) + XCTAssertNil(presentation.planProgress) + XCTAssertEqual(presentation.celebrationTitle, "Agent update") + for secret in [ + "Implement unreleased billing", + "Arul’s Mac Studio", + "Stealth Project", + "launch-secret-feature", + "SecretBilling.swift", + "Customer Alpha", + ] { + XCTAssertFalse(presentation.accessibilitySummary.contains(secret)) + } + } + + func testInlineActionLabelsDescribeNavigationInsteadOfExecution() { + let approve = AttentionAction(id: "approve", kind: "approve", label: "Approve") + let deny = AttentionAction(id: "deny", kind: "deny", label: "Deny") + let rerun = AttentionAction(id: "rerun", kind: "rerun_checks", label: "Rerun") + let dismiss = AttentionAction(id: "dismiss", kind: "dismiss", label: "Dismiss") + + XCTAssertEqual(approve.navigationLabel, "Open to approve") + XCTAssertEqual(deny.navigationLabel, "Open to deny") + XCTAssertEqual(rerun.navigationLabel, "Open to rerun checks") + XCTAssertTrue(approve.navigationAccessibilityHint.contains("Open to approve")) + XCTAssertTrue(approve.opensDestination) + XCTAssertFalse(dismiss.opensDestination) + } + + func testPhaseVocabularyAndToneMatchAttentionSurfaces() { + let running = fixtureItem(id: "running", phase: "running") + let mergeReady = fixtureItem(id: "merge-ready", phase: "merge_ready") + + XCTAssertEqual(running.statusLabel, "Working") + XCTAssertEqual(notchStatusTone(for: "starting"), .blue) + XCTAssertEqual(notchStatusTone(for: "running"), .blue) + XCTAssertEqual(notchStatusTone(for: "changes_requested"), .red) + XCTAssertTrue(mergeReady.isAttention) + XCTAssertEqual(mergeReady.statusLabel, "Ready to merge") + } + + private func fixtureItem( + id: String = "agent-1", + phase: String = "running", + updatedAt: String = "2026-07-28T12:00:00Z" + ) -> AttentionItem { + AttentionItem( + id: id, + fingerprint: "fingerprint-\(id)", + kind: "agent", + eventKind: "agent_running", + phase: phase, + machine: AttentionMachine(machineKey: "mac-1", name: "Studio", online: true, lastSeenAt: nil), + project: AttentionProject(projectId: "ade", name: "ADE"), + title: "Implement attention", + preview: "Running tests", + privacyPreview: "Agent update", + destination: AttentionDestination(kind: "session", sessionId: "session-1"), + occurredAt: updatedAt, + updatedAt: updatedAt + ) + } +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cc26fb491..40644d1ec 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -18,17 +18,19 @@ "dev:browser-bridge": "node ./scripts/browser-runtime-bridge.mjs", "export:browser-mock-ade": "node ./scripts/export-browser-mock-ade-snapshot.mjs", "build": "tsup && vite build", + "build:notch": "node ./scripts/build-attention-notch.mjs", + "test:notch": "swift test --package-path ./native/ADEAttentionNotch", "build:webclient": "vite build --config vite.webclient.config.ts --configLoader runner && node ./scripts/check-webclient-entry.mjs", "dist:win": "npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run validate:win:artifacts && npm run build && electron-builder --win --x64 --publish never && npm run validate:win:release", - "dist:mac": "npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run build && electron-builder --mac --publish never", - "dist:mac:dir": "npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run build && electron-builder --dir --mac --publish never -c.mac.identity=null -c.mac.notarize=false", - "dist:mac:signed": "node ./scripts/require-macos-release-secrets.cjs && npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run sign:mac:runtime-archives && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run build && electron-builder --mac --publish never", + "dist:mac": "npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run build:notch && npm run build && electron-builder --mac --publish never", + "dist:mac:dir": "npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run build:notch && npm run build && electron-builder --dir --mac --publish never -c.mac.identity=null -c.mac.notarize=false", + "dist:mac:signed": "node ./scripts/require-macos-release-secrets.cjs && npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run sign:mac:runtime-archives && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run build:notch && npm run build && electron-builder --mac --publish never", "prepare:mac:universal": "node ./scripts/prepare-universal-mac-inputs.mjs", - "dist:mac:universal:signed": "node ./scripts/require-macos-release-secrets.cjs && npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run sign:mac:runtime-archives && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run build && electron-builder --mac --universal --dir --publish never && node ./scripts/resign-mac-universal-app.mjs && electron-builder --mac zip --universal --prepackaged release/mac-universal/ADE.app --publish never && node ./scripts/create-mac-dmg.mjs", - "dist:mac:universal:signed:zip": "node ./scripts/require-macos-release-secrets.cjs && npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run sign:mac:runtime-archives && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run build && electron-builder --mac zip --universal --publish never", - "dist:mac:perarch:signed": "node ./scripts/require-macos-release-secrets.cjs && npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run sign:mac:runtime-archives && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run build && electron-builder --mac zip --arm64 --publish never && cp release/latest-mac.yml release/latest-mac-arm64.yml && node ./scripts/create-mac-dmg.mjs --app=release/mac-arm64/ADE.app --arch=arm64 && electron-builder --mac zip --x64 --publish never && cp release/latest-mac.yml release/latest-mac-x64.yml && node ./scripts/create-mac-dmg.mjs --app=release/mac/ADE.app --arch=x64 && node ./scripts/merge-mac-latest-yml.mjs release/latest-mac-arm64.yml release/latest-mac-x64.yml release/latest-mac.yml", - "dist:mac:arm64:signed": "node ./scripts/require-macos-release-secrets.cjs && npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run sign:mac:runtime-archives && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run build && electron-builder --mac zip --arm64 --publish never && node ./scripts/create-mac-dmg.mjs --app=release/mac-arm64/ADE.app --arch=arm64", - "dist:mac:x64:signed": "node ./scripts/require-macos-release-secrets.cjs && npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run sign:mac:runtime-archives && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run build && electron-builder --mac zip --x64 --publish never && node ./scripts/create-mac-dmg.mjs --app=release/mac/ADE.app --arch=x64", + "dist:mac:universal:signed": "node ./scripts/require-macos-release-secrets.cjs && npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run sign:mac:runtime-archives && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run build:notch && npm run build && electron-builder --mac --universal --dir --publish never && node ./scripts/resign-mac-universal-app.mjs && electron-builder --mac zip --universal --prepackaged release/mac-universal/ADE.app --publish never && node ./scripts/create-mac-dmg.mjs", + "dist:mac:universal:signed:zip": "node ./scripts/require-macos-release-secrets.cjs && npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run sign:mac:runtime-archives && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run build:notch && npm run build && electron-builder --mac zip --universal --publish never", + "dist:mac:perarch:signed": "node ./scripts/require-macos-release-secrets.cjs && npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run sign:mac:runtime-archives && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run build:notch && npm run build && electron-builder --mac zip --arm64 --publish never && cp release/latest-mac.yml release/latest-mac-arm64.yml && node ./scripts/create-mac-dmg.mjs --app=release/mac-arm64/ADE.app --arch=arm64 && electron-builder --mac zip --x64 --publish never && cp release/latest-mac.yml release/latest-mac-x64.yml && node ./scripts/create-mac-dmg.mjs --app=release/mac/ADE.app --arch=x64 && node ./scripts/merge-mac-latest-yml.mjs release/latest-mac-arm64.yml release/latest-mac-x64.yml release/latest-mac.yml", + "dist:mac:arm64:signed": "node ./scripts/require-macos-release-secrets.cjs && npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run sign:mac:runtime-archives && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run build:notch && npm run build && electron-builder --mac zip --arm64 --publish never && node ./scripts/create-mac-dmg.mjs --app=release/mac-arm64/ADE.app --arch=arm64", + "dist:mac:x64:signed": "node ./scripts/require-macos-release-secrets.cjs && npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run sign:mac:runtime-archives && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run build:notch && npm run build && electron-builder --mac zip --x64 --publish never && node ./scripts/create-mac-dmg.mjs --app=release/mac/ADE.app --arch=x64", "notarize:mac:dmg": "node ./scripts/notarize-mac-dmg.mjs", "validate:mac:artifacts": "node ./scripts/validate-mac-artifacts.mjs", "materialize:runtime-resources": "node ./scripts/materialize-runtime-resources.mjs", @@ -377,7 +379,20 @@ "notarize": true, "mergeASARs": false, "x64ArchFiles": "**/{*darwin-arm64*,*darwin-x64*,darwin-arm64,darwin-x64,whisper-cli}{,/**}", - "artifactName": "${productName}-${version}-${arch}.${ext}" + "artifactName": "${productName}-${version}-${arch}.${ext}", + "extraResources": [ + { + "from": "resources/native/ade-attention-notch", + "to": "native/ade-attention-notch" + }, + { + "from": "resources/native/ADEAttentionNotch_ADEAttentionNotch.bundle", + "to": "native/ADEAttentionNotch_ADEAttentionNotch.bundle", + "filter": [ + "**/*" + ] + } + ] }, "fileAssociations": [ { diff --git a/apps/desktop/resources/native/README.md b/apps/desktop/resources/native/README.md new file mode 100644 index 000000000..e8ee32bf6 --- /dev/null +++ b/apps/desktop/resources/native/README.md @@ -0,0 +1,11 @@ +# ADE native helpers + +`ade-attention-notch` and its adjacent SwiftPM resource bundle are materialized +here by: + +```bash +npm --prefix apps/desktop run build:notch +``` + +The generated universal Mach-O is intentionally ignored by git. Electron Builder +copies it into `ADE.app/Contents/Resources/native/` for macOS releases. diff --git a/apps/desktop/scripts/build-attention-notch.mjs b/apps/desktop/scripts/build-attention-notch.mjs new file mode 100644 index 000000000..87b6ae42b --- /dev/null +++ b/apps/desktop/scripts/build-attention-notch.mjs @@ -0,0 +1,92 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const desktopRoot = path.resolve(scriptDir, ".."); +const packageRoot = path.join(desktopRoot, "native", "ADEAttentionNotch"); +const outputRoot = path.join(desktopRoot, "resources", "native"); +const outputPath = path.join(outputRoot, "ade-attention-notch"); +const resourceBundleName = "ADEAttentionNotch_ADEAttentionNotch.bundle"; +const outputResourceBundlePath = path.join(outputRoot, resourceBundleName); + +if (process.platform !== "darwin") { + console.log("[attention-notch] Skipping native helper build outside macOS."); + process.exit(0); +} + +if (process.env.ADE_SKIP_ATTENTION_NOTCH_BUILD === "1") { + console.log("[attention-notch] Skipping native helper build (ADE_SKIP_ATTENTION_NOTCH_BUILD=1)."); + process.exit(0); +} + +const requestedArchs = String(process.env.ADE_ATTENTION_NOTCH_ARCHS || "arm64,x86_64") + .split(",") + .map((value) => value.trim()) + .filter((value) => value === "arm64" || value === "x86_64"); + +if (requestedArchs.length === 0) { + throw new Error("ADE_ATTENTION_NOTCH_ARCHS must include arm64 and/or x86_64"); +} + +fs.mkdirSync(outputRoot, { recursive: true }); +const builtBinaries = []; +const builtResourceBundles = []; + +for (const arch of requestedArchs) { + const triple = `${arch}-apple-macosx13.0`; + const scratchPath = path.join(packageRoot, `.build-${arch}`); + const baseArgs = [ + "build", + "--package-path", packageRoot, + "--scratch-path", scratchPath, + "--configuration", "release", + "--triple", triple, + "--product", "ade-attention-notch", + ]; + console.log(`[attention-notch] Building ${arch} helper.`); + execFileSync("swift", baseArgs, { stdio: "inherit" }); + const binPath = execFileSync("swift", [...baseArgs, "--show-bin-path"], { + encoding: "utf8", + }).trim(); + const binaryPath = path.join(binPath, "ade-attention-notch"); + if (!fs.existsSync(binaryPath)) { + throw new Error(`Swift build did not produce ${binaryPath}`); + } + const resourceBundlePath = path.join(binPath, resourceBundleName); + if (!fs.existsSync(resourceBundlePath)) { + throw new Error(`Swift build did not produce ${resourceBundlePath}`); + } + builtBinaries.push(binaryPath); + builtResourceBundles.push(resourceBundlePath); +} + +const temporaryOutput = path.join( + outputRoot, + `.ade-attention-notch.${process.pid}.${Date.now()}`, +); + +try { + if (builtBinaries.length === 1) { + fs.copyFileSync(builtBinaries[0], temporaryOutput); + } else { + execFileSync("lipo", ["-create", ...builtBinaries, "-output", temporaryOutput], { + stdio: "inherit", + }); + } + fs.chmodSync(temporaryOutput, 0o755); + fs.renameSync(temporaryOutput, outputPath); + fs.rmSync(outputResourceBundlePath, { force: true, recursive: true }); + fs.cpSync(builtResourceBundles[0], outputResourceBundlePath, { recursive: true }); +} finally { + fs.rmSync(temporaryOutput, { force: true }); +} + +const architectures = execFileSync("lipo", ["-archs", outputPath], { + encoding: "utf8", +}).trim(); +console.log( + `[attention-notch] Materialized ${path.relative(desktopRoot, outputPath)} and ${resourceBundleName} (${architectures}, ${os.platform()}).`, +); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 9434e7cd1..dfc6bdcb9 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -107,6 +107,17 @@ import { import { createQueueLandingService } from "./services/prs/queueLandingService"; import { createPrSummaryService } from "./services/prs/prSummaryService"; import { openExternalUrl } from "./services/shared/externalLinks"; +import { + AttentionNotchHelper, + resolveAttentionNotchExecutablePath, + type AttentionNotchOutput, +} from "./services/attention/attentionNotchHelper"; +import { + attentionRemoteBindingMatches, + attentionItemNavigationRequest, + resolveAttentionNotchOutput, + type AttentionNotchResolvedOutput, +} from "./services/attention/attentionNotchRouter"; import { detectDefaultBaseRef, resolveRepoRoot, @@ -130,6 +141,10 @@ import { mobileProjectRepositoryIdentityFromGitOrigin } from "../shared/syncMobi import type { OpenProjectBinding, AppNavigationRequest, + AttentionItem, + AttentionNotchAcknowledgeRequest, + AttentionNotchSettings, + AttentionSnapshot, AppZoomCommand, CloneProjectInput, CreateProjectInput, @@ -5669,6 +5684,7 @@ app.whenReady().then(async () => { let quitWarningAcknowledged = false; let quitConfirmationInFlight = false; let shutdownForceTimer: NodeJS.Timeout | null = null; + let attentionNotchHelper: AttentionNotchHelper | null = null; const shutdownOpenCodeServersBestEffort = (): void => { try { @@ -5689,6 +5705,12 @@ app.whenReady().then(async () => { }; const runImmediateProcessCleanup = (reason: string): void => { + try { + attentionNotchHelper?.dispose(); + } catch { + // ignore + } + attentionNotchHelper = null; try { autoUpdateService?.dispose(); } catch { @@ -6598,7 +6620,208 @@ app.whenReady().then(async () => { installApplicationMenu(); - registerIpc({ + let latestAttentionNotchSnapshot: AttentionSnapshot | null = null; + let attentionIpcBridge: ReturnType | null = null; + + const attentionWindow = async (): Promise => { + const existing = + BrowserWindow.getFocusedWindow() + ?? BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) + ?? null; + if (existing) return existing; + const opened = await openAdeWindow(); + return opened.windowId == null ? null : BrowserWindow.fromId(opened.windowId); + }; + + const sendAttentionNotchAcknowledge = async ( + request: AttentionNotchAcknowledgeRequest, + preferredWindow?: BrowserWindow | null, + ): Promise => { + const target = preferredWindow && !preferredWindow.isDestroyed() + ? preferredWindow + : await attentionWindow(); + if (!target || target.isDestroyed()) return; + target.webContents.send(IPC.attentionNotchAcknowledgeRequested, request); + }; + + const matchingRemoteAttentionWindow = ( + item: AttentionItem, + targetId?: string | null, + requiresExactMachine = false, + ): BrowserWindow | null => { + for (const [windowId, binding] of windowProjectBindings) { + if (!attentionRemoteBindingMatches( + item, + binding, + targetId ?? null, + requiresExactMachine, + )) continue; + const win = BrowserWindow.fromId(windowId); + if (win && !win.isDestroyed()) return win; + } + return null; + }; + + const foregroundAttentionWindow = (win: BrowserWindow): void => { + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); + }; + + const navigateFromAttentionItem = async ( + item: AttentionItem, + request: Extract["request"], + options: { + acknowledge: boolean; + fallbackAction?: Extract["fallbackAction"]; + }, + ): Promise => { + const accountMachineKey = item.machine.accountMachineKey?.trim() ?? ""; + const localMachineKey = attentionIpcBridge?.getLocalMachineIdentity().machineKey ?? ""; + const targetId = accountMachineKey + ? attentionIpcBridge?.resolveTargetIdForMachineKey(accountMachineKey) ?? null + : null; + const requiresRemoteMachine = Boolean( + accountMachineKey + && accountMachineKey !== localMachineKey, + ); + let remoteWindow = matchingRemoteAttentionWindow( + item, + targetId, + requiresRemoteMachine, + ); + + if (requiresRemoteMachine && !remoteWindow) { + const win = await attentionWindow(); + if (!win || win.isDestroyed() || !attentionIpcBridge) { + throw new Error("ADE could not open the remote Attention destination."); + } + const binding = await attentionIpcBridge.openAttentionProject({ + machineKey: accountMachineKey, + projectId: item.project.projectId, + windowId: win.id, + }); + remoteWindow = binding.targetId + ? matchingRemoteAttentionWindow(item, binding.targetId) ?? win + : win; + } + + if (remoteWindow) { + foregroundAttentionWindow(remoteWindow); + remoteWindow.webContents.send(IPC.appNavigate, request); + if (options.acknowledge) { + await sendAttentionNotchAcknowledge( + { itemId: item.id, mode: "seen" }, + remoteWindow, + ); + } + return; + } + + // A canonical foreign-machine identity must never fall through to a local + // project with a coincidentally matching path. + if (requiresRemoteMachine) { + throw new Error("The owning ADE machine could not be connected."); + } + + const projectRoot = item.project.rootPath?.trim() ?? ""; + if (projectRoot && fs.existsSync(projectRoot)) { + const delivered = await deliverAppNavigationToProject(projectRoot, request); + const win = delivered.ok ? BrowserWindow.fromId(delivered.windowId) : null; + if (options.acknowledge) { + await sendAttentionNotchAcknowledge( + { itemId: item.id, mode: "seen" }, + win, + ); + } + if (options.fallbackAction) { + getActiveContext().logger.info("attention.notch_action_opened_destination", { + itemId: item.id, + actionKind: options.fallbackAction.kind, + reason: "inline_action_not_safe_for_account_scope", + }); + } + return; + } + + dispatchOrQueueAppNavigationRequest(request); + if (options.acknowledge) { + await sendAttentionNotchAcknowledge({ + itemId: item.id, + mode: "seen", + }); + } + if (options.fallbackAction) { + getActiveContext().logger.info("attention.notch_action_opened_destination", { + itemId: item.id, + actionKind: options.fallbackAction.kind, + reason: "remote_destination_not_connected", + }); + } + }; + + const navigateFromAttentionNotch = async ( + resolved: Extract, + ): Promise => { + await navigateFromAttentionItem(resolved.item, resolved.request, { + acknowledge: true, + fallbackAction: resolved.fallbackAction, + }); + }; + + const handleAttentionNotchOutput = (output: AttentionNotchOutput): void => { + if (output.type === "surface") { + getActiveContext().logger.info("attention.notch_surface", { + displayId: output.displayId, + surface: output.surface, + }); + return; + } + if (output.type === "protocol_error") { + getActiveContext().logger.warn("attention.notch_protocol_error", { + message: output.message, + }); + return; + } + const resolved = resolveAttentionNotchOutput(output, latestAttentionNotchSnapshot); + if (resolved.kind === "ignore") { + getActiveContext().logger.warn("attention.notch_output_ignored", { + itemId: output.itemId, + reason: resolved.reason, + }); + return; + } + if (resolved.kind === "acknowledge") { + void sendAttentionNotchAcknowledge({ + itemId: resolved.item.id, + mode: resolved.mode, + }).catch((error: unknown) => { + getActiveContext().logger.warn("attention.notch_ack_route_failed", { + itemId: resolved.item.id, + error: error instanceof Error ? error.message : String(error), + }); + }); + return; + } + void navigateFromAttentionNotch(resolved).catch((error: unknown) => { + getActiveContext().logger.warn("attention.notch_navigation_failed", { + itemId: resolved.item.id, + error: error instanceof Error ? error.message : String(error), + }); + }); + }; + + attentionNotchHelper = new AttentionNotchHelper({ + executablePath: resolveAttentionNotchExecutablePath({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + appPath: app.getAppPath(), + }), + logger: getActiveContext().logger, + onOutput: handleAttentionNotchOutput, + }); + + attentionIpcBridge = registerIpc({ getCtx: () => { const ctx = getActiveContext(); if (!ctx.autoUpdateService) { @@ -6637,6 +6860,20 @@ app.whenReady().then(async () => { globalStatePath, builtInBrowserService, productAnalyticsService, + publishAttentionNotchSnapshot: (snapshot: AttentionSnapshot) => { + latestAttentionNotchSnapshot = snapshot; + attentionNotchHelper?.publishSnapshot(snapshot); + }, + updateAttentionNotchSettings: (settings: AttentionNotchSettings) => { + attentionNotchHelper?.updateSettings(settings); + }, + openAttentionItem: async (item: AttentionItem) => { + await navigateFromAttentionItem( + item, + attentionItemNavigationRequest(item), + { acknowledge: false }, + ); + }, }); // Explicit project launches still bind a project before the renderer boots; diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 74381cf2e..1ea1afd0b 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -2251,6 +2251,11 @@ describe("runtime account actions", () => { expect(isCtoOnlyAdeAction("account", "listMachines")).toBe(true); expect(isCtoOnlyAdeAction("account", "pairMachine")).toBe(true); expect(isCtoOnlyAdeAction("account", "status")).toBe(false); + expect(isCtoOnlyAdeAction("attention", "getSnapshot")).toBe(true); + expect(isCtoOnlyAdeAction("attention", "acknowledge")).toBe(true); + expect(isCtoOnlyAdeAction("attention", "reportPresence")).toBe(true); + expect(isCtoOnlyAdeAction("attention", "getPreferences")).toBe(true); + expect(isCtoOnlyAdeAction("attention", "putPreferences")).toBe(true); const fullStatus = { signedIn: true, userId: "user_123", diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index b73c3f004..c0e365666 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -26,6 +26,10 @@ import type { AutomationSaveDraftResult, } from "../../../shared/types/automations"; import type { AgentSessionSettlementResult } from "../../../shared/types/sessions"; +import type { + AttentionPreferences, + AttentionPresence, +} from "../../../shared/types/attention"; import type { ComputerUseOwnerSnapshotArgs } from "../../../shared/types/computerUseArtifacts"; import type { AgentChatFileSearchArgs, @@ -117,6 +121,7 @@ import { createAccountActionDomainService } from "../../../../../ade-cli/src/ser export const ADE_ACTION_DOMAIN_NAMES = [ "account", + "attention", "lane", "git", "diff", @@ -187,6 +192,13 @@ export const ADE_ACTION_CTO_ONLY: Partial { + const accountOwnerId = typeof value === "string" ? value.trim() : ""; + const status = runtime.accountAuthService?.getStatus(); + const currentOwnerId = status?.signedIn ? status.userId?.trim() || null : null; + if (!accountOwnerId || currentOwnerId !== accountOwnerId) { + throw new Error("The ADE account changed before Attention preferences could be used."); + } + return accountOwnerId; + }; + return { + getSnapshot: (args?: { since?: number; streamId?: string | null }) => + publisher.getAttentionSnapshot( + Number.isFinite(Number(args?.since)) + ? Math.max(0, Math.trunc(Number(args?.since))) + : 0, + typeof args?.streamId === "string" && args.streamId.trim() + ? args.streamId.trim() + : null, + ), + acknowledge: (args?: { + itemIds?: unknown; + seenAt?: unknown; + dismissedAt?: unknown; + }) => { + const itemIds = Array.isArray(args?.itemIds) + ? args.itemIds + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .map((value) => value.trim()) + .slice(0, 64) + : []; + if (itemIds.length === 0) throw new Error("itemIds must include at least one attention item."); + return publisher.acknowledgeAttention({ + itemIds, + ...(typeof args?.seenAt === "string" ? { seenAt: args.seenAt } : {}), + ...(args?.dismissedAt === null || typeof args?.dismissedAt === "string" + ? { dismissedAt: args.dismissedAt } + : {}), + }); + }, + reportPresence: (args?: AttentionPresence) => { + if (!args || typeof args.deviceId !== "string") { + throw new Error("A valid Attention presence payload is required."); + } + return publisher.reportAttentionPresence(args); + }, + getPreferences: (args?: { accountOwnerId?: unknown }) => + publisher.getAttentionPreferences( + requireCurrentAccountOwner(args?.accountOwnerId), + ), + putPreferences: (args?: { + accountOwnerId?: unknown; + preferences?: AttentionPreferences; + }) => { + if (!args?.preferences || typeof args.preferences !== "object") { + throw new Error("A valid Attention preferences payload is required."); + } + return publisher.putAttentionPreferences( + requireCurrentAccountOwner(args.accountOwnerId), + args.preferences, + ); + }, + }; +} + function buildSessionDomainService(runtime: AdeRuntime): OpaqueService | null { const sessionService = runtime.sessionService; if (!sessionService) return null; @@ -3632,6 +3745,7 @@ export function getAdeActionDomainServices( account: runtime.accountAuthService ? toService(createAccountActionDomainService(runtime.accountAuthService)) : null, + attention: toService(buildAttentionDomainService(runtime)), lane: toService(buildLaneDomainService(runtime)), git: toService(runtime.gitService), diff: toService(runtime.diffService), diff --git a/apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts b/apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts new file mode 100644 index 000000000..45cf7271c --- /dev/null +++ b/apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts @@ -0,0 +1,208 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const spawnMock = vi.fn(); +const existsSyncMock = vi.fn((_filePath: unknown) => true); + +vi.mock("node:child_process", () => ({ + spawn: (command: string, args: string[], options: object) => + spawnMock(command, args, options), +})); + +vi.mock("node:fs", () => ({ + default: { + existsSync: (filePath: unknown) => existsSyncMock(filePath), + }, +})); + +import { + AttentionNotchHelper, + resolveAttentionNotchExecutablePath, +} from "./attentionNotchHelper"; + +function fakeChild(): ChildProcessWithoutNullStreams & EventEmitter { + const child = new EventEmitter() as ChildProcessWithoutNullStreams & EventEmitter; + Object.assign(child, { + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + pid: 42, + exitCode: null, + signalCode: null, + kill: vi.fn(), + }); + return child; +} + +const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; + +describe("AttentionNotchHelper", () => { + beforeEach(() => { + vi.clearAllMocks(); + existsSyncMock.mockReturnValue(true); + }); + + it("resolves packaged and development helper paths", () => { + expect(resolveAttentionNotchExecutablePath({ + isPackaged: true, + resourcesPath: "/Applications/ADE.app/Contents/Resources", + appPath: "/repo/apps/desktop", + })).toBe("/Applications/ADE.app/Contents/Resources/native/ade-attention-notch"); + expect(resolveAttentionNotchExecutablePath({ + isPackaged: false, + resourcesPath: "/unused", + appPath: "/repo/apps/desktop", + })).toBe("/repo/apps/desktop/resources/native/ade-attention-notch"); + }); + + it("publishes exact helper actions and rejects malformed output", () => { + const child = fakeChild(); + spawnMock.mockReturnValue(child); + const onOutput = vi.fn(); + const helper = new AttentionNotchHelper({ + executablePath: "/tmp/notch", + logger, + onOutput, + platform: "darwin", + }); + helper.updateSettings({ + enabled: true, + preferredDisplayId: null, + hideDetails: true, + celebrationsEnabled: true, + soundsEnabled: false, + }); + child.emit("spawn"); + (child.stdout as PassThrough).write([ + JSON.stringify({ + type: "open", + itemId: "agent-1", + destination: { kind: "session", sessionId: "session-1" }, + deepLink: "ade://session/session-1", + }), + "{\"type\":\"open\"}", + "", + ].join("\n")); + + expect(onOutput).toHaveBeenCalledOnce(); + expect(onOutput.mock.calls[0]?.[0]).toMatchObject({ + type: "open", + itemId: "agent-1", + }); + expect(logger.warn).toHaveBeenCalledWith("attention.notch_helper_invalid_output"); + helper.dispose(); + }); + + it("does not start on non-macOS or when the binary is missing", () => { + const unsupported = new AttentionNotchHelper({ + executablePath: "/tmp/notch", + logger, + onOutput: vi.fn(), + platform: "win32", + }); + unsupported.updateSettings({ + enabled: true, + preferredDisplayId: null, + hideDetails: true, + celebrationsEnabled: true, + soundsEnabled: false, + }); + expect(unsupported.start()).toBe(false); + + existsSyncMock.mockReturnValue(false); + const missing = new AttentionNotchHelper({ + executablePath: "/tmp/notch", + logger, + onOutput: vi.fn(), + platform: "darwin", + }); + missing.updateSettings({ + enabled: true, + preferredDisplayId: null, + hideDetails: true, + celebrationsEnabled: true, + soundsEnabled: false, + }); + expect(missing.start()).toBe(false); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it("fails closed until enabled settings arrive, then sends settings before the cached snapshot", () => { + const child = fakeChild(); + spawnMock.mockReturnValue(child); + const helper = new AttentionNotchHelper({ + executablePath: "/tmp/notch", + logger, + onOutput: vi.fn(), + platform: "darwin", + }); + const snapshot = { + contractVersion: 1 as const, + revision: 1, + generatedAt: "2026-07-28T12:00:00.000Z", + items: [], + }; + const lines: string[] = []; + (child.stdin as PassThrough).setEncoding("utf8"); + child.stdin.on("data", (chunk: Buffer | string) => { + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + lines.push(...text.trim().split("\n")); + }); + + helper.publishSnapshot(snapshot); + + expect(helper.start()).toBe(false); + expect(spawnMock).not.toHaveBeenCalled(); + + helper.updateSettings({ + enabled: true, + preferredDisplayId: null, + hideDetails: true, + celebrationsEnabled: true, + soundsEnabled: false, + }); + expect(spawnMock).toHaveBeenCalledOnce(); + + child.emit("spawn"); + + expect(lines.map((line) => JSON.parse(line).type)).toEqual(["settings", "snapshot"]); + expect(JSON.parse(lines[0] ?? "{}").settings).toMatchObject({ + enabled: true, + hideDetails: true, + soundsEnabled: false, + }); + expect(JSON.parse(lines[1] ?? "{}").snapshot).toEqual(snapshot); + helper.dispose(); + }); + + it("keeps device-local disabled state from spawning on snapshot refresh", () => { + const helper = new AttentionNotchHelper({ + executablePath: "/tmp/notch", + logger, + onOutput: vi.fn(), + platform: "darwin", + }); + helper.updateSettings({ + enabled: false, + preferredDisplayId: null, + hideDetails: false, + celebrationsEnabled: true, + soundsEnabled: false, + }); + helper.publishSnapshot({ + contractVersion: 1, + revision: 1, + generatedAt: "2026-07-28T12:00:00.000Z", + items: [], + }); + + expect(spawnMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/main/services/attention/attentionNotchHelper.ts b/apps/desktop/src/main/services/attention/attentionNotchHelper.ts new file mode 100644 index 000000000..1962ccc31 --- /dev/null +++ b/apps/desktop/src/main/services/attention/attentionNotchHelper.ts @@ -0,0 +1,281 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import type { + AttentionAction, + AttentionDestination, + AttentionNotchSettings, + AttentionSnapshot, +} from "../../../shared/types/attention"; +import type { Logger } from "../logging/logger"; + +const MAX_HELPER_LINE_BYTES = 256 * 1024; +const MAX_RESTART_ATTEMPTS = 3; +const GRACEFUL_SHUTDOWN_MS = 500; + +export type AttentionNotchOutput = + | { + type: "open"; + itemId: string; + destination: AttentionDestination; + deepLink?: string | null; + } + | { + type: "action"; + itemId: string; + action: AttentionAction; + destination: AttentionDestination; + deepLink?: string | null; + } + | { + type: "surface"; + displayId: number; + surface: "physical_notch" | "menu_bar"; + } + | { + type: "protocol_error"; + message: string; + }; + +type AttentionNotchHelperOptions = { + executablePath: string; + logger: Logger; + onOutput: (output: AttentionNotchOutput) => void; + restartDelayMs?: number; + platform?: NodeJS.Platform; +}; + +export function resolveAttentionNotchExecutablePath(input: { + isPackaged: boolean; + resourcesPath: string; + appPath: string; +}): string { + return input.isPackaged + ? path.join(input.resourcesPath, "native", "ade-attention-notch") + : path.join(input.appPath, "resources", "native", "ade-attention-notch"); +} + +export class AttentionNotchHelper { + private child: ChildProcessWithoutNullStreams | null = null; + private disposed = false; + private restartAttempts = 0; + private restartTimer: NodeJS.Timeout | null = null; + private stableTimer: NodeJS.Timeout | null = null; + private stdoutBuffer = ""; + private latestSnapshot: AttentionSnapshot | null = null; + private latestSettings: AttentionNotchSettings | null = null; + + constructor(private readonly options: AttentionNotchHelperOptions) {} + + start(): boolean { + if (this.disposed || this.child || this.latestSettings?.enabled !== true) return false; + if ((this.options.platform ?? process.platform) !== "darwin") return false; + if (!fs.existsSync(this.options.executablePath)) { + this.options.logger.warn("attention.notch_helper_missing", { + executablePath: this.options.executablePath, + }); + return false; + } + + try { + const child = spawn(this.options.executablePath, [], { + env: { + ...process.env, + LC_ALL: "en_US.UTF-8", + }, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + this.child = child; + this.stdoutBuffer = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => this.consumeStdout(chunk)); + child.stdin.on("error", (error) => { + if (!this.disposed) { + this.options.logger.warn("attention.notch_helper_stdin_error", { + error: error.message, + }); + } + }); + child.stderr.on("data", (chunk: string) => { + this.options.logger.warn("attention.notch_helper_stderr", { + message: chunk.slice(0, 2_000), + }); + }); + child.once("spawn", () => { + this.options.logger.info("attention.notch_helper_started", { + pid: child.pid ?? null, + }); + this.stableTimer = setTimeout(() => { + this.stableTimer = null; + this.restartAttempts = 0; + }, 30_000); + this.stableTimer.unref(); + if (this.latestSettings) this.write({ type: "settings", settings: this.latestSettings }); + if (this.latestSnapshot) this.write({ type: "snapshot", snapshot: this.latestSnapshot }); + }); + child.once("error", (error) => { + this.options.logger.warn("attention.notch_helper_error", { + error: error.message, + }); + }); + child.once("exit", (code, signal) => { + if (this.stableTimer) { + clearTimeout(this.stableTimer); + this.stableTimer = null; + } + if (this.child === child) this.child = null; + this.options.logger.info("attention.notch_helper_exited", { + code, + signal, + disposed: this.disposed, + }); + if (!this.disposed) this.scheduleRestart(); + }); + return true; + } catch (error) { + this.options.logger.warn("attention.notch_helper_spawn_failed", { + error: error instanceof Error ? error.message : String(error), + }); + this.scheduleRestart(); + return false; + } + } + + publishSnapshot(snapshot: AttentionSnapshot): void { + this.latestSnapshot = snapshot; + if (!this.child && this.latestSettings?.enabled === true) { + this.start(); + } else if (this.child) { + this.write({ type: "snapshot", snapshot }); + } + } + + updateSettings(settings: AttentionNotchSettings): void { + this.latestSettings = settings; + if (!settings.enabled && this.restartTimer) { + clearTimeout(this.restartTimer); + this.restartTimer = null; + } + if (!this.child && settings.enabled) { + this.start(); + } else { + this.write({ type: "settings", settings }); + } + } + + setVisible(visible: boolean): void { + this.write({ type: "visibility", visible }); + } + + reanchor(): void { + this.write({ type: "reanchor" }); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + if (this.restartTimer) { + clearTimeout(this.restartTimer); + this.restartTimer = null; + } + if (this.stableTimer) { + clearTimeout(this.stableTimer); + this.stableTimer = null; + } + const child = this.child; + this.child = null; + if (!child) return; + + try { + child.stdin.write(`${JSON.stringify({ type: "quit" })}\n`); + child.stdin.end(); + } catch { + child.kill("SIGTERM"); + return; + } + const killTimer = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGTERM"); + } + }, GRACEFUL_SHUTDOWN_MS); + killTimer.unref(); + } + + private write(payload: unknown): void { + const child = this.child; + if (!child || !child.stdin.writable || child.stdin.destroyed) return; + try { + child.stdin.write(`${JSON.stringify(payload)}\n`); + } catch (error) { + this.options.logger.warn("attention.notch_helper_write_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + private consumeStdout(chunk: string): void { + this.stdoutBuffer += chunk; + if (Buffer.byteLength(this.stdoutBuffer, "utf8") > MAX_HELPER_LINE_BYTES) { + this.options.logger.warn("attention.notch_helper_output_overflow"); + this.stdoutBuffer = ""; + return; + } + + while (true) { + const newline = this.stdoutBuffer.indexOf("\n"); + if (newline < 0) return; + const line = this.stdoutBuffer.slice(0, newline).trim(); + this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1); + if (!line) continue; + try { + const parsed = JSON.parse(line) as unknown; + if (isAttentionNotchOutput(parsed)) { + this.options.onOutput(parsed); + } else { + this.options.logger.warn("attention.notch_helper_invalid_output"); + } + } catch { + this.options.logger.warn("attention.notch_helper_invalid_json"); + } + } + } + + private scheduleRestart(): void { + if (this.disposed || this.restartTimer || this.restartAttempts >= MAX_RESTART_ATTEMPTS) { + return; + } + this.restartAttempts += 1; + const delay = (this.options.restartDelayMs ?? 750) * this.restartAttempts; + this.restartTimer = setTimeout(() => { + this.restartTimer = null; + this.start(); + }, delay); + this.restartTimer.unref(); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isAttentionNotchOutput(value: unknown): value is AttentionNotchOutput { + if (!isRecord(value) || typeof value.type !== "string") return false; + if (value.type === "surface") { + return ( + typeof value.displayId === "number" + && (value.surface === "physical_notch" || value.surface === "menu_bar") + ); + } + if (value.type === "protocol_error") return typeof value.message === "string"; + if (value.type !== "open" && value.type !== "action") return false; + if ( + typeof value.itemId !== "string" + || !isRecord(value.destination) + || typeof value.destination.kind !== "string" + ) { + return false; + } + return value.type === "open" || isRecord(value.action); +} diff --git a/apps/desktop/src/main/services/attention/attentionNotchRouter.test.ts b/apps/desktop/src/main/services/attention/attentionNotchRouter.test.ts new file mode 100644 index 000000000..4f3f6d080 --- /dev/null +++ b/apps/desktop/src/main/services/attention/attentionNotchRouter.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "vitest"; + +import { + attentionRemoteBindingMatches, + attentionItemNavigationRequest, + parseAttentionNotchSettings, + parseAttentionNotchSnapshot, + resolveAttentionNotchOutput, +} from "./attentionNotchRouter"; +import type { AttentionItem, AttentionSnapshot } from "../../../shared/types"; + +function item(overrides: Partial = {}): AttentionItem { + return { + contractVersion: 1, + id: "agent-1", + revision: 3, + fingerprint: "agent-1:3", + kind: "agent", + eventKind: "agent_needs_you", + phase: "needs_you", + machine: { + machineKey: "machine-1", + name: "MacBook Pro", + online: true, + lastSeenAt: "2026-07-28T12:00:00.000Z", + }, + project: { + projectId: "project-1", + name: "ADE", + rootPath: "/projects/ADE", + }, + laneId: "d228b30e-d2b8-4140-b901-4e9aeab0ad38", + laneName: "notch", + provider: "codex", + model: "gpt-5", + title: "Agent needs you", + preview: "Approve the command", + privacyPreview: "Agent needs your attention", + detail: null, + recentActivity: ["Read package.json"], + planProgress: { completed: 2, total: 4, current: "Waiting for approval" }, + destination: { + kind: "session", + sessionId: "session-1", + itemId: "approval-1", + eventId: null, + }, + actions: [ + { id: "approve-1", kind: "approve", label: "Approve" }, + { id: "seen-1", kind: "mark_seen", label: "Mark seen" }, + ], + occurredAt: "2026-07-28T12:00:00.000Z", + updatedAt: "2026-07-28T12:00:02.000Z", + seenAt: null, + dismissedAt: null, + expiresAt: null, + ...overrides, + }; +} + +function snapshot(attentionItem = item()): AttentionSnapshot { + return { + contractVersion: 1, + revision: 4, + generatedAt: "2026-07-28T12:00:03.000Z", + items: [attentionItem], + tombstones: [], + }; +} + +describe("Attention Notch routing", () => { + it("never path-matches a canonical foreign machine to another machine", () => { + const foreign = item({ + machine: { + ...item().machine, + accountMachineKey: "account-machine-b", + }, + }); + const binding = { + kind: "remote" as const, + key: "remote:machine-a:project-1", + targetId: "machine-a", + runtimeName: "Machine A", + projectId: "project-1", + rootPath: "/projects/ADE", + displayName: "ADE", + }; + expect(attentionRemoteBindingMatches( + foreign, + binding, + null, + true, + )).toBe(false); + expect(attentionRemoteBindingMatches( + foreign, + binding, + "machine-a", + true, + )).toBe(true); + }); + + it("accepts bounded canonical snapshots and settings", () => { + expect(parseAttentionNotchSnapshot(snapshot())).toEqual(snapshot()); + expect(parseAttentionNotchSettings({ + enabled: true, + preferredDisplayId: 12, + hideDetails: false, + celebrationsEnabled: true, + soundsEnabled: false, + })).toEqual({ + enabled: true, + preferredDisplayId: 12, + hideDetails: false, + celebrationsEnabled: true, + soundsEnabled: false, + }); + }); + + it("rejects malformed or cross-kind renderer payloads", () => { + expect(parseAttentionNotchSnapshot({ + ...snapshot(), + items: [{ ...item(), phase: "invented_phase" }], + })).toBeNull(); + expect(parseAttentionNotchSnapshot({ + ...snapshot(), + items: [{ + ...item(), + kind: "pull_request", + destination: item().destination, + }], + })).toBeNull(); + expect(parseAttentionNotchSettings({ + enabled: true, + preferredDisplayId: -1, + hideDetails: false, + celebrationsEnabled: true, + soundsEnabled: true, + })).toBeNull(); + }); + + it("uses the canonical snapshot destination instead of trusting helper output", () => { + const current = item(); + expect(resolveAttentionNotchOutput({ + type: "open", + itemId: current.id, + destination: { kind: "session", sessionId: "stale-session" }, + }, snapshot(current))).toEqual({ + kind: "ignore", + reason: "stale_destination", + }); + }); + + it("routes unsupported inline actions to the exact destination", () => { + const current = item(); + expect(resolveAttentionNotchOutput({ + type: "action", + itemId: current.id, + destination: { + eventId: current.destination.kind === "session" + ? current.destination.eventId + : null, + itemId: current.destination.kind === "session" + ? current.destination.itemId + : null, + sessionId: current.destination.kind === "session" + ? current.destination.sessionId + : "", + kind: "session", + }, + action: current.actions[0]!, + }, snapshot(current))).toEqual({ + kind: "navigate", + item: current, + request: attentionItemNavigationRequest(current), + fallbackAction: current.actions[0], + }); + }); + + it("keeps acknowledgement actions inline", () => { + const current = item(); + expect(resolveAttentionNotchOutput({ + type: "action", + itemId: current.id, + destination: current.destination, + action: current.actions[1]!, + }, snapshot(current))).toEqual({ + kind: "acknowledge", + item: current, + mode: "seen", + }); + }); + + it("preserves exact PR ids and detail tabs", () => { + const pr = item({ + id: "pr-1", + kind: "pull_request", + eventKind: "pr_checks_failing", + phase: "checks_failing", + destination: { + kind: "pull_request", + prId: "database-pr-id", + repoOwner: "acme", + repoName: "ade", + number: 42, + tab: "checks", + eventId: "event-7", + }, + }); + expect(attentionItemNavigationRequest(pr)).toEqual({ + target: { + kind: "pr", + prId: "database-pr-id", + prNumber: 42, + laneId: pr.laneId, + repoOwner: "acme", + repoName: "ade", + detailTab: "checks", + }, + source: "attention-notch", + }); + }); +}); diff --git a/apps/desktop/src/main/services/attention/attentionNotchRouter.ts b/apps/desktop/src/main/services/attention/attentionNotchRouter.ts new file mode 100644 index 000000000..f0f71c41f --- /dev/null +++ b/apps/desktop/src/main/services/attention/attentionNotchRouter.ts @@ -0,0 +1,368 @@ +import type { + AppNavigationRequest, + AttentionAction, + AttentionItem, + AttentionNotchSettings, + AttentionSnapshot, + OpenProjectBinding, +} from "../../../shared/types"; +import type { AttentionNotchOutput } from "./attentionNotchHelper"; + +const MAX_NOTCH_ITEMS = 256; +const MAX_NOTCH_ACTIONS = 12; +const MAX_SNAPSHOT_BYTES = 512 * 1024; +const ATTENTION_PHASES = new Set([ + "starting", + "running", + "needs_you", + "blocked", + "failed", + "completed", + "stale", + "checks_failing", + "review_requested", + "changes_requested", + "merge_ready", + "open", + "merged", + "closed", +]); +const ATTENTION_EVENTS = new Set([ + "agent_running", + "agent_needs_you", + "agent_failed", + "agent_completed", + "pr_checks_failing", + "pr_review_requested", + "pr_changes_requested", + "pr_merge_ready", + "pr_merged", + "pr_opened", + "pr_closed", +]); + +export type AttentionNotchResolvedOutput = + | { + kind: "navigate"; + item: AttentionItem; + request: AppNavigationRequest; + fallbackAction: AttentionAction | null; + } + | { + kind: "acknowledge"; + item: AttentionItem; + mode: "seen" | "dismiss"; + } + | { + kind: "ignore"; + reason: + | "non_interactive_output" + | "unknown_item" + | "stale_destination" + | "unknown_action"; + }; + +export function attentionRemoteBindingMatches( + item: AttentionItem, + binding: Extract, + targetId: string | null, + requiresExactMachine: boolean, +): boolean { + if (binding.projectId !== item.project.projectId) return false; + if (requiresExactMachine) { + return Boolean(targetId && binding.targetId === targetId); + } + return targetId + ? binding.targetId === targetId + : binding.rootPath === item.project.rootPath; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown, maxLength = 4_096): value is string { + return typeof value === "string" && value.trim().length > 0 && value.length <= maxLength; +} + +function isNullableString(value: unknown, maxLength = 4_096): boolean { + return value == null || (typeof value === "string" && value.length <= maxLength); +} + +function isAttentionDestination(value: unknown): boolean { + if (!isRecord(value)) return false; + if (value.kind === "session") { + return ( + isNonEmptyString(value.sessionId) + && isNullableString(value.itemId) + && isNullableString(value.eventId) + ); + } + return ( + value.kind === "pull_request" + && Number.isSafeInteger(value.number) + && Number(value.number) > 0 + && (value.tab === "overview" + || value.tab === "activity" + || value.tab === "checks" + || value.tab === "files") + && isNullableString(value.prId) + && isNullableString(value.repoOwner, 256) + && isNullableString(value.repoName, 256) + && isNullableString(value.eventId) + ); +} + +function isAttentionAction(value: unknown): boolean { + if (!isRecord(value)) return false; + return ( + isNonEmptyString(value.id, 512) + && ( + value.kind === "approve" + || value.kind === "deny" + || value.kind === "answer" + || value.kind === "restart" + || value.kind === "rerun_checks" + || value.kind === "mark_seen" + || value.kind === "dismiss" + || value.kind === "open" + ) + && isNonEmptyString(value.label, 256) + && (value.destructive == null || typeof value.destructive === "boolean") + && ( + value.payload == null + || ( + isRecord(value.payload) + && Object.keys(value.payload).length <= 32 + && Object.values(value.payload).every( + (entry) => + entry == null + || typeof entry === "string" + || typeof entry === "number" + || typeof entry === "boolean", + ) + ) + ) + ); +} + +function isAttentionItem(value: unknown): value is AttentionItem { + if (!isRecord(value)) return false; + if ( + value.contractVersion !== 1 + || !isNonEmptyString(value.id, 512) + || !Number.isSafeInteger(value.revision) + || Number(value.revision) < 0 + || !isNonEmptyString(value.fingerprint, 1_024) + || (value.kind !== "agent" && value.kind !== "pull_request") + || typeof value.eventKind !== "string" + || !ATTENTION_EVENTS.has(value.eventKind) + || typeof value.phase !== "string" + || !ATTENTION_PHASES.has(value.phase) + || !isRecord(value.machine) + || !isNonEmptyString(value.machine.machineKey, 512) + || ( + value.machine.accountMachineKey != null + && ( + !isNonEmptyString(value.machine.accountMachineKey, 64) + || !/^[a-f0-9]{32,64}$/i.test(value.machine.accountMachineKey) + ) + ) + || !isNullableString(value.machine.deviceId, 256) + || !isNonEmptyString(value.machine.name, 512) + || typeof value.machine.online !== "boolean" + || !isNullableString(value.machine.lastSeenAt, 128) + || !isRecord(value.project) + || !isNonEmptyString(value.project.projectId, 512) + || !isNonEmptyString(value.project.name, 512) + || !isNullableString(value.project.rootPath) + || !isNullableString(value.laneId, 512) + || !isNullableString(value.laneName, 512) + || !isNullableString(value.provider, 256) + || !isNullableString(value.model, 512) + || !isNonEmptyString(value.title, 1_024) + || !isNonEmptyString(value.preview, 4_096) + || !isNonEmptyString(value.privacyPreview, 1_024) + || !isNullableString(value.detail, 8_192) + || ( + value.recentActivity != null + && ( + !Array.isArray(value.recentActivity) + || value.recentActivity.length > 16 + || !value.recentActivity.every((entry) => isNonEmptyString(entry, 1_024)) + ) + ) + || ( + value.planProgress != null + && ( + !isRecord(value.planProgress) + || !Number.isSafeInteger(value.planProgress.completed) + || Number(value.planProgress.completed) < 0 + || !Number.isSafeInteger(value.planProgress.total) + || Number(value.planProgress.total) < 0 + || Number(value.planProgress.completed) > Number(value.planProgress.total) + || !isNullableString(value.planProgress.current, 1_024) + ) + ) + || !isAttentionDestination(value.destination) + || (value.kind === "agent" && (value.destination as { kind?: unknown }).kind !== "session") + || ( + value.kind === "pull_request" + && (value.destination as { kind?: unknown }).kind !== "pull_request" + ) + || !Array.isArray(value.actions) + || value.actions.length > MAX_NOTCH_ACTIONS + || !value.actions.every(isAttentionAction) + || !isNonEmptyString(value.occurredAt, 128) + || !isNonEmptyString(value.updatedAt, 128) + || !isNullableString(value.seenAt, 128) + || !isNullableString(value.dismissedAt, 128) + || !isNullableString(value.expiresAt, 128) + ) { + return false; + } + return true; +} + +export function parseAttentionNotchSnapshot(input: unknown): AttentionSnapshot | null { + if (!isRecord(input)) return null; + try { + if (Buffer.byteLength(JSON.stringify(input), "utf8") > MAX_SNAPSHOT_BYTES) return null; + } catch { + return null; + } + if ( + input.contractVersion !== 1 + || !isNullableString(input.streamId, 512) + || !Number.isSafeInteger(input.revision) + || Number(input.revision) < 0 + || !isNonEmptyString(input.generatedAt, 128) + || !Array.isArray(input.items) + || input.items.length > MAX_NOTCH_ITEMS + || !input.items.every(isAttentionItem) + ) { + return null; + } + return input as AttentionSnapshot; +} + +export function parseAttentionNotchSettings(input: unknown): AttentionNotchSettings | null { + if (!isRecord(input)) return null; + if ( + typeof input.enabled !== "boolean" + || typeof input.hideDetails !== "boolean" + || typeof input.celebrationsEnabled !== "boolean" + || typeof input.soundsEnabled !== "boolean" + || ( + input.preferredDisplayId != null + && (!Number.isSafeInteger(input.preferredDisplayId) || Number(input.preferredDisplayId) < 0) + ) + ) { + return null; + } + return { + enabled: input.enabled, + preferredDisplayId: input.preferredDisplayId == null + ? null + : Number(input.preferredDisplayId), + hideDetails: input.hideDetails, + celebrationsEnabled: input.celebrationsEnabled, + soundsEnabled: input.soundsEnabled, + }; +} + +function sameDestination( + left: AttentionItem["destination"], + right: AttentionItem["destination"], +): boolean { + if (left.kind !== right.kind) return false; + if (left.kind === "session" && right.kind === "session") { + return ( + left.sessionId === right.sessionId + && (left.itemId ?? null) === (right.itemId ?? null) + && (left.eventId ?? null) === (right.eventId ?? null) + ); + } + if (left.kind !== "pull_request" || right.kind !== "pull_request") return false; + return ( + (left.prId ?? null) === (right.prId ?? null) + && (left.repoOwner ?? null) === (right.repoOwner ?? null) + && (left.repoName ?? null) === (right.repoName ?? null) + && left.number === right.number + && left.tab === right.tab + && (left.eventId ?? null) === (right.eventId ?? null) + ); +} + +export function attentionItemNavigationRequest(item: AttentionItem): AppNavigationRequest { + if (item.destination.kind === "session") { + return { + target: { + kind: "work", + sessionId: item.destination.sessionId, + laneId: item.laneId ?? null, + envelope: null, + event: null, + offset: null, + }, + source: "attention-notch", + }; + } + + return { + target: { + kind: "pr", + prId: item.destination.prId ?? null, + prNumber: item.destination.number, + laneId: item.laneId ?? null, + repoOwner: item.destination.repoOwner ?? null, + repoName: item.destination.repoName ?? null, + detailTab: item.destination.tab === "activity" + ? "overview" + : item.destination.tab, + }, + source: "attention-notch", + }; +} + +export function resolveAttentionNotchOutput( + output: AttentionNotchOutput, + snapshot: AttentionSnapshot | null, +): AttentionNotchResolvedOutput { + if (output.type !== "open" && output.type !== "action") { + return { kind: "ignore", reason: "non_interactive_output" }; + } + const item = snapshot?.items.find((candidate) => candidate.id === output.itemId); + if (!item) return { kind: "ignore", reason: "unknown_item" }; + if (!sameDestination(item.destination, output.destination)) { + return { kind: "ignore", reason: "stale_destination" }; + } + if (output.type === "open") { + return { + kind: "navigate", + item, + request: attentionItemNavigationRequest(item), + fallbackAction: null, + }; + } + + const action = item.actions.find( + (candidate) => + candidate.id === output.action.id + && candidate.kind === output.action.kind, + ); + if (!action) return { kind: "ignore", reason: "unknown_action" }; + if (action.kind === "mark_seen" || action.kind === "dismiss") { + return { + kind: "acknowledge", + item, + mode: action.kind === "dismiss" ? "dismiss" : "seen", + }; + } + return { + kind: "navigate", + item, + request: attentionItemNavigationRequest(item), + fallbackAction: action.kind === "open" ? null : action, + }; +} diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 9c3146f0b..c2cbd09ca 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -16,6 +16,17 @@ import type { DiskPressureMonitor, DiskPressureSnapshot } from "../storage/diskP import path from "node:path"; import { fileURLToPath } from "node:url"; import { IPC } from "../../../shared/ipc"; +import type { + AttentionItem, + AttentionNotchSettings, + AttentionPreferences, + AttentionPresence, + AttentionSnapshot, +} from "../../../shared/types/attention"; +import { + ATTENTION_CONTRACT_VERSION, + DEFAULT_ATTENTION_PREFERENCES, +} from "../../../shared/types/attention"; import { isSyncServiceUnavailableError } from "../../../shared/runtimeErrors"; import { encodeCodedErrorMessage, parseCodedErrorMessage } from "../../../shared/codedError"; import { areAutomationsEnabledForPackagedState } from "../../../shared/automationAvailability"; @@ -95,6 +106,10 @@ import { toShallowRecentProjectSummary, } from "../projects/recentProjectSummary"; import { authorizeRecentProjectRuntimeRoot } from "../projects/recentProjectRuntimeAuthorization"; +import { + parseAttentionNotchSettings, + parseAttentionNotchSnapshot, +} from "../attention/attentionNotchRouter"; import type { ApplyConflictProposalArgs, BatchAssessmentResult, @@ -1565,6 +1580,9 @@ export function registerIpc({ globalStatePath, builtInBrowserService, productAnalyticsService, + publishAttentionNotchSnapshot, + updateAttentionNotchSettings, + openAttentionItem, }: { getCtx: () => AppContext; getResourceUsageContexts?: () => AppContext[]; @@ -1585,6 +1603,9 @@ export function registerIpc({ globalStatePath: string; builtInBrowserService?: ReturnType | null; productAnalyticsService?: ProductAnalyticsService; + publishAttentionNotchSnapshot?: (snapshot: AttentionSnapshot) => void; + updateAttentionNotchSettings?: (settings: AttentionNotchSettings) => void; + openAttentionItem?: (item: AttentionItem) => Promise; }) { // Process-scoped by design: renderer reloads and additional windows in the // same app launch do not repeat the account choice, while a full ADE relaunch @@ -1796,6 +1817,7 @@ export function registerIpc({ [IPC.accountCancelLogin]: new Set(["sessionId"]), [IPC.accountPairMachine]: new Set(["machineKey"]), [IPC.accountRemoveMachine]: new Set(["machineKey"]), + [IPC.attentionNotchPublishSnapshot]: new Set(["items"]), }; const redactIpcArgsForChannel = (channel: string, args: unknown[]): unknown[] => { @@ -3140,6 +3162,134 @@ export function registerIpc({ return { ok: true } as const; }); + ipcMain.handle(IPC.attentionNotchPublishSnapshot, async (_event, input: unknown) => { + const snapshot = parseAttentionNotchSnapshot(input); + if (!snapshot) throw new Error("Invalid Attention Notch snapshot."); + publishAttentionNotchSnapshot?.(snapshot); + }); + + ipcMain.handle(IPC.attentionNotchUpdateSettings, async (_event, input: unknown) => { + const settings = parseAttentionNotchSettings(input); + if (!settings) throw new Error("Invalid Attention Notch settings."); + updateAttentionNotchSettings?.(settings); + }); + + ipcMain.handle(IPC.attentionGetSnapshot, async (_event, input: unknown) => { + const record = isRecord(input) ? input : {}; + const since = Number.isFinite(Number(record.since)) + ? Math.max(0, Math.trunc(Number(record.since))) + : 0; + const streamId = + typeof record.streamId === "string" && record.streamId.trim() + ? record.streamId.trim() + : null; + if (!localRuntimeConnectionPool) { + return { + contractVersion: ATTENTION_CONTRACT_VERSION, + streamId: null, + revision: 0, + generatedAt: new Date().toISOString(), + items: [], + tombstones: [], + } satisfies AttentionSnapshot; + } + return await localRuntimeConnectionPool.callAttention( + "getSnapshot", + { since, streamId }, + ); + }); + + ipcMain.handle(IPC.attentionAcknowledge, async (_event, input: unknown) => { + if (!localRuntimeConnectionPool) { + throw new Error("Account Attention is unavailable until the ADE brain is ready."); + } + const record = isRecord(input) ? input : {}; + const itemIds = Array.isArray(record.itemIds) + ? record.itemIds + .filter((value): value is string => + typeof value === "string" && value.trim().length > 0) + .map((value) => value.trim()) + .slice(0, 64) + : []; + if (itemIds.length === 0) { + throw new Error("At least one Attention item id is required."); + } + await localRuntimeConnectionPool.callAttention("acknowledge", { + itemIds, + ...(typeof record.seenAt === "string" ? { seenAt: record.seenAt } : {}), + ...(record.dismissedAt === null || typeof record.dismissedAt === "string" + ? { dismissedAt: record.dismissedAt } + : {}), + }); + }); + + ipcMain.handle(IPC.attentionReportPresence, async (_event, input: unknown) => { + if (!localRuntimeConnectionPool) return; + const presence = isRecord(input) ? input : null; + if (!presence || typeof presence.deviceId !== "string" || !presence.deviceId.trim()) { + throw new Error("A valid Attention presence payload is required."); + } + await localRuntimeConnectionPool.callAttention( + "reportPresence", + presence as AttentionPresence & Record, + ); + }); + + ipcMain.handle(IPC.attentionGetPreferences, async (_event, input: unknown) => { + if (!localRuntimeConnectionPool) return DEFAULT_ATTENTION_PREFERENCES; + const record = isRecord(input) ? input : {}; + const accountOwnerId = + typeof record.accountOwnerId === "string" ? record.accountOwnerId.trim() : ""; + if (!accountOwnerId) { + throw new Error("A valid Attention account owner is required."); + } + return await localRuntimeConnectionPool.callAttention( + "getPreferences", + { accountOwnerId }, + ); + }); + + ipcMain.handle(IPC.attentionPutPreferences, async (_event, input: unknown) => { + if (!localRuntimeConnectionPool) { + throw new Error("Account Attention is unavailable until the ADE brain is ready."); + } + if (!isRecord(input) || !isRecord(input.preferences)) { + throw new Error("A valid Attention preferences payload is required."); + } + const accountOwnerId = + typeof input.accountOwnerId === "string" ? input.accountOwnerId.trim() : ""; + if (!accountOwnerId) { + throw new Error("A valid Attention account owner is required."); + } + await localRuntimeConnectionPool.callAttention( + "putPreferences", + { + accountOwnerId, + preferences: input.preferences, + }, + ); + }); + + ipcMain.handle(IPC.attentionOpenItem, async (_event, input: unknown) => { + const snapshot = parseAttentionNotchSnapshot({ + contractVersion: 1, + streamId: null, + revision: ( + typeof input === "object" + && input !== null + && Number.isSafeInteger((input as { revision?: unknown }).revision) + ) + ? Number((input as { revision: number }).revision) + : 0, + generatedAt: new Date().toISOString(), + items: [input], + tombstones: [], + }); + const item = snapshot?.items[0] ?? null; + if (!item) throw new Error("Invalid Attention item."); + await openAttentionItem?.(item); + }); + ipcMain.handle( IPC.analyticsCapture, async ( @@ -10304,4 +10454,25 @@ export function registerIpc({ getCtx().autoUpdateService?.dismissInstalledNotice(); }); + return { + getLocalMachineIdentity: runtimeBridge.getLocalMachineIdentity, + resolveTargetIdForMachineKey: runtimeBridge.resolveTargetIdForMachineKey, + async openAttentionProject(args: { + machineKey: string; + projectId: string; + windowId: number | null; + }) { + const machineKey = args.machineKey.trim(); + if (!machineKey) throw new Error("Attention machine identity is required."); + let targetId = runtimeBridge.resolveTargetIdForMachineKey(machineKey); + if (!targetId) { + targetId = (await accountBridge.pairMachine(machineKey)).targetId; + } + return await runtimeBridge.openRemoteProjectForWindow({ + targetId, + projectId: args.projectId, + windowId: args.windowId, + }); + }, + }; } diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index cb27bcd58..4a64ed87a 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -1459,6 +1459,87 @@ describe("registerIpc sync bridge", () => { vi.useRealTimers(); }); + it("routes account Attention through the machine runtime without a project binding", async () => { + const snapshot = { + contractVersion: 1, + streamId: "account-stream", + revision: 5, + generatedAt: "2026-07-28T12:00:00.000Z", + items: [], + tombstones: [], + }; + const callAttention = vi.fn(async (action: string) => { + if (action === "getSnapshot") return snapshot; + if (action === "getPreferences") return { account: { hideDetails: true } }; + return undefined; + }); + registerIpc({ + getCtx: () => ({ + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, + }) as any, + localRuntimeConnectionPool: { callAttention } as any, + getWindowSession: () => ({ + windowId: 7, + project: null, + binding: { + kind: "remote", + key: "remote:other-machine:project", + targetId: "other-machine", + runtimeName: "Other machine", + projectId: "remote-project", + rootPath: "/srv/remote", + displayName: "Remote", + }, + }), + switchProjectFromDialog: vi.fn(), + closeCurrentProject: vi.fn(), + closeProjectByPath: vi.fn(), + globalStatePath: "/tmp/ade-state.json", + }); + + await expect( + ipcHandlers.get(IPC.attentionGetSnapshot)?.(eventForSender(), { + since: 4, + streamId: "account-stream", + }), + ).resolves.toEqual(snapshot); + await ipcHandlers.get(IPC.attentionAcknowledge)?.(eventForSender(), { + itemIds: ["attention-1"], + seenAt: "2026-07-28T12:01:00.000Z", + }); + await ipcHandlers.get(IPC.attentionReportPresence)?.(eventForSender(), { + deviceId: "desktop-1", + platform: "macOS", + }); + await expect( + ipcHandlers.get(IPC.attentionGetPreferences)?.(eventForSender(), { + accountOwnerId: "account-a", + }), + ).resolves.toEqual({ account: { hideDetails: true } }); + await ipcHandlers.get(IPC.attentionPutPreferences)?.( + eventForSender(), + { + accountOwnerId: "account-a", + preferences: { account: { hideDetails: false } }, + }, + ); + + expect(callAttention.mock.calls.map(([action]) => action)).toEqual([ + "getSnapshot", + "acknowledge", + "reportPresence", + "getPreferences", + "putPreferences", + ]); + expect(callAttention).toHaveBeenNthCalledWith(4, "getPreferences", { + accountOwnerId: "account-a", + }); + expect(callAttention).toHaveBeenNthCalledWith(5, "putPreferences", { + accountOwnerId: "account-a", + preferences: { account: { hideDetails: false } }, + }); + }); + it("validates recovery identifiers and target ownership before mutating chat state", async () => { const assertRecoveryTargetOwned = vi.fn(async (args: { sessionId: string; diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts index 2c28d3459..112bd8894 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts @@ -300,6 +300,12 @@ export type RuntimeBridgeRegistration = { currentOwnerUserId: string | null, ): AccountMachineReconciliationResult; getLocalMachineIdentity(): AdeAccountLocalMachineIdentity; + resolveTargetIdForMachineKey(machineKey: string): string | null; + openRemoteProjectForWindow(args: { + targetId: string; + projectId: string; + windowId: number | null; + }): Promise; }; export function getOrCreateLocalAccountMachineIdentity(args: { @@ -436,8 +442,59 @@ export function registerRuntimeBridge({ remoteConnectionService.reconcileAccountOwnership(currentOwnerUserId), getLocalMachineIdentity: () => getLocalMachineIdentity?.() ?? getOrCreateLocalAccountMachineIdentity(), + resolveTargetIdForMachineKey: (machineKey) => { + const normalized = machineKey.trim(); + if (!normalized) return null; + return remoteConnectionService.listTargets().find( + (target) => target.pairedMachine?.machineKey?.trim() === normalized, + )?.id ?? null; + }, + openRemoteProjectForWindow: async ({ targetId, projectId, windowId }) => { + const binding = await resolveRemoteProjectBinding(targetId, projectId); + bindRemoteProject?.(windowId, binding); + return binding; + }, }; + async function resolveRemoteProjectBinding( + rawTargetId: string, + rawProjectId: string, + ): Promise { + const targetId = rawTargetId.trim(); + const projectId = rawProjectId.trim(); + const target = targetId ? remoteConnectionService.getTarget(targetId) : null; + if (!target) throw new Error("Remote target was not found."); + if (!projectId) throw new Error("Remote project is required."); + + const connection = await remoteConnectionService.connect(target.id, { + explicit: true, + }); + let project = + connection.projects.find( + (candidate) => candidate.projectId === projectId, + ) ?? null; + if (!project) { + const projects = await remoteConnectionService.projects(target.id); + project = + projects.find((candidate) => candidate.projectId === projectId) ?? + null; + } + if (!project) throw new Error("Remote project was not found on this runtime."); + + return { + kind: "remote", + key: remoteProjectBindingKey(target.id, project.projectId), + targetId: target.id, + runtimeName: target.name, + hostname: target.hostname, + projectId: project.projectId, + rootPath: project.rootPath, + displayName: project.displayName || path.basename(project.rootPath), + gitOriginUrl: project.gitOriginUrl, + iconDataUrl: project.icon?.dataUrl ?? null, + }; + } + const cleanupRuntimeEventSubscription = (senderId: number): void => { const existing = runtimeEventSubscriptions.get(senderId); runtimeEventSubscriptions.delete(senderId); @@ -931,38 +988,7 @@ export function registerRuntimeBridge({ const projectId = typeof arg?.projectId === "string" ? arg.projectId.trim() : ""; try { - const target = id ? remoteConnectionService.getTarget(id) : null; - if (!target) throw new Error("Remote target was not found."); - if (!projectId) throw new Error("Remote project is required."); - - const connection = await remoteConnectionService.connect(target.id, { - explicit: true, - }); - let project = - connection.projects.find( - (candidate) => candidate.projectId === projectId, - ) ?? null; - if (!project) { - const projects = await remoteConnectionService.projects(target.id); - project = - projects.find((candidate) => candidate.projectId === projectId) ?? - null; - } - if (!project) - throw new Error("Remote project was not found on this runtime."); - - const binding: OpenProjectBinding & { kind: "remote" } = { - kind: "remote", - key: remoteProjectBindingKey(target.id, project.projectId), - targetId: target.id, - runtimeName: target.name, - hostname: target.hostname, - projectId: project.projectId, - rootPath: project.rootPath, - displayName: project.displayName || path.basename(project.rootPath), - gitOriginUrl: project.gitOriginUrl, - iconDataUrl: project.icon?.dataUrl ?? null, - }; + const binding = await resolveRemoteProjectBinding(id, projectId); if ( isLatestOpenRequest() && canBindRemoteProjectToSender(windowId, event.sender) diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index 3e15978e9..d44c4d684 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -3181,6 +3181,30 @@ describe("local runtime connection pool", () => { }); }); + it("routes Attention through the machine scope without adding a project id", async () => { + const call = vi.fn().mockResolvedValue({ revision: 4, items: [] }); + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as never); + (pool as unknown as { connection: Promise }).connection = Promise.resolve({ + client: { call, isClosed: () => false }, + child: null, + socketPath: "/tmp/ade.sock", + }); + + await expect(pool.callAttention("getSnapshot", { + since: 3, + streamId: "account-stream", + })).resolves.toEqual({ revision: 4, items: [] }); + expect(call).toHaveBeenCalledWith("attention.call", { + action: "getSnapshot", + args: { since: 3, streamId: "account-stream" }, + }); + }); + it("keeps foreground catalog metadata authoritative while routing background actions", async () => { const rootPath = path.resolve("/repo"); const project = { diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index 27ddea422..d2c928780 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -1401,6 +1401,13 @@ export class LocalRuntimeConnectionPool { return await entry.client.call(method, params) as T; } + async callAttention( + action: string, + args: Record = {}, + ): Promise { + return await this.callSync("attention.call", { action, args }); + } + async callActionForRoot( rootPath: string, request: RemoteRuntimeActionRequest, diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index a594e3acf..6057416de 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -1169,6 +1169,43 @@ declare global { actions: { listRegistry: () => Promise; }; + attention: { + getSnapshot: ( + since?: number, + streamId?: string | null, + ) => Promise; + acknowledge: (args: { + itemIds: string[]; + seenAt?: string; + dismissedAt?: string | null; + }) => Promise; + reportPresence: ( + presence: import("../shared/types").AttentionPresence, + ) => Promise; + getPreferences: (accountOwnerId: string) => Promise< + import("../shared/types").AttentionPreferences + >; + putPreferences: ( + accountOwnerId: string, + preferences: import("../shared/types").AttentionPreferences, + ) => Promise; + openItem: ( + item: import("../shared/types").AttentionItem, + ) => Promise; + }; + attentionNotch: { + publishSnapshot: ( + snapshot: import("../shared/types").AttentionSnapshot, + ) => Promise; + updateSettings: ( + settings: import("../shared/types").AttentionNotchSettings, + ) => Promise; + onAcknowledgeRequested: ( + cb: ( + request: import("../shared/types").AttentionNotchAcknowledgeRequest, + ) => void, + ) => () => void; + }; usage: { getAdeStats: (args?: GetAdeUsageStatsArgs) => Promise; getSnapshot: () => Promise; diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 66cf9e357..d9b998a56 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -1,6 +1,112 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { IPC } from "../shared/ipc"; +describe("preload Attention Notch bridge", () => { + beforeEach(() => { + vi.resetModules(); + delete (globalThis as any).__adeBridge; + }); + + afterEach(() => { + vi.resetModules(); + vi.doUnmock("electron"); + delete (globalThis as any).__adeBridge; + }); + + it("publishes typed helper state and cleans up acknowledgement listeners", async () => { + const invoke = vi.fn(async () => undefined); + const on = vi.fn(); + const removeListener = vi.fn(); + vi.doMock("electron", () => ({ + contextBridge: { + exposeInMainWorld: vi.fn((_name: string, value: unknown) => { + (globalThis as any).__adeBridge = value; + }), + }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + const bridge = (globalThis as any).__adeBridge; + const snapshot = { + contractVersion: 1, + revision: 1, + generatedAt: "2026-07-28T12:00:00.000Z", + items: [], + tombstones: [], + }; + const settings = { + enabled: true, + preferredDisplayId: null, + hideDetails: false, + celebrationsEnabled: true, + soundsEnabled: false, + }; + await bridge.attentionNotch.publishSnapshot(snapshot); + await bridge.attentionNotch.updateSettings(settings); + const item = { id: "agent-1" }; + await bridge.attention.getSnapshot(7, "account-stream"); + await bridge.attention.acknowledge({ + itemIds: ["agent-1"], + seenAt: "2026-07-28T12:01:00.000Z", + }); + const presence = { deviceId: "desktop-1", platform: "macOS" }; + await bridge.attention.reportPresence(presence); + await bridge.attention.getPreferences("account-a"); + const preferences = { account: { hideDetails: true } }; + await bridge.attention.putPreferences("account-a", preferences); + await bridge.attention.openItem(item); + + expect(invoke).toHaveBeenCalledWith(IPC.attentionNotchPublishSnapshot, snapshot); + expect(invoke).toHaveBeenCalledWith(IPC.attentionNotchUpdateSettings, settings); + expect(invoke).toHaveBeenCalledWith(IPC.attentionGetSnapshot, { + since: 7, + streamId: "account-stream", + }); + expect(invoke).toHaveBeenCalledWith(IPC.attentionAcknowledge, { + itemIds: ["agent-1"], + seenAt: "2026-07-28T12:01:00.000Z", + }); + expect(invoke).toHaveBeenCalledWith(IPC.attentionReportPresence, presence); + expect(invoke).toHaveBeenCalledWith(IPC.attentionGetPreferences, { + accountOwnerId: "account-a", + }); + expect(invoke).toHaveBeenCalledWith(IPC.attentionPutPreferences, { + accountOwnerId: "account-a", + preferences, + }); + expect(invoke).toHaveBeenCalledWith(IPC.attentionOpenItem, item); + expect(invoke).not.toHaveBeenCalledWith( + IPC.remoteRuntimeCallAction, + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + IPC.localRuntimeCallAction, + expect.anything(), + ); + + const callback = vi.fn(); + const unsubscribe = bridge.attentionNotch.onAcknowledgeRequested(callback); + expect(on).toHaveBeenCalledWith( + IPC.attentionNotchAcknowledgeRequested, + expect.any(Function), + ); + const listener = on.mock.calls.at(-1)?.[1]; + listener({}, { itemId: "agent-1", mode: "seen" }); + expect(callback).toHaveBeenCalledWith({ itemId: "agent-1", mode: "seen" }); + unsubscribe(); + expect(removeListener).toHaveBeenCalledWith( + IPC.attentionNotchAcknowledgeRequested, + listener, + ); + }); +}); + describe("preload OAuth bridge", () => { beforeEach(() => { vi.resetModules(); diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 174c495e7..224f396de 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2,6 +2,14 @@ import { contextBridge, ipcRenderer, webFrame, webUtils } from "electron"; import { IPC } from "../shared/ipc"; import { isSyncServiceUnavailableError } from "../shared/runtimeErrors"; import { EXTERNAL_FILES_WORKSPACE_ID_PREFIX } from "../shared/types/files"; +import { + type AttentionItem, + type AttentionNotchAcknowledgeRequest, + type AttentionNotchSettings, + type AttentionPreferences, + type AttentionPresence, + type AttentionSnapshot, +} from "../shared/types/attention"; import { deriveSmartLinkPreview, type SmartLinkPreview } from "../shared/smartLinks"; import { sessionLifecycleApplied } from "../shared/sessionLifecycleResult"; import { createOrchestrationBridge } from "./orchestrationBridge"; @@ -4706,6 +4714,54 @@ contextBridge.exposeInMainWorld("ade", { return ipcRenderer.invoke(IPC.adeActionsListRegistry); }, }, + attention: { + getSnapshot: async ( + since = 0, + streamId?: string | null, + ): Promise => + ipcRenderer.invoke(IPC.attentionGetSnapshot, { + since, + streamId: streamId?.trim() || null, + }), + acknowledge: async (args: { + itemIds: string[]; + seenAt?: string; + dismissedAt?: string | null; + }): Promise => + ipcRenderer.invoke(IPC.attentionAcknowledge, args), + reportPresence: async (presence: AttentionPresence): Promise => + ipcRenderer.invoke(IPC.attentionReportPresence, presence), + getPreferences: async (accountOwnerId: string): Promise => + ipcRenderer.invoke(IPC.attentionGetPreferences, { accountOwnerId }), + putPreferences: async ( + accountOwnerId: string, + preferences: AttentionPreferences, + ): Promise => + ipcRenderer.invoke(IPC.attentionPutPreferences, { + accountOwnerId, + preferences, + }), + openItem: async (item: AttentionItem): Promise => { + await ipcRenderer.invoke(IPC.attentionOpenItem, item); + }, + }, + attentionNotch: { + publishSnapshot: async (snapshot: AttentionSnapshot): Promise => + ipcRenderer.invoke(IPC.attentionNotchPublishSnapshot, snapshot), + updateSettings: async (settings: AttentionNotchSettings): Promise => + ipcRenderer.invoke(IPC.attentionNotchUpdateSettings, settings), + onAcknowledgeRequested: ( + cb: (request: AttentionNotchAcknowledgeRequest) => void, + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + request: AttentionNotchAcknowledgeRequest, + ) => cb(request); + ipcRenderer.on(IPC.attentionNotchAcknowledgeRequested, listener); + return () => + ipcRenderer.removeListener(IPC.attentionNotchAcknowledgeRequested, listener); + }, + }, usage: { getAdeStats: async (args: GetAdeUsageStatsArgs = {}): Promise => { const normalizedArgs: GetAdeUsageStatsArgs = { ...args, scope: args.scope ?? "machine" }; diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index bb607f60d..dcb853ac2 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -92,6 +92,9 @@ const WorkspaceGraphPage = React.lazy(() => const PersonalChatsPage = React.lazy(() => import("../personalChats/PersonalChatsPage").then((m) => ({ default: m.PersonalChatsPage })) ); +const AttentionCenter = React.lazy(() => + import("../attention/AttentionCenter").then((m) => ({ default: m.AttentionCenter })) +); const AccountPage = React.lazy(() => import("../account/AccountPage").then((m) => ({ default: m.AccountPage })) ); @@ -715,6 +718,8 @@ function ProjectTabHost() { const lruRef = React.useRef([]); const [routesBySurfaceKey, setRoutesBySurfaceKey] = React.useState>({}); const isPersonalChatsRoute = location.pathname === "/chats" || location.pathname.startsWith("/chats/"); + const isAttentionRoute = + location.pathname === "/attention" || location.pathname.startsWith("/attention/"); const isAccountRoute = location.pathname === "/account" || location.pathname.startsWith("/account/"); const isExternalFilesRoute = location.pathname === "/files" && new URLSearchParams(location.search).has("externalPath"); const activeBinding = !showWelcome && activeProject?.rootPath @@ -762,7 +767,7 @@ function ProjectTabHost() { // Machine-level routes (personal chats, account) are not project surfaces; // the route-restore below would otherwise clobber them with the active // project's stored route on load. - if (isPersonalChatsRoute || isAccountRoute) return; + if (isPersonalChatsRoute || isAttentionRoute || isAccountRoute) return; const previousSurfaceKey = previousActiveSurfaceKeyRef.current; if (previousSurfaceKey === activeSurfaceKey) return; const currentRoute = serializeStoredProjectRoute(location); @@ -785,7 +790,7 @@ function ProjectTabHost() { if (currentRoute !== nextRoute) { navigate(nextRoute, { replace: true }); } - }, [activeSurfaceKey, isAccountRoute, isPersonalChatsRoute, location, navigate, routesBySurfaceKey]); + }, [activeSurfaceKey, isAccountRoute, isAttentionRoute, isPersonalChatsRoute, location, navigate, routesBySurfaceKey]); React.useEffect(() => { if (!activeSurfaceKey) return; @@ -934,11 +939,11 @@ function ProjectTabHost() { ); } - if (!projectHydrated && !activeProject) { + if (!isAttentionRoute && !projectHydrated && !activeProject) { return GuardLoadingFallback; } - if (!isPersonalChatsRoute && !isAccountRoute && (!activeProject || showWelcome || mountedProjects.length === 0)) { + if (!isPersonalChatsRoute && !isAttentionRoute && !isAccountRoute && (!activeProject || showWelcome || mountedProjects.length === 0)) { return ( @@ -974,7 +979,7 @@ function ProjectTabHost() { return ( ) : null} + {isAttentionRoute ? ( + + + window.ade.attention.openItem(item)} + /> + + + ) : null} {isAccountRoute ? ( diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 5e61329b6..40584a3cd 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -82,6 +82,7 @@ import { WebAnalyticsConsentBanner, } from "../analytics/ProductAnalyticsLifecycle"; import { useAppWideSessionAttention } from "../../hooks/useAppWideSessionAttention"; +import { useAttentionSync } from "../attention/useAttentionSync"; type PrToast = { id: string; @@ -96,11 +97,12 @@ type AutoLinkToast = { }; function primaryTabPath(pathname: string): string { - const roots = ["/lanes", "/files", "/work", "/graph", "/prs", "/history", "/automations", "/cto", "/settings"]; + const roots = ["/attention", "/lanes", "/files", "/work", "/graph", "/prs", "/history", "/automations", "/cto", "/settings"]; return roots.find((root) => pathname === root || pathname.startsWith(`${root}/`)) ?? pathname; } const PRODUCT_ANALYTICS_ROUTE_ROOTS = [ + "/attention", "/lanes", "/files", "/work", @@ -361,6 +363,8 @@ export function AppShell({ children }: { children: React.ReactNode }) { const isOnboardingRoute = location.pathname === "/onboarding"; const isPersonalChatsRoute = location.pathname === "/chats" || location.pathname.startsWith("/chats/"); + const isAttentionRoute = + location.pathname === "/attention" || location.pathname.startsWith("/attention/"); const isAccountRoute = location.pathname === "/account" || location.pathname.startsWith("/account/"); const isLanesRoute = location.pathname.startsWith("/lanes"); @@ -373,6 +377,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { const isWorkAdjacentRoute = isWorkRoute || isLanesRoute; const isLanesRouteRef = useRef(isLanesRoute); useAppWideSessionAttention(); + useAttentionSync(isAttentionRoute); useEffect(() => { isLanesRouteRef.current = isLanesRoute; @@ -1113,6 +1118,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { const tintClass = useMemo(() => { const tintMap: Record = { + "/attention": "tab-tint-work", "/lanes": "tab-tint-lanes", "/files": "tab-tint-files", "/work": "tab-tint-work", diff --git a/apps/desktop/src/renderer/components/app/TabNav.tsx b/apps/desktop/src/renderer/components/app/TabNav.tsx index a4715dbfa..4df2d661b 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.tsx @@ -8,6 +8,7 @@ import { GitPullRequest, MagnifyingGlass, ClockCounterClockwise, + BellRinging, Robot, Brain, ChatCircleDots, @@ -30,8 +31,13 @@ import { docs } from "../../onboarding/docsLinks"; import { SmartTooltip, type SmartTooltipContent } from "../ui/SmartTooltip"; import type { GitHubStatus } from "../../../shared/types"; import { readStoredPrsRoute } from "../prs/prsRouteState"; +import { + selectAttentionUnseenCount, + useAttentionStore, +} from "../../state/attentionStore"; const mainItems = [ + { to: "/attention", label: "Attention", icon: BellRinging }, { to: "/work", label: "Work", icon: Terminal }, { to: "/lanes", label: "Lanes", icon: GitBranch }, { to: "/files", label: "Files", icon: FileCode }, @@ -47,6 +53,9 @@ const settingsItem = { to: "/settings", label: "Settings", icon: GearSix } as co const SIDEBAR_ICON_SIZE = 20; const SIDEBAR_AVATAR_SIZE_CLASS = "h-5 w-5"; const TAB_TOOLTIP_BY_PATH: Record> = { + "/attention": { + description: "See agents and pull requests that are live, need you, or recently finished across every machine and project.", + }, "/work": { description: "Chat with agents, launch CLI sessions, inspect shells, and use the right-side tool drawers.", docUrl: docs.chatOverview, @@ -101,6 +110,7 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) const projectBinding = useAppStore((s) => s.projectBinding); const showWelcome = useAppStore((s) => s.showWelcome); const terminalAttention = useAppStore((s) => s.terminalAttention); + const unseenAttentionCount = useAttentionStore(selectAttentionUnseenCount); const location = useLocation(); const { status: accountStatus } = useAccountStatus(); const activeProjectRoot = @@ -144,9 +154,10 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) const renderItem = ( it: { to: string; label: string; icon: React.ElementType }, ) => { - const onWelcomeLanding = showWelcome || !hasActiveProject; + const globallyAvailable = it.to === "/attention"; + const onWelcomeLanding = !globallyAvailable && (showWelcome || !hasActiveProject); const isActive = !onWelcomeLanding && primaryTabPath(location.pathname) === it.to; - const isActiveAllowed = !showWelcome && hasActiveProject; + const isActiveAllowed = globallyAvailable || (!showWelcome && hasActiveProject); const navTarget = it.to === "/prs" ? readStoredPrsRoute(activeProjectRoot) ?? it.to : it.to; const tooltipBase = TAB_TOOLTIP_BY_PATH[it.to]; const tooltip: SmartTooltipContent = { @@ -250,6 +261,13 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) : "ade-status-dot-active", )} /> + ) : it.to === "/attention" && unseenAttentionCount > 0 ? ( + + {Math.min(99, unseenAttentionCount)} + ) : null} @@ -280,7 +298,7 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) > {/* Core navigation items */}
- {mainItems.slice(0, 4).map((it) => renderItem(it))} + {mainItems.slice(0, 5).map((it) => renderItem(it))}
{!webMode ? ( @@ -290,7 +308,7 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) {/* Tool navigation items */}
- {mainItems.slice(4).map((it) => renderItem(it))} + {mainItems.slice(5).map((it) => renderItem(it))}
) : null} diff --git a/apps/desktop/src/renderer/components/attention/AttentionCenter.css b/apps/desktop/src/renderer/components/attention/AttentionCenter.css new file mode 100644 index 000000000..cae103b6a --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/AttentionCenter.css @@ -0,0 +1,1724 @@ +/* Attention is a monitoring surface, not a dense debug console. Everything here + hangs off one type scale and one tone system so a new rule can't quietly + reintroduce 7px labels or a dark-only accent. Mono is reserved for counts and + timestamps, where fixed-width rhythm actually helps scanning. */ + +.attention-center { + position: relative; + display: flex; + height: 100%; + min-height: 0; + min-width: 0; + flex-direction: column; + overflow: hidden; + color: var(--color-fg); + background: + radial-gradient(circle at 20% -20%, color-mix(in srgb, var(--color-accent) 12%, transparent), transparent 35%), + linear-gradient(180deg, color-mix(in srgb, var(--color-bg) 92%, var(--color-card)), var(--color-bg)); + isolation: isolate; + + /* Type scale. Nothing in this file sets a raw font-size. */ + --attn-fs-2xs: 10px; /* counts, badges */ + --attn-fs-xs: 11px; /* meta, timestamps, eyebrows */ + --attn-fs-sm: 12px; /* labels, item titles, controls */ + --attn-fs-md: 13px; /* body copy, previews, descriptions */ + --attn-fs-lg: 15px; /* empty-state and placeholder headings */ + --attn-fs-xl: 18px; /* page title */ + + /* Rhythm */ + --attn-gutter: clamp(16px, 2.2vw, 28px); + --attn-radius-panel: 14px; + --attn-radius-card: 11px; + --attn-radius-control: 9px; + + /* Surfaces */ + --attention-surface: color-mix(in srgb, var(--color-card) 76%, transparent); + --attention-surface-raised: color-mix(in srgb, var(--color-card) 91%, transparent); + --attention-hairline: color-mix(in srgb, var(--color-border) 68%, transparent); + --attention-copy-dim: color-mix(in srgb, var(--color-muted-fg) 88%, transparent); + --attn-sticky-bg: color-mix(in srgb, var(--color-card) 88%, var(--color-bg)); + --attn-sheen: rgba(255, 255, 255, 0.055); + --attn-shadow-popover: 0 30px 80px -32px rgba(0, 0, 0, 0.86); + --attn-shadow-menu: 0 22px 55px -25px rgba(0, 0, 0, 0.75); + + /* Tones. --tone-color is the readable ink and is re-declared per tone class. + Anything derived from it must be mixed in the rule that consumes it: a + derived token declared here would compute once against this neutral + default and inherit that same value into every tone. --tone-on is a plain + literal, so it is safe to hold here. */ + --tone-color: #a1a1aa; + --tone-on: #0e1111; + --attn-warn: #fbbf24; + --attn-danger: #f87171; + --attn-ok: #34d399; + --attn-idle: #71717a; +} + +.attention-tone-amber { --tone-color: #fbbf24; } +.attention-tone-red { --tone-color: #f87171; } +.attention-tone-violet { --tone-color: #a78bfa; } +.attention-tone-blue { --tone-color: #60a5fa; } +.attention-tone-cyan { --tone-color: #22d3ee; } +.attention-tone-emerald { --tone-color: #34d399; } +.attention-tone-neutral { --tone-color: #a1a1aa; } + +/* The 400-level tones above sit at ~1.7:1 on a white card. Light theme gets + 600/700-level equivalents so phase pills, the Deny action and status dots + stay readable instead of washing out. */ +[data-theme="light"] .attention-center { + --tone-on: #ffffff; + --attn-sheen: rgba(255, 255, 255, 0.6); + --attn-shadow-popover: 0 24px 60px -28px rgba(15, 23, 42, 0.28); + --attn-shadow-menu: 0 18px 44px -22px rgba(15, 23, 42, 0.22); + --attn-warn: #b45309; + --attn-danger: #b91c1c; + --attn-ok: #047857; + --attn-idle: #71717a; + --attn-sticky-bg: color-mix(in srgb, var(--color-card) 94%, var(--color-bg)); +} + +[data-theme="light"] .attention-tone-amber { --tone-color: #b45309; } +[data-theme="light"] .attention-tone-red { --tone-color: #dc2626; } +[data-theme="light"] .attention-tone-violet { --tone-color: #6d28d9; } +[data-theme="light"] .attention-tone-blue { --tone-color: #1d4ed8; } +[data-theme="light"] .attention-tone-cyan { --tone-color: #0e7490; } +[data-theme="light"] .attention-tone-emerald { --tone-color: #047857; } +[data-theme="light"] .attention-tone-neutral { --tone-color: #52525b; } + +.attention-ambient { + position: absolute; + z-index: -1; + width: 360px; + height: 360px; + border-radius: 999px; + opacity: 0.09; + filter: blur(90px); + pointer-events: none; +} + +.attention-ambient-one { + top: -220px; + left: 20%; + background: var(--color-accent); +} + +.attention-ambient-two { + right: -200px; + bottom: -220px; + background: color-mix(in srgb, var(--color-accent) 40%, #22d3ee); +} + +[data-theme="light"] .attention-ambient { + opacity: 0.05; +} + +/* ── Header ─────────────────────────────────────────────────────────── */ + +.attention-header { + position: relative; + z-index: 12; + display: flex; + min-height: 70px; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 12px 20px; + border-bottom: 1px solid var(--attention-hairline); + background: color-mix(in srgb, var(--color-bg) 78%, transparent); + backdrop-filter: blur(22px) saturate(1.2); +} + +.attention-title-lockup, +.attention-header-controls, +.attention-detail-breadcrumb, +.attention-detail-tools, +.attention-detail-kicker, +.attention-section-heading { + display: flex; + align-items: center; +} + +.attention-title-lockup { + min-width: 0; + gap: 11px; +} + +.attention-title-icon { + position: relative; + display: inline-flex; + width: 36px; + height: 36px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + color: var(--color-accent-bright, var(--color-accent)); + border: 1px solid color-mix(in srgb, var(--color-accent) 28%, transparent); + border-radius: 12px; + background: + linear-gradient(145deg, color-mix(in srgb, var(--color-accent) 18%, transparent), color-mix(in srgb, var(--color-card) 92%, transparent)); + box-shadow: inset 0 1px 0 var(--attn-sheen), 0 8px 22px -14px var(--color-accent); +} + +.attention-title-icon > span { + position: absolute; + top: -5px; + right: -6px; + display: inline-flex; + min-width: 18px; + height: 18px; + align-items: center; + justify-content: center; + padding: 0 4px; + color: #1c1500; + border: 2px solid var(--color-bg); + border-radius: 99px; + background: #fbbf24; + font-family: var(--font-mono); + font-size: var(--attn-fs-2xs); + font-weight: 750; + line-height: 1; +} + +.attention-title-lockup h1 { + margin: 0; + font-size: var(--attn-fs-xl); + font-weight: 680; + letter-spacing: -0.025em; +} + +.attention-title-lockup p { + margin: 2px 0 0; + overflow: hidden; + color: var(--attention-copy-dim); + font-size: var(--attn-fs-xs); + text-overflow: ellipsis; + white-space: nowrap; +} + +.attention-header-controls { + position: relative; + gap: 9px; +} + +.attention-freshness { + display: inline-flex; + align-items: center; + gap: 5px; + color: color-mix(in srgb, var(--color-muted-fg) 82%, transparent); + font-size: var(--attn-fs-xs); + white-space: nowrap; +} + +.attention-freshness-error { + color: var(--attn-danger); +} + +/* ── Scope picker ───────────────────────────────────────────────────── */ + +.attention-scope-wrap { + position: relative; +} + +.attention-scope-button { + display: flex; + width: min(190px, 24vw); + height: 31px; + align-items: center; + gap: 7px; + padding: 0 9px; + color: var(--color-muted-fg); + border: 1px solid var(--attention-hairline); + border-radius: var(--attn-radius-control); + background: color-mix(in srgb, var(--color-card) 75%, transparent); + font-size: var(--attn-fs-sm); + font-weight: 590; + transition: color 140ms ease, border-color 140ms ease, background 140ms ease; +} + +.attention-scope-button:hover, +.attention-scope-button[data-scoped] { + color: var(--color-fg); + border-color: color-mix(in srgb, var(--color-accent) 34%, var(--color-border)); + background: color-mix(in srgb, var(--color-accent) 8%, var(--color-card)); +} + +.attention-scope-menu { + position: absolute; + top: calc(100% + 7px); + right: 0; + z-index: 50; + width: 275px; + max-height: min(500px, calc(100vh - 150px)); + overflow-y: auto; + padding: 6px; + border: 1px solid color-mix(in srgb, var(--color-border) 86%, transparent); + border-radius: 13px; + background: color-mix(in srgb, var(--color-card) 96%, var(--color-bg)); + box-shadow: var(--attn-shadow-menu), inset 0 1px 0 var(--attn-sheen); + backdrop-filter: blur(28px) saturate(1.25); +} + +.attention-scope-group { + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid var(--attention-hairline); +} + +.attention-scope-option { + display: flex; + width: 100%; + min-height: 38px; + align-items: center; + gap: 9px; + padding: 6px 8px; + color: var(--color-muted-fg); + border-radius: 8px; + text-align: left; + transition: color 120ms ease, background 120ms ease; +} + +.attention-scope-option:hover, +.attention-scope-option[aria-checked="true"] { + color: var(--color-fg); + background: color-mix(in srgb, var(--color-accent) 9%, transparent); +} + +.attention-scope-option strong, +.attention-scope-option small { + display: block; +} + +.attention-scope-option strong { + font-size: var(--attn-fs-sm); + font-weight: 640; +} + +.attention-scope-option small { + margin-top: 1px; + color: var(--color-muted-fg); + font-size: var(--attn-fs-xs); +} + +.attention-scope-option-icon { + display: inline-flex; + width: 23px; + height: 23px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 1px solid var(--attention-hairline); + border-radius: 7px; + background: color-mix(in srgb, var(--color-fg) 3%, transparent); +} + +.attention-scope-project { + min-height: 32px; + padding-left: 20px; + font-size: var(--attn-fs-sm); +} + +/* ── Toolbar ────────────────────────────────────────────────────────── */ + +.attention-toolbar { + position: relative; + z-index: 8; + display: flex; + min-height: 46px; + flex: 0 0 auto; + align-items: center; + gap: 12px; + padding: 7px 20px; + border-bottom: 1px solid var(--attention-hairline); + background: color-mix(in srgb, var(--color-bg) 68%, transparent); +} + +.attention-tabs { + display: inline-flex; + gap: 3px; + padding: 3px; + border: 1px solid var(--attention-hairline); + border-radius: 10px; + background: color-mix(in srgb, var(--color-bg) 75%, var(--color-card)); +} + +.attention-tab { + display: inline-flex; + height: 28px; + align-items: center; + gap: 6px; + padding: 0 10px; + color: color-mix(in srgb, var(--color-muted-fg) 88%, transparent); + border-radius: 7px; + font-size: var(--attn-fs-sm); + font-weight: 600; + transition: color 140ms ease, background 140ms ease, box-shadow 140ms ease; +} + +.attention-tab:hover { + color: var(--color-fg); +} + +.attention-tab[data-active] { + color: color-mix(in srgb, var(--color-accent) 35%, var(--color-fg)); + background: color-mix(in srgb, var(--color-accent) 12%, var(--color-card)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 17%, transparent), 0 4px 12px -10px var(--color-accent); +} + +.attention-tab-count { + display: inline-flex; + min-width: 17px; + height: 17px; + align-items: center; + justify-content: center; + padding: 0 4px; + color: var(--color-muted-fg); + border-radius: 5px; + background: color-mix(in srgb, var(--color-fg) 5%, transparent); + font-family: var(--font-mono); + font-size: var(--attn-fs-2xs); + line-height: 1; +} + +.attention-tab[data-active] .attention-tab-count { + color: var(--color-fg); + background: color-mix(in srgb, var(--color-accent) 14%, transparent); +} + +.attention-filter-chip, +.attention-toolbar-hint { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: var(--attn-fs-xs); +} + +.attention-filter-chip { + max-width: 190px; + height: 26px; + padding: 0 8px; + color: color-mix(in srgb, var(--color-accent) 44%, var(--color-fg)); + border: 1px solid color-mix(in srgb, var(--color-accent) 26%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--color-accent) 8%, transparent); +} + +.attention-filter-chip span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.attention-toolbar-hint { + margin-left: auto; + color: color-mix(in srgb, var(--color-muted-fg) 68%, transparent); +} + +/* ── Layout ─────────────────────────────────────────────────────────── */ + +.attention-layout { + position: relative; + display: grid; + min-height: 0; + flex: 1 1 auto; + grid-template-columns: clamp(300px, 34%, 430px) minmax(0, 1fr); + gap: 10px; + padding: 10px; +} + +.attention-roster-panel, +.attention-detail-panel { + min-height: 0; + min-width: 0; + overflow: hidden; + border: 1px solid var(--attention-hairline); + border-radius: var(--attn-radius-panel); + background: var(--attention-surface); + box-shadow: inset 0 1px 0 var(--attn-sheen); + backdrop-filter: blur(20px); +} + +.attention-roster-panel { + display: flex; + flex-direction: column; +} + +.attention-panel-heading { + display: flex; + min-height: 43px; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 12px; + border-bottom: 1px solid var(--attention-hairline); +} + +.attention-panel-heading > div { + display: flex; + min-width: 0; + align-items: baseline; + gap: 7px; +} + +.attention-panel-heading strong { + font-size: var(--attn-fs-sm); + font-weight: 650; + letter-spacing: -0.01em; + white-space: nowrap; +} + +.attention-panel-heading > div > span { + color: var(--color-muted-fg); + font-family: var(--font-mono); + font-size: var(--attn-fs-xs); + white-space: nowrap; +} + +.attention-panel-heading button { + flex: 0 0 auto; + padding: 4px 7px; + color: var(--attn-warn); + border-radius: 6px; + background: color-mix(in srgb, var(--attn-warn) 12%, transparent); + font-size: var(--attn-fs-xs); + font-weight: 600; + white-space: nowrap; + transition: background 130ms ease; +} + +.attention-panel-heading button:hover { + background: color-mix(in srgb, var(--attn-warn) 20%, transparent); +} + +.attention-roster-scroll { + min-height: 0; + flex: 1 1 auto; + overflow-y: auto; + padding: 7px; + scrollbar-gutter: stable; +} + +/* ── Roster grouping: machine → project → item ──────────────────────── */ + +.attention-machine-group + .attention-machine-group { + margin-top: 10px; +} + +/* Both group headings stick so the machine and project a row belongs to stay + on screen while scrolling a long roster. */ +.attention-machine-heading { + position: sticky; + top: -7px; + z-index: 3; + display: flex; + height: 38px; + align-items: center; + gap: 8px; + margin: 0 -7px; + padding: 0 13px; + border-bottom: 1px solid var(--attention-hairline); + background: var(--attn-sticky-bg); + backdrop-filter: blur(12px); +} + +.attention-machine-icon { + display: inline-flex; + width: 26px; + height: 26px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + color: color-mix(in srgb, var(--color-accent) 34%, var(--color-fg)); + border: 1px solid var(--attention-hairline); + border-radius: 8px; + background: color-mix(in srgb, var(--color-accent) 5%, transparent); +} + +.attention-machine-heading strong, +.attention-machine-heading small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.attention-machine-heading strong { + font-size: var(--attn-fs-sm); + font-weight: 650; +} + +.attention-machine-heading small { + margin-top: 1px; + color: var(--color-muted-fg); + font-size: var(--attn-fs-xs); +} + +.attention-online-dot { + width: 6px; + height: 6px; + flex: 0 0 auto; + border-radius: 99px; +} + +.attention-online-dot.is-online { + background: var(--attn-ok); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--attn-ok) 12%, transparent); +} + +.attention-online-dot.is-offline { + background: var(--attn-idle); +} + +.attention-machine-count { + display: inline-flex; + min-width: 19px; + height: 19px; + align-items: center; + justify-content: center; + padding: 0 5px; + color: var(--color-muted-fg); + border-radius: 6px; + background: color-mix(in srgb, var(--color-fg) 6%, transparent); + font-family: var(--font-mono); + font-size: var(--attn-fs-2xs); +} + +.attention-project-group { + margin-top: 2px; +} + +.attention-project-heading { + position: sticky; + top: 31px; + z-index: 2; + display: flex; + height: 28px; + align-items: center; + gap: 7px; + margin: 0 -7px; + padding: 0 14px 0 22px; + color: color-mix(in srgb, var(--color-muted-fg) 92%, transparent); + background: var(--attn-sticky-bg); + font-size: var(--attn-fs-xs); + font-weight: 600; + letter-spacing: 0.01em; +} + +.attention-project-heading > span:nth-child(2) { + flex: 1 1 auto; +} + +.attention-project-heading > span:last-child { + font-family: var(--font-mono); + font-size: var(--attn-fs-2xs); +} + +.attention-project-glyph { + display: inline-flex; + width: 18px; + height: 18px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + color: color-mix(in srgb, var(--color-accent) 45%, var(--color-fg)); + border: 1px solid color-mix(in srgb, var(--color-accent) 18%, var(--color-border)); + border-radius: 5px; + background: color-mix(in srgb, var(--color-accent) 6%, transparent); + font-family: var(--font-sans); + font-size: var(--attn-fs-2xs); + font-weight: 700; + line-height: 1; +} + +.attention-project-items { + display: flex; + flex-direction: column; + gap: 3px; + padding-top: 3px; +} + +/* ── Item row ───────────────────────────────────────────────────────── */ + +.attention-item-row { + position: relative; + display: flex; + width: 100%; + min-width: 0; + align-items: flex-start; + gap: 9px; + overflow: hidden; + padding: 10px 10px 9px; + color: var(--color-fg); + border: 1px solid transparent; + border-radius: 10px; + text-align: left; + transition: border-color 140ms ease, background 140ms ease, box-shadow 140ms ease; +} + +.attention-item-row:hover { + border-color: color-mix(in srgb, var(--tone-color) 18%, var(--color-border)); + background: color-mix(in srgb, var(--tone-color) 5%, transparent); +} + +.attention-item-row[data-selected] { + border-color: color-mix(in srgb, var(--tone-color) 28%, var(--color-border)); + background: + linear-gradient(100deg, color-mix(in srgb, var(--tone-color) 10%, transparent), color-mix(in srgb, var(--color-fg) 2%, transparent)); + box-shadow: 0 9px 26px -22px var(--tone-color), inset 0 1px 0 var(--attn-sheen); +} + +.attention-selected-rail { + position: absolute; + top: 8px; + bottom: 8px; + left: 0; + width: 2px; + border-radius: 0 99px 99px 0; + background: var(--tone-color); + box-shadow: 0 0 10px color-mix(in srgb, var(--tone-color) 55%, transparent); +} + +.attention-item-icon, +.attention-detail-provider { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + color: var(--tone-color); + border: 1px solid color-mix(in srgb, var(--tone-color) 22%, var(--color-border)); + background: color-mix(in srgb, var(--tone-color) 7%, var(--color-card)); +} + +.attention-item-icon { + width: 31px; + height: 31px; + border-radius: 9px; +} + +.attention-item-copy { + display: block; + min-width: 0; + flex: 1 1 auto; +} + +.attention-item-title-line { + display: flex; + min-width: 0; + align-items: baseline; + gap: 8px; +} + +/* Titles are frequently file paths and branch names, which have no break + opportunities. Without this they clip mid-word with no ellipsis. */ +.attention-item-title-line strong { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; + font-size: var(--attn-fs-sm); + font-weight: 640; + letter-spacing: -0.01em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.attention-item-title-line time { + flex: 0 0 auto; + color: color-mix(in srgb, var(--color-muted-fg) 78%, transparent); + font-family: var(--font-mono); + font-size: var(--attn-fs-2xs); +} + +.attention-item-preview { + display: -webkit-box; + overflow: hidden; + margin-top: 3px; + color: color-mix(in srgb, var(--color-muted-fg) 92%, transparent); + font-size: var(--attn-fs-md); + line-height: 1.4; + overflow-wrap: anywhere; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.attention-item-meta { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; + margin-top: 6px; + overflow: hidden; + color: color-mix(in srgb, var(--color-muted-fg) 78%, transparent); + font-size: var(--attn-fs-xs); + white-space: nowrap; +} + +.attention-item-meta > span:not(.attention-phase-pill) { + overflow: hidden; + text-overflow: ellipsis; +} + +.attention-item-meta > span:not(:last-child)::after { + margin-left: 6px; + color: color-mix(in srgb, var(--color-muted-fg) 35%, transparent); + content: "·"; +} + +.attention-phase-pill { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 5px; + color: color-mix(in srgb, var(--tone-color) 78%, var(--color-fg)); + font-size: var(--attn-fs-xs); + font-weight: 650; +} + +.attention-phase-dot { + width: 5px; + height: 5px; + flex: 0 0 auto; + border-radius: 99px; + background: var(--tone-color); + box-shadow: 0 0 8px color-mix(in srgb, var(--tone-color) 40%, transparent); +} + +.attention-phase-dot-active { + animation: attention-status-pulse 2.4s ease-in-out infinite; +} + +/* Unseen is a separate axis from phase, so it gets its own mark rather than a + second dot in the phase colour. */ +.attention-unseen-dot { + width: 7px; + height: 7px; + flex: 0 0 auto; + margin-top: 4px; + border: 2px solid color-mix(in srgb, var(--color-accent) 78%, transparent); + border-radius: 99px; + background: transparent; +} + +.attention-item-row[data-selected] .attention-unseen-dot, +.attention-item-row:hover .attention-unseen-dot { + background: color-mix(in srgb, var(--color-accent) 78%, transparent); +} + +/* ── Detail ─────────────────────────────────────────────────────────── */ + +.attention-detail-panel { + overflow-y: auto; +} + +.attention-detail-card { + position: relative; + display: flex; + min-height: 100%; + flex-direction: column; + overflow: hidden; + background: + radial-gradient(circle at 86% 0%, color-mix(in srgb, var(--tone-color) 8%, transparent), transparent 28%), + color-mix(in srgb, var(--color-card) 71%, transparent); +} + +.attention-detail-accent { + position: absolute; + top: 0; + right: 0; + left: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--tone-color) 18%, var(--tone-color) 82%, transparent); + opacity: 0.85; + box-shadow: 0 0 16px color-mix(in srgb, var(--tone-color) 35%, transparent); +} + +.attention-detail-header { + display: flex; + min-height: 43px; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 13px; + border-bottom: 1px solid var(--attention-hairline); +} + +.attention-detail-breadcrumb { + min-width: 0; + gap: 6px; + overflow: hidden; + color: color-mix(in srgb, var(--color-muted-fg) 88%, transparent); + font-size: var(--attn-fs-xs); + white-space: nowrap; +} + +.attention-breadcrumb-status { + display: inline-flex; + color: var(--attn-idle); +} + +.attention-breadcrumb-status.is-online { + color: var(--attn-ok); +} + +.attention-breadcrumb-separator { + color: color-mix(in srgb, var(--color-muted-fg) 40%, transparent); +} + +.attention-detail-tools { + flex: 0 0 auto; + gap: 4px; +} + +.attention-seen-label { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0 5px; + color: color-mix(in srgb, var(--color-muted-fg) 78%, transparent); + font-size: var(--attn-fs-xs); +} + +.attention-icon-button { + display: inline-flex; + width: 26px; + height: 26px; + align-items: center; + justify-content: center; + color: var(--color-muted-fg); + border: 1px solid transparent; + border-radius: 7px; + transition: color 120ms ease, border-color 120ms ease, background 120ms ease; +} + +.attention-icon-button:hover { + color: var(--color-fg); + border-color: var(--attention-hairline); + background: color-mix(in srgb, var(--color-fg) 5%, transparent); +} + +.attention-detail-hero { + display: flex; + gap: 13px; + padding: clamp(16px, 2.4vw, 24px) var(--attn-gutter) 16px; +} + +.attention-detail-provider { + width: 44px; + height: 44px; + border-radius: 13px; + box-shadow: 0 10px 28px -20px var(--tone-color), inset 0 1px 0 var(--attn-sheen); +} + +.attention-detail-kicker { + gap: 8px; +} + +.attention-detail-kicker time { + color: color-mix(in srgb, var(--color-muted-fg) 75%, transparent); + font-family: var(--font-mono); + font-size: var(--attn-fs-2xs); +} + +/* Sized for a path-shaped title: still clearly the hero, but it no longer + takes three lines and swamps the actions below it. */ +.attention-detail-hero h2 { + max-width: 62ch; + margin: 7px 0 0; + font-size: clamp(16px, 1.5vw, 20px); + font-weight: 660; + line-height: 1.28; + letter-spacing: -0.018em; + overflow-wrap: anywhere; +} + +.attention-detail-hero p { + max-width: 74ch; + margin: 7px 0 0; + color: color-mix(in srgb, var(--color-muted-fg) 92%, transparent); + font-size: var(--attn-fs-md); + line-height: 1.55; + overflow-wrap: anywhere; +} + +.attention-offline-banner, +.attention-ack-error { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 0 var(--attn-gutter) 14px; + padding: 9px 11px; + border-radius: 10px; + font-size: var(--attn-fs-md); + line-height: 1.45; +} + +.attention-offline-banner { + color: var(--attn-warn); + border: 1px solid color-mix(in srgb, var(--attn-warn) 26%, transparent); + background: color-mix(in srgb, var(--attn-warn) 8%, transparent); +} + +.attention-ack-error { + color: var(--attn-danger); + border: 1px solid color-mix(in srgb, var(--attn-danger) 28%, transparent); + background: color-mix(in srgb, var(--attn-danger) 8%, transparent); +} + +.attention-offline-banner svg, +.attention-ack-error svg { + flex: 0 0 auto; + margin-top: 1px; +} + +.attention-offline-banner strong, +.attention-ack-error strong { + display: block; + margin-bottom: 1px; +} + +.attention-detail-actions { + display: flex; + flex-wrap: wrap; + gap: 7px; + padding: 0 var(--attn-gutter) 18px; + border-bottom: 1px solid var(--attention-hairline); +} + +.attention-action { + display: inline-flex; + height: 31px; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 12px; + color: var(--color-muted-fg); + border: 1px solid var(--attention-hairline); + border-radius: 8px; + background: color-mix(in srgb, var(--color-fg) 3%, transparent); + font-size: var(--attn-fs-sm); + font-weight: 620; + transition: color 130ms ease, border-color 130ms ease, background 130ms ease, filter 130ms ease; +} + +/* Dark theme lightens the tone so near-black text sits on it; light theme uses + the tone at full strength under white text. */ +.attention-action[data-tone="primary"] { + color: var(--tone-on); + border-color: color-mix(in srgb, var(--tone-color) 70%, white); + background: color-mix(in srgb, var(--tone-color) 82%, white); +} + +[data-theme="light"] .attention-action[data-tone="primary"] { + border-color: var(--tone-color); + background: var(--tone-color); +} + +.attention-action[data-tone="secondary"]:hover { + color: var(--color-fg); + border-color: color-mix(in srgb, var(--tone-color) 34%, var(--color-border)); + background: color-mix(in srgb, var(--tone-color) 8%, transparent); +} + +.attention-action[data-tone="danger"] { + color: var(--attn-danger); + border-color: color-mix(in srgb, var(--attn-danger) 30%, transparent); + background: color-mix(in srgb, var(--attn-danger) 8%, transparent); +} + +.attention-action[data-tone="ghost"] { + border-color: transparent; + background: transparent; +} + +.attention-action:hover:not(:disabled) { + filter: brightness(1.08); +} + +.attention-action:disabled { + cursor: not-allowed; + opacity: 0.42; +} + +.attention-detail-body { + display: grid; + min-height: 0; + flex: 1 1 auto; + align-content: start; + gap: 10px; + padding: 14px var(--attn-gutter) 20px; +} + +.attention-detail-section { + padding: 12px; + border: 1px solid color-mix(in srgb, var(--color-border) 62%, transparent); + border-radius: var(--attn-radius-card); + background: color-mix(in srgb, var(--color-bg) 48%, transparent); +} + +.attention-section-heading { + gap: 7px; + color: color-mix(in srgb, var(--tone-color) 68%, var(--color-fg)); +} + +.attention-section-heading h3 { + margin: 0; + color: var(--color-fg); + font-size: var(--attn-fs-sm); + font-weight: 650; +} + +.attention-section-heading > span { + margin-left: auto; + color: var(--color-muted-fg); + font-family: var(--font-mono); + font-size: var(--attn-fs-xs); +} + +.attention-detail-note p, +.attention-detail-calm p { + margin: 8px 0 0; + color: color-mix(in srgb, var(--color-muted-fg) 92%, transparent); + font-size: var(--attn-fs-md); + line-height: 1.58; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.attention-progress-track { + height: 5px; + margin-top: 11px; + overflow: hidden; + border-radius: 99px; + background: color-mix(in srgb, var(--color-fg) 8%, transparent); +} + +.attention-progress-fill { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, color-mix(in srgb, var(--tone-color) 72%, white), var(--tone-color)); + box-shadow: 0 0 12px color-mix(in srgb, var(--tone-color) 35%, transparent); +} + +.attention-plan-current { + display: flex; + align-items: center; + gap: 6px; + margin: 9px 0 0; + color: var(--color-muted-fg); + font-size: var(--attn-fs-md); + overflow-wrap: anywhere; +} + +.attention-plan-current svg { + flex: 0 0 auto; + color: var(--tone-color); +} + +.attention-activity-list { + position: relative; + display: flex; + flex-direction: column; + gap: 0; + margin: 9px 0 0; + padding: 0; + list-style: none; +} + +.attention-activity-list::before { + position: absolute; + top: 10px; + bottom: 10px; + left: 3px; + width: 1px; + background: color-mix(in srgb, var(--color-border) 85%, transparent); + content: ""; +} + +.attention-activity-list li { + position: relative; + display: flex; + gap: 9px; + padding: 5px 0; + color: color-mix(in srgb, var(--color-muted-fg) 92%, transparent); + font-size: var(--attn-fs-md); + line-height: 1.45; + overflow-wrap: anywhere; +} + +.attention-activity-node { + z-index: 1; + width: 7px; + height: 7px; + flex: 0 0 auto; + margin-top: 5px; + border: 2px solid color-mix(in srgb, var(--color-card) 82%, var(--color-bg)); + border-radius: 99px; + background: color-mix(in srgb, var(--tone-color) 68%, var(--color-muted-fg)); +} + +.attention-detail-calm { + display: flex; + align-items: flex-start; + gap: 10px; + color: var(--tone-color); +} + +.attention-detail-calm svg { + flex: 0 0 auto; +} + +.attention-detail-calm h3 { + margin: 0; + color: var(--color-fg); + font-size: var(--attn-fs-sm); + font-weight: 650; +} + +.attention-detail-calm p { + margin-top: 3px; +} + +.attention-detail-footer { + display: flex; + min-height: 34px; + align-items: center; + gap: 7px; + padding: 7px 14px; + color: color-mix(in srgb, var(--color-muted-fg) 72%, transparent); + border-top: 1px solid var(--attention-hairline); + font-size: var(--attn-fs-xs); +} + +.attention-detail-footer > span + span:not(.ml-auto)::before { + margin-right: 7px; + content: "·"; +} + +/* ── Empty and placeholder states ───────────────────────────────────── */ + +.attention-empty, +.attention-detail-placeholder { + display: flex; + height: 100%; + min-height: 250px; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 28px; + text-align: center; +} + +.attention-empty-icon, +.attention-detail-placeholder-icon { + display: inline-flex; + width: 49px; + height: 49px; + align-items: center; + justify-content: center; + color: color-mix(in srgb, var(--color-accent) 55%, var(--color-muted-fg)); + border: 1px solid color-mix(in srgb, var(--color-accent) 16%, var(--color-border)); + border-radius: 15px; + background: color-mix(in srgb, var(--color-accent) 6%, transparent); + box-shadow: 0 15px 35px -28px var(--color-accent); +} + +.attention-empty-icon-error { + color: var(--attn-danger); + border-color: color-mix(in srgb, var(--attn-danger) 26%, transparent); + background: color-mix(in srgb, var(--attn-danger) 7%, transparent); +} + +.attention-empty strong, +.attention-detail-placeholder strong { + margin-top: 14px; + font-size: var(--attn-fs-lg); + font-weight: 650; + letter-spacing: -0.015em; +} + +.attention-empty p, +.attention-detail-placeholder p { + max-width: 42ch; + margin: 6px 0 0; + color: var(--color-muted-fg); + font-size: var(--attn-fs-md); + line-height: 1.55; +} + +.attention-subtle-button { + display: inline-flex; + height: 28px; + align-items: center; + gap: 6px; + margin-top: 13px; + padding: 0 10px; + color: color-mix(in srgb, var(--color-accent) 44%, var(--color-fg)); + border: 1px solid color-mix(in srgb, var(--color-accent) 24%, transparent); + border-radius: 7px; + background: color-mix(in srgb, var(--color-accent) 7%, transparent); + font-size: var(--attn-fs-sm); + font-weight: 600; +} + +/* ── Settings popover ───────────────────────────────────────────────── */ + +.attention-settings-wrap { + position: relative; +} + +.attention-settings-trigger { + display: inline-flex; + width: 30px; + height: 30px; + align-items: center; + justify-content: center; + color: color-mix(in srgb, var(--color-muted-fg) 92%, transparent); + border: 1px solid transparent; + border-radius: var(--attn-radius-control); + background: transparent; + transition: color 140ms ease, border-color 140ms ease, background 140ms ease, transform 140ms ease; +} + +.attention-settings-trigger:hover, +.attention-settings-trigger[aria-expanded="true"] { + color: var(--color-fg); + border-color: color-mix(in srgb, var(--color-accent) 24%, var(--color-border)); + background: color-mix(in srgb, var(--color-accent) 8%, var(--color-card)); +} + +.attention-settings-trigger:active { + transform: scale(0.94); +} + +/* Anchored to the trigger's right edge and clamped to the viewport instead of + the old fixed -216px nudge, which could hang off the window. */ +.attention-settings-popover { + position: absolute; + top: calc(100% + 9px); + right: 0; + z-index: 80; + width: min(400px, calc(100vw - 32px)); + max-height: calc(100vh - 120px); + overflow-y: auto; + border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent); + border-radius: 16px; + background: + radial-gradient(circle at 14% -10%, color-mix(in srgb, var(--color-accent) 12%, transparent), transparent 35%), + color-mix(in srgb, var(--color-card) 97%, var(--color-bg)); + box-shadow: var(--attn-shadow-popover), inset 0 1px 0 var(--attn-sheen); + backdrop-filter: blur(30px) saturate(1.25); + transform-origin: top right; +} + +.attention-settings-popover:focus { + outline: none; +} + +.attention-settings-popover > header { + position: sticky; + top: 0; + z-index: 1; + display: flex; + min-height: 56px; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 11px 13px; + border-bottom: 1px solid var(--attention-hairline); + background: color-mix(in srgb, var(--color-card) 96%, var(--color-bg)); +} + +.attention-settings-popover > header > div, +.attention-settings-popover > header > div > span:last-child { + display: flex; +} + +.attention-settings-popover > header > div { + min-width: 0; + align-items: center; + gap: 9px; +} + +.attention-settings-popover > header > div > span:last-child { + min-width: 0; + flex-direction: column; +} + +.attention-settings-popover > header strong { + font-size: var(--attn-fs-sm); + font-weight: 660; + letter-spacing: -0.01em; +} + +.attention-settings-popover > header small { + margin-top: 2px; + color: var(--attention-copy-dim); + font-size: var(--attn-fs-xs); +} + +.attention-settings-heading-icon { + display: inline-flex; + width: 31px; + height: 31px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + color: var(--color-accent-bright, var(--color-accent)); + border: 1px solid color-mix(in srgb, var(--color-accent) 24%, transparent); + border-radius: 9px; + background: color-mix(in srgb, var(--color-accent) 9%, transparent); +} + +.attention-settings-account-badge { + flex: 0 0 auto; + padding: 3px 7px; + color: color-mix(in srgb, var(--color-accent) 55%, var(--color-fg)); + border: 1px solid color-mix(in srgb, var(--color-accent) 22%, transparent); + border-radius: 99px; + background: color-mix(in srgb, var(--color-accent) 7%, transparent); + font-size: var(--attn-fs-2xs); + font-weight: 650; + letter-spacing: 0.02em; +} + +.attention-settings-popover section { + padding: 10px 10px 6px; +} + +.attention-settings-popover section + section { + padding-top: 9px; + border-top: 1px solid var(--attention-hairline); +} + +.attention-settings-popover section h3 { + margin: 0 0 5px 3px; + color: color-mix(in srgb, var(--color-muted-fg) 88%, transparent); + font-size: var(--attn-fs-2xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; +} + +.attention-settings-row, +.attention-settings-delay { + display: grid; + min-height: 48px; + grid-template-columns: 30px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + padding: 7px; + border-radius: 10px; + transition: background 130ms ease; +} + +.attention-settings-row:hover, +.attention-settings-delay:hover { + background: color-mix(in srgb, var(--color-fg) 4%, transparent); +} + +.attention-settings-row[data-disabled], +.attention-settings-delay[data-disabled] { + opacity: 0.5; +} + +.attention-settings-row-icon { + display: inline-flex; + width: 29px; + height: 29px; + align-items: center; + justify-content: center; + color: color-mix(in srgb, var(--color-accent) 43%, var(--color-muted-fg)); + border: 1px solid color-mix(in srgb, var(--color-border) 68%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--color-bg) 46%, transparent); +} + +.attention-settings-row-copy, +.attention-settings-row-copy > span { + display: flex; + min-width: 0; +} + +.attention-settings-row-copy { + flex-direction: column; +} + +.attention-settings-row-copy > span { + align-items: center; + gap: 6px; +} + +.attention-settings-row-copy strong { + font-size: var(--attn-fs-sm); + font-weight: 630; +} + +.attention-settings-row-copy small { + flex: 0 0 auto; + padding: 2px 5px; + color: color-mix(in srgb, var(--color-accent) 50%, var(--color-fg)); + border-radius: 4px; + background: color-mix(in srgb, var(--color-accent) 10%, transparent); + font-size: var(--attn-fs-2xs); + font-weight: 650; + letter-spacing: 0.02em; +} + +.attention-settings-row-copy em { + margin-top: 3px; + color: color-mix(in srgb, var(--color-muted-fg) 88%, transparent); + font-size: var(--attn-fs-xs); + font-style: normal; + line-height: 1.35; +} + +.attention-settings-switch { + position: relative; + width: 32px; + height: 19px; + flex: 0 0 auto; + padding: 0; + border: 1px solid color-mix(in srgb, var(--color-border) 90%, transparent); + border-radius: 99px; + background: color-mix(in srgb, var(--color-muted) 80%, transparent); + box-shadow: var(--shadow-inset, inset 0 1px 2px rgba(0, 0, 0, 0.18)); + transition: border-color 150ms ease, background 150ms ease; +} + +.attention-settings-switch > span { + position: absolute; + top: 2px; + left: 2px; + width: 13px; + height: 13px; + border-radius: 99px; + background: color-mix(in srgb, var(--color-muted-fg) 82%, white); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + transition: transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1), background 150ms ease; +} + +.attention-settings-switch[aria-checked="true"] { + border-color: color-mix(in srgb, var(--color-accent) 50%, transparent); + background: color-mix(in srgb, var(--color-accent) 62%, var(--color-accent-deep, var(--color-accent))); +} + +.attention-settings-switch[aria-checked="true"] > span { + background: #fff; + transform: translateX(13px); +} + +.attention-settings-delay select { + width: 132px; + height: 29px; + padding: 0 8px; + color: var(--color-fg); + border: 1px solid var(--attention-hairline); + border-radius: 7px; + background: color-mix(in srgb, var(--color-bg) 62%, var(--color-card)); + font-family: var(--font-sans); + font-size: var(--attn-fs-xs); +} + +.attention-settings-delay select:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.attention-settings-loading { + display: flex; + min-height: 230px; + align-items: center; + justify-content: center; + gap: 9px; + color: var(--attention-copy-dim); + font-size: var(--attn-fs-md); +} + +.attention-settings-loading > span { + width: 14px; + height: 14px; + border: 2px solid color-mix(in srgb, var(--color-accent) 18%, transparent); + border-top-color: var(--color-accent); + border-radius: 50%; + animation: attention-settings-spin 700ms linear infinite; +} + +.attention-settings-error { + display: flex; + align-items: flex-start; + gap: 7px; + margin: 5px 10px 8px; + padding: 8px 9px; + color: var(--attn-danger); + border: 1px solid color-mix(in srgb, var(--attn-danger) 26%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--attn-danger) 7%, transparent); + font-size: var(--attn-fs-xs); + line-height: 1.4; +} + +.attention-settings-error svg { + flex: 0 0 auto; + margin-top: 1px; +} + +.attention-settings-popover > footer { + position: sticky; + bottom: 0; + display: flex; + min-height: 48px; + align-items: center; + gap: 7px; + padding: 9px 10px; + border-top: 1px solid var(--attention-hairline); + background: color-mix(in srgb, var(--color-card) 96%, var(--color-bg)); +} + +.attention-settings-popover > footer > span { + display: inline-flex; + min-width: 0; + flex: 1; + align-items: center; + gap: 5px; + color: color-mix(in srgb, var(--color-muted-fg) 82%, transparent); + font-size: var(--attn-fs-xs); +} + +.attention-settings-popover > footer > button { + height: 29px; + flex: 0 0 auto; + padding: 0 11px; + color: var(--color-muted-fg); + border: 1px solid transparent; + border-radius: 7px; + background: transparent; + font-size: var(--attn-fs-sm); + font-weight: 610; +} + +.attention-settings-popover > footer > button:hover { + color: var(--color-fg); + background: color-mix(in srgb, var(--color-fg) 5%, transparent); +} + +.attention-settings-popover > footer > .attention-settings-save { + color: #fff; + border-color: color-mix(in srgb, var(--color-accent) 55%, transparent); + background: color-mix(in srgb, var(--color-accent) 74%, var(--color-accent-deep, var(--color-accent))); + box-shadow: 0 7px 18px -10px var(--color-accent); +} + +.attention-settings-popover > footer > .attention-settings-save:hover { + color: #fff; + background: color-mix(in srgb, var(--color-accent) 88%, var(--color-accent-deep, var(--color-accent))); +} + +.attention-settings-popover > footer > button:disabled { + opacity: 0.45; + pointer-events: none; +} + +/* ── Focus and motion ───────────────────────────────────────────────── */ + +.attention-center button:focus-visible, +.attention-center select:focus-visible, +.attention-center [role="dialog"]:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-accent) 72%, var(--color-fg)); + outline-offset: 2px; +} + +.attention-item-row:focus-visible { + outline-offset: -2px; +} + +@keyframes attention-settings-spin { + to { transform: rotate(360deg); } +} + +/* Opacity only. A scaling dot reads as a throb on a surface that is meant to + sit in the corner of your eye all day. */ +@keyframes attention-status-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* ── Responsive ─────────────────────────────────────────────────────── */ + +@media (min-width: 1600px) { + .attention-layout { + gap: 12px; + padding: 12px; + } +} + +@media (max-width: 900px) { + .attention-layout { + grid-template-columns: minmax(270px, 40%) minmax(0, 1fr); + } + + .attention-freshness, + .attention-toolbar-hint { + display: none; + } +} + +/* Below this the detail card cannot hold a hero, an action row and three + sections in a ~400px column, so the panes stack and each scrolls on its own + instead of clipping path-shaped titles. */ +@media (max-width: 820px) { + .attention-layout { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(170px, 42%) minmax(0, 1fr); + } + + .attention-detail-hero { + padding-top: 16px; + } +} + +@media (max-width: 700px) { + .attention-header { + min-height: 62px; + padding-right: 12px; + padding-left: 12px; + } + + .attention-title-lockup p { + display: none; + } + + .attention-scope-button { + width: 148px; + } + + .attention-toolbar { + padding-right: 12px; + padding-left: 12px; + } + + .attention-layout { + gap: 6px; + padding: 6px; + } + + .attention-roster-panel, + .attention-detail-panel { + border-radius: var(--attn-radius-card); + } +} + +@media (prefers-reduced-motion: reduce) { + .attention-center *, + .attention-center *::before, + .attention-center *::after { + scroll-behavior: auto !important; + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } + + .attention-phase-dot-active { + animation: none; + } +} diff --git a/apps/desktop/src/renderer/components/attention/AttentionCenter.test.tsx b/apps/desktop/src/renderer/components/attention/AttentionCenter.test.tsx new file mode 100644 index 000000000..73621e14f --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/AttentionCenter.test.tsx @@ -0,0 +1,484 @@ +// @vitest-environment jsdom + +import React from "react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ATTENTION_CONTRACT_VERSION, + DEFAULT_ATTENTION_PREFERENCES, + type AttentionItem, +} from "../../../shared/types"; +import { + attentionStore, + resetAttentionStoreForTests, +} from "../../state/attentionStore"; +import { + publishAccountStatus, + SIGNED_OUT_ACCOUNT, +} from "../../lib/account"; +import { + AttentionCenter, +} from "./AttentionCenter"; + +const originalAde = window.ade; +const signedInAccount = { + signedIn: true as const, + userId: "account-a", + email: null, + name: null, + expiresAt: null, + provider: null, + imageUrl: null, +}; + +beforeEach(() => { + publishAccountStatus(signedInAccount); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + account: { + ...(originalAde?.account ?? {}), + status: vi.fn(async () => signedInAccount), + }, + }, + }); +}); + +function item( + id: string, + patch: Partial = {}, +): AttentionItem { + return { + contractVersion: ATTENTION_CONTRACT_VERSION, + id, + revision: 1, + fingerprint: `fingerprint-${id}`, + kind: "agent", + eventKind: "agent_needs_you", + phase: "needs_you", + machine: { + machineKey: "studio", + name: "Studio Mac", + online: true, + lastSeenAt: "2026-07-28T14:00:00.000Z", + }, + project: { projectId: "ade", name: "ADE", rootPath: "/repo/ade" }, + provider: "codex", + model: "GPT-5", + title: `Task ${id}`, + preview: "Waiting for a safe decision", + privacyPreview: "Agent needs your attention", + detail: "The agent reached an approval checkpoint.", + recentActivity: ["Edited AuthService.ts", "Ran focused tests"], + planProgress: { completed: 2, total: 4, current: "Verify the approval flow" }, + destination: { kind: "session", sessionId: `session-${id}` }, + actions: [ + { id: `approve-${id}`, kind: "approve", label: "Approve" }, + { id: `deny-${id}`, kind: "deny", label: "Deny" }, + ], + occurredAt: "2026-07-28T14:00:00.000Z", + updatedAt: "2026-07-28T14:00:00.000Z", + seenAt: null, + dismissedAt: null, + expiresAt: null, + ...patch, + }; +} + +afterEach(() => { + cleanup(); + resetAttentionStoreForTests(); + publishAccountStatus(SIGNED_OUT_ACCOUNT); + window.localStorage.removeItem("ade:attention:notch-enabled"); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: originalAde, + }); +}); + +describe("AttentionCenter", () => { + it("opens exact context before acknowledging an unhandled action", async () => { + const online = item("approval"); + attentionStore.setState({ + itemsById: { [online.id]: online }, + generatedAt: "2026-07-28T14:00:00.000Z", + }); + let finishOpening: () => void = () => {}; + const openItem = vi.fn(() => new Promise((resolve) => { + finishOpening = resolve; + })); + + render(); + + expect(screen.getByRole("heading", { name: "Task approval" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Open to approve" })); + await waitFor(() => expect(openItem).toHaveBeenCalledWith( + expect.objectContaining({ + id: online.id, + destination: online.destination, + }), + )); + expect(attentionStore.getState().itemsById.approval?.seenAt).toBeNull(); + + await act(async () => { + finishOpening(); + await Promise.resolve(); + }); + await waitFor(() => { + expect(attentionStore.getState().itemsById.approval?.seenAt).not.toBeNull(); + }); + }); + + it("keeps failed navigation unseen and explains how opening failed", async () => { + const remote = item("unreachable"); + attentionStore.setState({ itemsById: { [remote.id]: remote } }); + const openItem = vi.fn(async () => { + throw new Error("Studio Mac stopped responding."); + }); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open to approve" })); + + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain( + "Studio Mac stopped responding.", + ); + }); + expect(attentionStore.getState().itemsById.unreachable?.seenAt).toBeNull(); + }); + + it("keeps remote actions disabled for last-known offline work", () => { + const offline = item("offline", { + machine: { + machineKey: "cloud", + name: "Cloud Mac", + online: false, + lastSeenAt: "2026-07-28T13:00:00.000Z", + }, + }); + attentionStore.setState({ itemsById: { [offline.id]: offline } }); + + render(); + + expect(screen.getByText("Cloud Mac is offline.")).toBeTruthy(); + expect( + (screen.getByRole("button", { name: "Open to approve" }) as HTMLButtonElement).disabled, + ).toBe(true); + expect((screen.getByRole("button", { name: "Open" }) as HTMLButtonElement).disabled).toBe(true); + }); + + it("applies project lenses and offers a one-click clear affordance", () => { + const ade = item("ade"); + const versic = item("versic", { + project: { projectId: "versic", name: "Versic", rootPath: "/repo/versic" }, + title: "Task Versic", + }); + attentionStore.setState({ + itemsById: { [ade.id]: ade, [versic.id]: versic }, + }); + + render(); + fireEvent.click(screen.getByRole("button", { name: "All machines" })); + fireEvent.click(screen.getByRole("menuitemradio", { name: "Versic" })); + + return waitFor(() => { + expect(screen.getByRole("heading", { name: "Task Versic" })).toBeTruthy(); + }).then(() => { + expect(screen.queryByRole("heading", { name: "Task ade" })).toBeNull(); + fireEvent.click(screen.getByTitle("Clear scope")); + expect(attentionStore.getState().scope).toEqual({ kind: "all" }); + }); + }); + + it("rolls back a failed acknowledgement and explains the failure", async () => { + const approval = item("rollback"); + let rejectAcknowledgement: (error: Error) => void = () => {}; + const acknowledge = vi.fn(() => new Promise((_resolve, reject) => { + rejectAcknowledgement = reject; + })); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(window.ade ?? {}), + attention: { + acknowledge, + getSnapshot: vi.fn(), + reportPresence: vi.fn(), + getPreferences: vi.fn(), + putPreferences: vi.fn(), + }, + }, + }); + attentionStore.setState({ itemsById: { [approval.id]: approval } }); + render(); + + fireEvent.click(screen.getByRole("button", { name: /Task rollback/ })); + expect(attentionStore.getState().itemsById.rollback?.seenAt).not.toBeNull(); + + await act(async () => { + rejectAcknowledgement(new Error("Relay is temporarily unavailable.")); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(attentionStore.getState().itemsById.rollback?.seenAt).toBeNull(); + expect(screen.getByRole("alert").textContent).toContain("Relay is temporarily unavailable."); + }); + }); + + it("contains a rejected detail acknowledgement after rolling it back", async () => { + const approval = item("detail-rollback"); + const acknowledge = vi.fn(async () => { + throw new Error("Relay rejected the acknowledgement."); + }); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(window.ade ?? {}), + attention: { + acknowledge, + getSnapshot: vi.fn(), + reportPresence: vi.fn(), + getPreferences: vi.fn(), + putPreferences: vi.fn(), + }, + }, + }); + attentionStore.setState({ itemsById: { [approval.id]: approval } }); + render(); + + fireEvent.click(screen.getByTitle("Mark as seen")); + + await waitFor(() => { + expect(acknowledge).toHaveBeenCalledWith({ + itemIds: [approval.id], + seenAt: expect.any(String), + }); + expect(attentionStore.getState().itemsById[approval.id]?.seenAt).toBeNull(); + expect(screen.getByRole("alert").textContent).toContain( + "Relay rejected the acknowledgement.", + ); + }); + }); + + it("walks the roster with arrow keys and exposes it as a single tab stop", () => { + const first = item("first"); + const second = item("second", { updatedAt: "2026-07-28T13:59:00.000Z" }); + attentionStore.setState({ itemsById: { [first.id]: first, [second.id]: second } }); + + const { container } = render(); + const rows = Array.from( + container.querySelectorAll("[data-attention-item]"), + ); + + expect(rows).toHaveLength(2); + expect(rows.filter((row) => row.tabIndex === 0)).toHaveLength(1); + + rows[0].focus(); + fireEvent.keyDown(rows[0], { key: "ArrowDown" }); + expect(document.activeElement).toBe(rows[1]); + + fireEvent.keyDown(rows[1], { key: "ArrowUp" }); + expect(document.activeElement).toBe(rows[0]); + + fireEvent.keyDown(rows[0], { key: "End" }); + expect(document.activeElement).toBe(rows[1]); + }); + + it("moves focus into the scope menu and hands it back when dismissed", () => { + const only = item("scoped"); + attentionStore.setState({ itemsById: { [only.id]: only } }); + + render(); + const trigger = screen.getByRole("button", { name: "All machines" }); + fireEvent.click(trigger); + + const options = screen.getAllByRole("menuitemradio"); + expect(document.activeElement).toBe(options[0]); + + fireEvent.keyDown(options[0], { key: "ArrowDown" }); + expect(document.activeElement).toBe(options[1]); + + fireEvent.keyDown(options[1], { key: "Escape" }); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + expect(document.activeElement).toBe(trigger); + }); + + it("returns focus to the settings trigger when the popover is dismissed", async () => { + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(window.ade ?? {}), + attention: { + getSnapshot: vi.fn(), + acknowledge: vi.fn(), + reportPresence: vi.fn(), + getPreferences: vi.fn(async () => DEFAULT_ATTENTION_PREFERENCES), + putPreferences: vi.fn(), + }, + }, + }); + + render(); + const trigger = screen.getByRole("button", { name: "Attention settings" }); + fireEvent.click(trigger); + + const dialog = await screen.findByRole("dialog", { name: "Attention settings" }); + expect(document.activeElement).toBe(dialog); + + fireEvent.keyDown(document, { key: "Escape" }); + await waitFor(() => { + expect(screen.queryByRole("dialog", { name: "Attention settings" })).toBeNull(); + }); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + }); + + it("saves account delivery preferences and keeps ADE Notch device-local", async () => { + const putPreferences = vi.fn(async () => undefined); + const updateSettings = vi.fn(async () => undefined); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(window.ade ?? {}), + attention: { + getSnapshot: vi.fn(), + acknowledge: vi.fn(), + reportPresence: vi.fn(), + getPreferences: vi.fn(async () => DEFAULT_ATTENTION_PREFERENCES), + putPreferences, + }, + attentionNotch: { + publishSnapshot: vi.fn(), + updateSettings, + onAcknowledgeRequested: vi.fn(() => () => {}), + }, + }, + }); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Attention settings" })); + await screen.findByRole("dialog", { name: "Attention settings" }); + await waitFor(() => { + expect(screen.getByRole("switch", { name: "Sounds" })).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole("switch", { name: "ADE Notch" })); + fireEvent.click(screen.getByRole("switch", { name: "Sounds" })); + fireEvent.change(screen.getByRole("combobox", { name: "Phone escalation" }), { + target: { value: "120" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(putPreferences).toHaveBeenCalledWith( + "account-a", + expect.objectContaining({ + account: expect.objectContaining({ + soundsEnabled: true, + desktopFirstEnabled: true, + desktopFirstDelaySeconds: 120, + }), + }), + ); + expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ + enabled: false, + soundsEnabled: true, + })); + expect(window.localStorage.getItem("ade:attention:notch-enabled")).toBe("false"); + }); + }); + + it("does not apply an earlier account's delayed preferences after switching accounts", async () => { + let resolveAccountA: (preferences: typeof DEFAULT_ATTENTION_PREFERENCES) => void = + () => {}; + const accountAPreferences = new Promise((resolve) => { + resolveAccountA = resolve; + }); + const accountBPreferences = { + ...DEFAULT_ATTENTION_PREFERENCES, + account: { + ...DEFAULT_ATTENTION_PREFERENCES.account, + notificationsEnabled: false, + hideDetails: false, + }, + }; + const putPreferences = vi.fn(async () => undefined); + const getPreferences = vi + .fn() + .mockImplementationOnce(() => accountAPreferences) + .mockResolvedValueOnce(accountBPreferences); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(window.ade ?? {}), + attention: { + getSnapshot: vi.fn(), + acknowledge: vi.fn(), + reportPresence: vi.fn(), + getPreferences, + putPreferences, + }, + }, + }); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Attention settings" })); + await waitFor(() => expect(getPreferences).toHaveBeenCalledTimes(1)); + + act(() => { + publishAccountStatus({ + signedIn: true, + userId: "account-b", + email: null, + name: null, + expiresAt: null, + provider: null, + imageUrl: null, + }); + }); + await waitFor(() => { + expect(screen.queryByRole("dialog", { name: "Attention settings" })).toBeNull(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Attention settings" })); + await screen.findByRole("dialog", { name: "Attention settings" }); + await waitFor(() => { + expect(getPreferences).toHaveBeenCalledTimes(2); + expect( + screen.getByRole("switch", { name: "Phone notifications" }) + .getAttribute("aria-checked"), + ).toBe("false"); + }); + + await act(async () => { + resolveAccountA({ + ...DEFAULT_ATTENTION_PREFERENCES, + account: { + ...DEFAULT_ATTENTION_PREFERENCES.account, + notificationsEnabled: true, + hideDetails: true, + }, + }); + await accountAPreferences; + }); + + expect( + screen.getByRole("switch", { name: "Phone notifications" }) + .getAttribute("aria-checked"), + ).toBe("false"); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(putPreferences).toHaveBeenCalledWith("account-b", accountBPreferences); + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/attention/AttentionCenter.tsx b/apps/desktop/src/renderer/components/attention/AttentionCenter.tsx new file mode 100644 index 000000000..99e512b50 --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/AttentionCenter.tsx @@ -0,0 +1,1150 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + AnimatePresence, + LayoutGroup, + MotionConfig, + motion, + useReducedMotion, +} from "motion/react"; +import { + ArrowClockwise, + ArrowSquareOut, + BellRinging, + CaretDown, + Check, + CheckCircle, + ClockCounterClockwise, + DesktopTower, + FunnelSimple, + GitPullRequest, + Lightning, + ListChecks, + RadioButton, + Sparkle, + Tray, + WarningCircle, + WifiHigh, + WifiSlash, + X, + XCircle, +} from "@phosphor-icons/react"; + +import { + attentionDestinationDeepLink, + type AttentionAction, + type AttentionItem, + type AttentionMachineRef, + type AttentionProjectRef, +} from "../../../shared/types"; +import { relativeWhen } from "../../lib/format"; +import { openAdeDeeplink } from "../../lib/openExternal"; +import { + acknowledgeAttentionItem, + selectAttentionCounts, + selectAttentionItems, + useAttentionStore, + type AttentionScope, + type AttentionView, +} from "../../state/attentionStore"; +import { ProviderLogo } from "../shared/ProviderLogos"; +import { cn } from "../ui/cn"; +import { + attentionActionTone, + attentionPhasePresentation, + attentionViewEmptyCopy, + type AttentionTone, +} from "./attentionPresentation"; +import { AttentionSettingsPopover } from "./AttentionSettingsPopover"; +import { refreshAttentionSnapshot } from "./useAttentionSync"; +import "./AttentionCenter.css"; + +type AttentionCenterProps = { + onAction?: (item: AttentionItem, action: AttentionAction) => void | Promise; + onOpenItem?: (item: AttentionItem) => void | Promise; +}; + +type ProjectGroup = { + project: AttentionProjectRef; + items: AttentionItem[]; +}; + +type MachineGroup = { + machine: AttentionMachineRef; + projects: ProjectGroup[]; + itemCount: number; +}; + +const VIEW_CONFIG: Array<{ + id: AttentionView; + label: string; + icon: React.ElementType; +}> = [ + { id: "live", label: "Live", icon: RadioButton }, + { id: "inbox", label: "Inbox", icon: Tray }, + { id: "recent", label: "Recent", icon: ClockCounterClockwise }, +]; + +function groupItems(items: readonly AttentionItem[]): MachineGroup[] { + const machines = new Map(); + for (const item of items) { + let machine = machines.get(item.machine.machineKey); + if (!machine) { + machine = { machine: item.machine, projects: [], itemCount: 0 }; + machines.set(item.machine.machineKey, machine); + } + machine.itemCount += 1; + let project = machine.projects.find( + (entry) => entry.project.projectId === item.project.projectId, + ); + if (!project) { + project = { project: item.project, items: [] }; + machine.projects.push(project); + } + project.items.push(item); + } + return [...machines.values()].sort((left, right) => { + if (left.machine.online !== right.machine.online) return left.machine.online ? -1 : 1; + return left.machine.name.localeCompare(right.machine.name); + }); +} + +function toneClass(tone: AttentionTone): string { + return `attention-tone-${tone}`; +} + +function itemIcon(item: AttentionItem, size: number): React.ReactNode { + if (item.kind === "pull_request") { + return ; + } + return ; +} + +function actionIcon(action: AttentionAction): React.ElementType { + if (action.kind === "approve") return Check; + if (action.kind === "deny") return X; + if (action.kind === "restart" || action.kind === "rerun_checks") return ArrowClockwise; + if (action.kind === "open") return ArrowSquareOut; + if (action.kind === "dismiss") return XCircle; + if (action.kind === "mark_seen") return CheckCircle; + return Lightning; +} + +function itemSupportsActionOffline(action: AttentionAction): boolean { + return action.kind === "mark_seen" || action.kind === "dismiss"; +} + +function navigationErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim()) return error.message.trim(); + return "ADE couldn’t open the exact machine and project for this item."; +} + +function scopeLabel(scope: AttentionScope): string { + return scope.kind === "all" ? "All machines" : scope.label; +} + +const ROSTER_PANEL_ID = "attention-roster-panel"; + +function tabDomId(view: AttentionView): string { + return `attention-tab-${view}`; +} + +/** Moves focus within a group of controls, wrapping at both ends. */ +function focusRelative(elements: HTMLElement[], from: Element | null, delta: number): void { + if (elements.length === 0) return; + const current = elements.indexOf(from as HTMLElement); + const next = current < 0 + ? 0 + : (current + delta + elements.length) % elements.length; + elements[next]?.focus(); +} + +function AttentionTabs({ + view, + counts, + onChange, +}: { + view: AttentionView; + counts: Record; + onChange: (view: AttentionView) => void; +}) { + // A tablist is a single tab stop: arrows move between tabs, Tab leaves the group. + const onKeyDown = (event: React.KeyboardEvent) => { + const delta = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0; + const tabs = Array.from( + event.currentTarget.querySelectorAll('[role="tab"]'), + ); + if (delta !== 0) { + event.preventDefault(); + focusRelative(tabs, document.activeElement, delta); + return; + } + if (event.key === "Home" || event.key === "End") { + event.preventDefault(); + (event.key === "Home" ? tabs[0] : tabs[tabs.length - 1])?.focus(); + } + }; + + return ( +
+ {VIEW_CONFIG.map((entry) => { + const active = view === entry.id; + const count = counts[entry.id]; + return ( + + ); + })} +
+ ); +} + +function ScopePicker({ + scope, + allItems, + onChange, +}: { + scope: AttentionScope; + allItems: AttentionItem[]; + onChange: (scope: AttentionScope) => void; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const triggerRef = useRef(null); + const options = useMemo(() => groupItems(allItems), [allItems]); + + useEffect(() => { + if (!open) return; + const close = (event: PointerEvent) => { + if (!ref.current?.contains(event.target as Node)) setOpen(false); + }; + window.addEventListener("pointerdown", close); + return () => window.removeEventListener("pointerdown", close); + }, [open]); + + // Opening a menu should land focus inside it, and closing it should hand + // focus back to the trigger rather than dropping the user at the document. + useEffect(() => { + if (!open) return; + const menu = ref.current?.querySelector('[role="menu"]'); + if (!menu) return; + const checked = menu.querySelector('[aria-checked="true"]'); + (checked ?? menu.querySelector('[role="menuitemradio"]'))?.focus(); + }, [open]); + + const closeMenu = (returnFocus: boolean) => { + setOpen(false); + if (returnFocus) triggerRef.current?.focus(); + }; + + const onMenuKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + closeMenu(true); + return; + } + if (event.key === "Tab") { + setOpen(false); + return; + } + const delta = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0; + const items = Array.from( + event.currentTarget.querySelectorAll('[role="menuitemradio"]'), + ); + if (delta !== 0) { + event.preventDefault(); + focusRelative(items, document.activeElement, delta); + return; + } + if (event.key === "Home" || event.key === "End") { + event.preventDefault(); + (event.key === "Home" ? items[0] : items[items.length - 1])?.focus(); + } + }; + + return ( +
+ + + {open ? ( + + + {options.map((machine) => ( +
+ + {machine.projects.map((project) => ( + + ))} +
+ ))} +
+ ) : null} +
+
+ ); +} + +function PhasePill({ item }: { item: AttentionItem }) { + const phase = attentionPhasePresentation(item.phase); + return ( + + + {phase.label} + + ); +} + +function AttentionItemRow({ + item, + selected, + tabbable, + reducedMotion, + onSelect, +}: { + item: AttentionItem; + selected: boolean; + tabbable: boolean; + reducedMotion: boolean; + onSelect: () => void; +}) { + const phase = attentionPhasePresentation(item.phase); + return ( + + {selected ? ( + + ) : null} + + {itemIcon(item, 17)} + + + + {item.title} + + + {item.preview} + + + {item.laneName ? {item.laneName} : null} + {item.model ? {item.model} : null} + + + {!item.seenAt ? : null} + + ); +} + +function AttentionRoster({ + items, + selectedId, + focusId, + reducedMotion, + onSelect, +}: { + items: AttentionItem[]; + selectedId: string | null; + focusId: string | null; + reducedMotion: boolean; + onSelect: (item: AttentionItem) => void; +}) { + const groups = useMemo(() => groupItems(items), [items]); + + // The roster is one tab stop; arrows walk the rows across machine and project + // groups, and Enter/Space (native button behaviour) opens the focused row. + const onKeyDown = (event: React.KeyboardEvent) => { + const delta = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0; + const rows = Array.from( + event.currentTarget.querySelectorAll("[data-attention-item]"), + ); + if (delta !== 0) { + event.preventDefault(); + focusRelative(rows, document.activeElement, delta); + return; + } + if (event.key === "Home" || event.key === "End") { + event.preventDefault(); + (event.key === "Home" ? rows[0] : rows[rows.length - 1])?.focus(); + } + }; + + return ( +
+ + {groups.map((machine) => ( + +
+ + + + + {machine.machine.name} + + {machine.machine.online + ? "Online now" + : machine.machine.lastSeenAt + ? `Offline · ${relativeWhen(machine.machine.lastSeenAt)}` + : "Offline"} + + + + {machine.itemCount} +
+ {machine.projects.map((project) => ( +
+
+ + {project.project.name.slice(0, 1).toUpperCase()} + + {project.project.name} + {project.items.length} +
+
+ {project.items.map((item) => ( + onSelect(item)} + /> + ))} +
+
+ ))} +
+ ))} +
+
+ ); +} + +function EmptyAttention({ + view, + scoped, + syncError, + onClearScope, + onRetry, +}: { + view: AttentionView; + scoped: boolean; + syncError: string | null; + onClearScope: () => void; + onRetry: () => void; +}) { + const copy = attentionViewEmptyCopy(view); + const Icon = view === "inbox" ? CheckCircle : view === "recent" ? ClockCounterClockwise : Sparkle; + if (syncError) { + return ( + + + + + Couldn’t sync Attention +

{syncError}

+ +
+ ); + } + return ( + + + {scoped ? "Nothing in this filter" : copy.title} +

{scoped ? "Clear it to see every machine and project." : copy.body}

+ {scoped ? ( + + ) : null} +
+ ); +} + +function DetailAction({ + item, + action, + pending, + opensDestination, + onRun, +}: { + item: AttentionItem; + action: AttentionAction; + pending: boolean; + opensDestination: boolean; + onRun: () => void; +}) { + const Icon = actionIcon(action); + const disabled = pending || (!item.machine.online && !itemSupportsActionOffline(action)); + const label = opensDestination + && action.kind !== "open" + && action.kind !== "mark_seen" + && action.kind !== "dismiss" + ? `Open to ${action.label.toLocaleLowerCase()}` + : action.label; + return ( + + + {pending ? "Working…" : label} + + ); +} + +function AttentionDetail({ + item, + pendingActionId, + acknowledgementError, + navigationError, + opensDestinationForActions, + onAction, +}: { + item: AttentionItem; + pendingActionId: string | null; + acknowledgementError: string | null; + navigationError: string | null; + opensDestinationForActions: boolean; + onAction: (action: AttentionAction) => void; +}) { + const phase = attentionPhasePresentation(item.phase); + const actions = item.actions.some((action) => action.kind === "open") + ? item.actions + : [ + ...item.actions, + { id: `open:${item.id}`, kind: "open" as const, label: "Open" }, + ]; + const primaryActions = actions.filter( + (action) => action.kind !== "mark_seen" && action.kind !== "dismiss", + ); + const planTotal = Math.max(0, item.planProgress?.total ?? 0); + const planCompleted = Math.min(planTotal, Math.max(0, item.planProgress?.completed ?? 0)); + const planPercent = planTotal > 0 ? Math.round((planCompleted / planTotal) * 100) : 0; + + return ( + +
+
+
+ + {item.machine.online ? : } + + {item.machine.name} + / + {item.project.name} + {item.laneName ? ( + <> + / + {item.laneName} + + ) : null} +
+
+ {item.seenAt ? ( + Seen + ) : ( + + )} + +
+
+ +
+ {itemIcon(item, 24)} +
+
+ + +
+

{item.title}

+

{item.preview}

+
+
+ + {!item.machine.online ? ( +
+ + + {item.machine.name} is offline. + This is its last-known state. Remote actions unlock when it reconnects. + +
+ ) : null} + + {acknowledgementError ? ( +
+ + + That update didn’t stick. + {acknowledgementError} + +
+ ) : null} + + {navigationError ? ( +
+ + + Couldn’t open this work. + {navigationError} + +
+ ) : null} + + {primaryActions.length > 0 ? ( +
+ {primaryActions.map((action) => ( + onAction(action)} + /> + ))} +
+ ) : null} + +
+ {item.detail ? ( +
+
+ +

What’s happening

+
+

{item.detail}

+
+ ) : null} + + {item.planProgress ? ( +
+
+ +

Plan progress

+ {planCompleted} of {planTotal} +
+
+ +
+ {item.planProgress.current ? ( +

+ + {item.planProgress.current} +

+ ) : null} +
+ ) : null} + + {item.recentActivity?.length ? ( +
+
+ +

Recent activity

+
+
    + {item.recentActivity.slice(0, 8).map((activity, index) => ( +
  1. + + {activity} +
  2. + ))} +
+
+ ) : null} + + {!item.detail && !item.planProgress && !item.recentActivity?.length ? ( +
+ +
+

Ready when you are

+

Open it to pick up in the exact session it came from.

+
+
+ ) : null} +
+ +
+ {item.kind === "agent" ? item.provider || "Agent" : "Pull request"} + {item.model ? {item.model} : null} + Updated {relativeWhen(item.updatedAt)} +
+ + ); +} + +function DetailPlaceholder() { + return ( +
+ + Nothing selected +

Pick an item to see its activity and act on it.

+
+ ); +} + +export function AttentionCenter({ onAction, onOpenItem }: AttentionCenterProps = {}) { + const state = useAttentionStore((value) => value); + const { + itemsById, + view, + scope, + selectedItemId, + generatedAt, + syncStatus, + syncError, + acknowledgementErrors, + setView, + setScope, + selectItem, + markSeen, + dismiss, + } = state; + const reducedMotion = useReducedMotion() ?? false; + const [now, setNow] = useState(() => Date.now()); + const [pendingActionId, setPendingActionId] = useState(null); + const [navigationFailure, setNavigationFailure] = useState<{ + itemId: string; + message: string; + } | null>(null); + const allItems = useMemo(() => Object.values(itemsById), [itemsById]); + const visibleItems = useMemo( + () => selectAttentionItems(state, now), + [now, state], + ); + const counts = useMemo( + () => selectAttentionCounts(state, now), + [now, state], + ); + const selectedItem = (selectedItemId ? itemsById[selectedItemId] : null) + ?? visibleItems[0] + ?? null; + // Exactly one row carries tabIndex 0. The selected item can be filtered out + // of the current view, so fall back to the first visible row rather than + // leaving the whole roster unreachable by keyboard. + const rosterFocusId = useMemo(() => { + if (selectedItem && visibleItems.some((entry) => entry.id === selectedItem.id)) { + return selectedItem.id; + } + return visibleItems[0]?.id ?? null; + }, [selectedItem, visibleItems]); + const machineCount = new Set(allItems.map((item) => item.machine.machineKey)).size; + const liveMachineCount = new Set( + allItems.filter((item) => item.machine.online).map((item) => item.machine.machineKey), + ).size; + + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 30_000); + return () => window.clearInterval(timer); + }, []); + + useEffect(() => { + if (!selectedItem && selectedItemId) selectItem(null); + }, [selectItem, selectedItem, selectedItemId]); + + const openItem = async (item: AttentionItem): Promise => { + setNavigationFailure((current) => current?.itemId === item.id ? null : current); + try { + if (onOpenItem) { + await onOpenItem(item); + } else { + openAdeDeeplink(attentionDestinationDeepLink(item.destination)); + } + } catch (error) { + setNavigationFailure({ + itemId: item.id, + message: navigationErrorMessage(error), + }); + return false; + } + // Opening is the user-visible proof that the exact destination resolved. + // Only then may the ambient item leave the unseen state. + await acknowledgeAttentionItem(item.id, "seen").catch(() => {}); + return true; + }; + + const runAction = async (item: AttentionItem, action: AttentionAction) => { + if (pendingActionId) return; + if (action.kind === "open") { + await openItem(item); + return; + } + setPendingActionId(action.id); + try { + if (action.kind === "mark_seen" || action.kind === "dismiss") { + if (onAction) { + if (action.kind === "mark_seen") markSeen(item.id); + else dismiss(item.id); + await onAction(item, action); + } else { + await acknowledgeAttentionItem( + item.id, + action.kind === "dismiss" ? "dismiss" : "seen", + ); + } + } else if (onAction) { + await onAction(item, action); + } else { + await openItem(item); + } + } catch { + // Account acknowledgement helpers already roll back optimistic state and + // expose their bounded error in the detail panel. Keep the click promise + // contained so React event dispatch never produces an unhandled rejection. + } finally { + setPendingActionId(null); + } + }; + + return ( + +
+
+
+ +
+
+ + + {counts.inbox > 0 ? {Math.min(99, counts.inbox)} : null} + +
+

Attention

+

+ {machineCount > 0 + ? `${liveMachineCount} of ${machineCount} machine${machineCount === 1 ? "" : "s"} online` + : "Across every machine on your account"} +

+
+
+
+ + {syncStatus === "error" ? ( + + ) : syncStatus === "syncing" ? ( + + + Syncing + + ) : generatedAt ? ( + + + Synced {relativeWhen(generatedAt)} + + ) : null} + +
+
+ +
+ + {scope.kind !== "all" ? ( + + ) : ( + + + Highest priority first + + )} +
+ +
+
+
+
+ + {view === "live" ? "In motion" : view === "inbox" ? "Needs review" : "Latest outcomes"} + + {visibleItems.length} item{visibleItems.length === 1 ? "" : "s"} +
+ {counts.inbox > 0 && view !== "inbox" ? ( + + ) : null} +
+ {visibleItems.length > 0 ? ( + { + selectItem(item.id); + void acknowledgeAttentionItem(item.id, "seen").catch(() => {}); + }} + /> + ) : ( + setScope({ kind: "all" })} + onRetry={() => void refreshAttentionSnapshot()} + /> + )} +
+ +
+ + {selectedItem ? ( + void runAction(selectedItem, action)} + /> + ) : ( + + + + )} + +
+
+
+ + ); +} + +export default AttentionCenter; diff --git a/apps/desktop/src/renderer/components/attention/AttentionSettingsPopover.tsx b/apps/desktop/src/renderer/components/attention/AttentionSettingsPopover.tsx new file mode 100644 index 000000000..78b4cc33b --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/AttentionSettingsPopover.tsx @@ -0,0 +1,424 @@ +import React, { useEffect, useRef, useState } from "react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { + BellRinging, + Check, + Confetti, + DeviceMobile, + GearSix, + HourglassMedium, + LockKey, + Notches, + SpeakerHigh, + WarningCircle, +} from "@phosphor-icons/react"; + +import { + DEFAULT_ATTENTION_PREFERENCES, + type AttentionPreferences, +} from "../../../shared/types"; +import { + attentionNotchSettingsFromPreferences, + normalizeAttentionPreferences, + readAttentionNotchEnabled, + writeAttentionNotchEnabled, +} from "./attentionNotchLocalSettings"; +import { useAccountStatus } from "../../lib/account"; + +const DESKTOP_FIRST_OPTIONS = [ + { value: 0, label: "Immediately" }, + { value: 30, label: "After 30 seconds" }, + { value: 120, label: "After 2 minutes" }, + { value: 300, label: "After 5 minutes" }, +] as const; + +type ToggleRowProps = { + icon: React.ElementType; + label: string; + description: string; + checked: boolean; + disabled?: boolean; + badge?: string; + onChange: (checked: boolean) => void; +}; + +function ToggleRow({ + icon: Icon, + label, + description, + checked, + disabled = false, + badge, + onChange, +}: ToggleRowProps) { + return ( +
+ + + + + + {label} + {badge ? {badge} : null} + + {description} + + +
+ ); +} + +export function AttentionSettingsPopover() { + const { status: accountStatus } = useAccountStatus(); + const accountOwnerId = accountStatus.signedIn ? accountStatus.userId : null; + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(null); + const [preferences, setPreferences] = + useState(DEFAULT_ATTENTION_PREFERENCES); + const [notchEnabled, setNotchEnabled] = useState(readAttentionNotchEnabled); + const reducedMotion = useReducedMotion() ?? false; + const rootRef = useRef(null); + const triggerRef = useRef(null); + const restoreTriggerFocusRef = useRef(false); + const accountOwnerRef = useRef(accountOwnerId); + const previousAccountOwnerRef = useRef(accountOwnerId); + const accountEffectMountedRef = useRef(false); + const requestGenerationRef = useRef(0); + accountOwnerRef.current = accountOwnerId; + if (previousAccountOwnerRef.current !== accountOwnerId) { + previousAccountOwnerRef.current = accountOwnerId; + requestGenerationRef.current += 1; + } + + const dialogElement = () => + rootRef.current?.querySelector('[role="dialog"]') ?? null; + + const closePopover = (returnFocus: boolean) => { + requestGenerationRef.current += 1; + restoreTriggerFocusRef.current = returnFocus; + setOpen(false); + }; + + useEffect(() => { + if (!accountEffectMountedRef.current) { + accountEffectMountedRef.current = true; + return; + } + restoreTriggerFocusRef.current = false; + setOpen(false); + setLoading(false); + setSaving(false); + setSaved(false); + setError(null); + setPreferences(DEFAULT_ATTENTION_PREFERENCES); + }, [accountOwnerId]); + + useEffect(() => { + if (!open) return; + const onPointerDown = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) closePopover(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") closePopover(true); + }; + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + // Land focus in the dialog on open so the whole panel is reachable without + // tabbing back through the page behind it. + useEffect(() => { + if (open) dialogElement()?.focus(); + }, [open]); + + // Keep Tab inside the popover while it is open; Escape and the footer + // buttons are the ways out. + const onDialogKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== "Tab") return; + const dialog = dialogElement(); + if (!dialog) return; + const focusable = Array.from( + dialog.querySelectorAll("button, select, [href], input, [tabindex]:not([tabindex='-1'])"), + ).filter((element) => !element.hasAttribute("disabled")); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + if (event.shiftKey && (active === first || active === dialog)) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && active === last) { + event.preventDefault(); + first.focus(); + } + }; + + const openSettings = () => { + if (open) { + closePopover(true); + return; + } + setOpen(true); + setLoading(true); + setError(null); + setSaved(false); + setNotchEnabled(readAttentionNotchEnabled()); + const ownerId = accountOwnerId; + const generation = requestGenerationRef.current + 1; + requestGenerationRef.current = generation; + const isCurrentRequest = () => + requestGenerationRef.current === generation + && accountOwnerRef.current === ownerId; + const api = window.ade?.attention; + if (!api || !ownerId) { + setError("Attention settings are unavailable in this ADE session."); + setLoading(false); + return; + } + void api.getPreferences(ownerId) + .then((nextPreferences) => { + if (!isCurrentRequest()) return; + setPreferences(normalizeAttentionPreferences(nextPreferences)); + }) + .catch((loadError: unknown) => { + if (!isCurrentRequest()) return; + setError( + loadError instanceof Error && loadError.message.trim() + ? loadError.message + : "ADE couldn’t load your Attention settings.", + ); + }) + .finally(() => { + if (isCurrentRequest()) setLoading(false); + }); + }; + + const updateAccount = ( + patch: Partial, + ) => { + setSaved(false); + setPreferences((current) => ({ + ...current, + account: { ...current.account, ...patch }, + })); + }; + + const desktopFirstDelay = preferences.account.desktopFirstEnabled + ? preferences.account.desktopFirstDelaySeconds + : 0; + + const save = async () => { + if (saving) return; + const ownerId = accountOwnerId; + const generation = requestGenerationRef.current; + const isCurrentRequest = () => + requestGenerationRef.current === generation + && accountOwnerRef.current === ownerId; + setSaving(true); + setSaved(false); + setError(null); + try { + const api = window.ade?.attention; + if (!api || !ownerId) { + throw new Error("Attention settings are unavailable in this ADE session."); + } + await api.putPreferences(ownerId, preferences); + if (!isCurrentRequest()) return; + writeAttentionNotchEnabled(notchEnabled); + await window.ade?.attentionNotch?.updateSettings( + attentionNotchSettingsFromPreferences(preferences, notchEnabled), + ); + setSaved(true); + window.setTimeout(() => setSaved(false), 1_800); + } catch (saveError) { + if (!isCurrentRequest()) return; + setError( + saveError instanceof Error && saveError.message.trim() + ? saveError.message + : "ADE couldn’t save your Attention settings.", + ); + } finally { + if (isCurrentRequest()) setSaving(false); + } + }; + + return ( +
+ + { + if (!restoreTriggerFocusRef.current) return; + restoreTriggerFocusRef.current = false; + triggerRef.current?.focus(); + }} + > + {open ? ( + +
+
+ + + + + Attention settings + One policy across every project + +
+ Account +
+ + {loading ? ( +
+ + Loading your preferences… +
+ ) : ( + <> +
+

Surfaces

+ + + updateAccount({ notificationsEnabled })} + /> + + updateAccount({ liveActivitiesEnabled })} + /> +
+ +
+

Delivery

+ + updateAccount({ soundsEnabled })} + /> + + updateAccount({ celebrationsEnabled })} + /> + updateAccount({ hideDetails })} + /> +
+ + )} + + {error ? ( +
+ + {error} +
+ ) : null} + +
+ + {saved ? <> Saved : "Changes apply across ADE"} + + + +
+
+ ) : null} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/attention/attentionNotchLocalSettings.ts b/apps/desktop/src/renderer/components/attention/attentionNotchLocalSettings.ts new file mode 100644 index 000000000..99b24a5d5 --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/attentionNotchLocalSettings.ts @@ -0,0 +1,64 @@ +import type { + AttentionNotchSettings, + AttentionPreferences, +} from "../../../shared/types"; +import { DEFAULT_ATTENTION_PREFERENCES } from "../../../shared/types"; + +const ATTENTION_NOTCH_ENABLED_KEY = "ade:attention:notch-enabled"; + +export function readAttentionNotchEnabled(): boolean { + if (typeof window === "undefined") return true; + try { + return window.localStorage.getItem(ATTENTION_NOTCH_ENABLED_KEY) !== "false"; + } catch { + return true; + } +} + +export function writeAttentionNotchEnabled(enabled: boolean): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(ATTENTION_NOTCH_ENABLED_KEY, String(enabled)); + } catch { + // A restricted renderer still keeps the setting for the current process + // through the native helper update issued by the caller. + } +} + +export function normalizeAttentionPreferences( + preferences: AttentionPreferences, +): AttentionPreferences { + return { + ...DEFAULT_ATTENTION_PREFERENCES, + ...preferences, + account: { + ...DEFAULT_ATTENTION_PREFERENCES.account, + ...preferences.account, + eventPolicies: { + ...DEFAULT_ATTENTION_PREFERENCES.account.eventPolicies, + ...preferences.account?.eventPolicies, + }, + quietHours: { + ...DEFAULT_ATTENTION_PREFERENCES.account.quietHours, + ...preferences.account?.quietHours, + }, + }, + devices: preferences.devices ?? {}, + projects: preferences.projects ?? {}, + mutedSessionIds: preferences.mutedSessionIds ?? [], + }; +} + +export function attentionNotchSettingsFromPreferences( + preferences: AttentionPreferences, + enabled = readAttentionNotchEnabled(), +): AttentionNotchSettings { + const normalized = normalizeAttentionPreferences(preferences); + return { + enabled, + preferredDisplayId: null, + hideDetails: normalized.account.hideDetails, + celebrationsEnabled: normalized.account.celebrationsEnabled, + soundsEnabled: normalized.account.soundsEnabled, + }; +} diff --git a/apps/desktop/src/renderer/components/attention/attentionPresentation.ts b/apps/desktop/src/renderer/components/attention/attentionPresentation.ts new file mode 100644 index 000000000..12e0f86a0 --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/attentionPresentation.ts @@ -0,0 +1,65 @@ +import type { AttentionActionKind, AttentionPhase } from "../../../shared/types"; + +export type AttentionTone = + | "amber" + | "red" + | "violet" + | "blue" + | "cyan" + | "emerald" + | "neutral"; + +const PHASE_PRESENTATION: Record< + AttentionPhase, + { label: string; tone: AttentionTone; active: boolean } +> = { + starting: { label: "Starting", tone: "blue", active: true }, + running: { label: "Working", tone: "blue", active: true }, + needs_you: { label: "Needs you", tone: "amber", active: true }, + blocked: { label: "Blocked", tone: "amber", active: true }, + failed: { label: "Failed", tone: "red", active: false }, + completed: { label: "Completed", tone: "emerald", active: false }, + stale: { label: "Stale", tone: "neutral", active: false }, + checks_failing: { label: "Checks failing", tone: "red", active: false }, + review_requested: { label: "Review requested", tone: "violet", active: false }, + changes_requested: { label: "Changes requested", tone: "red", active: false }, + merge_ready: { label: "Ready to merge", tone: "emerald", active: false }, + open: { label: "Open", tone: "blue", active: false }, + merged: { label: "Merged", tone: "emerald", active: false }, + closed: { label: "Closed", tone: "neutral", active: false }, +}; + +export function attentionPhasePresentation(phase: AttentionPhase) { + return PHASE_PRESENTATION[phase]; +} + +export function attentionActionTone( + kind: AttentionActionKind, +): "primary" | "danger" | "secondary" | "ghost" { + if (kind === "approve" || kind === "answer" || kind === "rerun_checks") return "primary"; + if (kind === "deny") return "danger"; + if (kind === "open" || kind === "restart") return "secondary"; + return "ghost"; +} + +export function attentionViewEmptyCopy(view: "live" | "inbox" | "recent"): { + title: string; + body: string; +} { + if (view === "inbox") { + return { + title: "You’re all caught up", + body: "Approvals, failures, review requests, and finished work you haven’t seen will collect here.", + }; + } + if (view === "recent") { + return { + title: "No recent outcomes", + body: "Completed and resolved work stays here for 24 hours after you review it.", + }; + } + return { + title: "No live work yet", + body: "Active agents and pull requests from every signed-in machine will appear here as they move.", + }; +} diff --git a/apps/desktop/src/renderer/components/attention/useAttentionSync.hook.test.tsx b/apps/desktop/src/renderer/components/attention/useAttentionSync.hook.test.tsx new file mode 100644 index 000000000..214217161 --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/useAttentionSync.hook.test.tsx @@ -0,0 +1,390 @@ +// @vitest-environment jsdom +// React hook integration coverage is kept under a distinct basename so TypeScript +// includes it alongside the transport-only useAttentionSync tests. + +import React from "react"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + ATTENTION_CONTRACT_VERSION, + DEFAULT_ATTENTION_PREFERENCES, + type AttentionItem, + type AttentionNotchSettings, + type AttentionSnapshot, +} from "../../../shared/types"; +import { + attentionStore, + resetAttentionStoreForTests, +} from "../../state/attentionStore"; +import { publishAccountStatus, SIGNED_OUT_ACCOUNT } from "../../lib/account"; +import { refreshAttentionSnapshot, useAttentionSync } from "./useAttentionSync"; + +const originalAde = window.ade; + +function liveItem(): AttentionItem { + return { + contractVersion: ATTENTION_CONTRACT_VERSION, + id: "live-account-item", + revision: 4, + fingerprint: "account-fingerprint", + kind: "agent", + eventKind: "agent_running", + phase: "running", + machine: { + machineKey: "studio", + name: "Studio Mac", + online: true, + lastSeenAt: "2026-07-28T14:00:00.000Z", + }, + project: { projectId: "ade", name: "ADE" }, + provider: "codex", + title: "Account work", + preview: "Running across machines", + privacyPreview: "Agent running", + destination: { kind: "session", sessionId: "session-account" }, + actions: [], + occurredAt: "2026-07-28T14:00:00.000Z", + updatedAt: "2026-07-28T14:00:00.000Z", + seenAt: null, + dismissedAt: null, + expiresAt: null, + }; +} + +function Harness() { + useAttentionSync(true); + return null; +} + +afterEach(() => { + cleanup(); + resetAttentionStoreForTests(); + publishAccountStatus(SIGNED_OUT_ACCOUNT); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: originalAde, + }); +}); + +describe("useAttentionSync", () => { + it("applies privacy settings before publishing the first native snapshot", async () => { + publishAccountStatus({ + signedIn: true, + userId: "user-settings-order", + email: null, + name: null, + expiresAt: null, + provider: null, + imageUrl: null, + }); + const calls: string[] = []; + const updateSettings = vi.fn(async () => { + calls.push("settings"); + }); + const publishSnapshot = vi.fn(async () => { + calls.push("snapshot"); + }); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + attention: { + getSnapshot: vi.fn(async (): Promise => ({ + contractVersion: ATTENTION_CONTRACT_VERSION, + revision: 0, + generatedAt: "2026-07-28T14:01:00.000Z", + items: [], + tombstones: [], + })), + acknowledge: vi.fn(), + reportPresence: vi.fn(), + getPreferences: vi.fn(async () => DEFAULT_ATTENTION_PREFERENCES), + putPreferences: vi.fn(), + }, + account: { + ...(originalAde?.account ?? {}), + status: vi.fn(async () => ({ + signedIn: true, + userId: "user-settings-order", + email: null, + name: null, + expiresAt: null, + provider: null, + imageUrl: null, + })), + }, + attentionNotch: { + publishSnapshot, + updateSettings, + setVisible: vi.fn(), + reanchor: vi.fn(), + onAcknowledgeRequested: vi.fn(() => () => {}), + }, + }, + }); + + render(); + + await waitFor(() => expect(publishSnapshot).toHaveBeenCalled()); + expect(calls.slice(0, 2)).toEqual(["settings", "snapshot"]); + }); + + it("keeps an in-flight account A preference fetch out of account B's notch stream", async () => { + publishAccountStatus({ + signedIn: true, + userId: "account-a", + email: null, + name: null, + expiresAt: null, + provider: null, + imageUrl: null, + }); + let resolveAccountAPreferences: + ((preferences: typeof DEFAULT_ATTENTION_PREFERENCES) => void) | null = null; + const accountAPreferences = { + ...DEFAULT_ATTENTION_PREFERENCES, + account: { + ...DEFAULT_ATTENTION_PREFERENCES.account, + hideDetails: false, + celebrationsEnabled: true, + soundsEnabled: true, + }, + }; + const accountBPreferences = { + ...DEFAULT_ATTENTION_PREFERENCES, + account: { + ...DEFAULT_ATTENTION_PREFERENCES.account, + hideDetails: false, + celebrationsEnabled: false, + soundsEnabled: false, + }, + }; + const getPreferences = vi.fn() + .mockImplementationOnce(() => new Promise((resolve) => { + resolveAccountAPreferences = resolve; + })) + .mockResolvedValue(accountBPreferences); + const accountAItem = liveItem(); + const accountBItem = { + ...liveItem(), + id: "account-b-item", + fingerprint: "account-b-fingerprint", + machine: { + ...liveItem().machine, + machineKey: "account-b-machine", + accountMachineKey: "canonical-account-b", + deviceId: "device-account-b", + }, + destination: { kind: "session" as const, sessionId: "session-account-b" }, + }; + const getSnapshot = vi.fn() + .mockResolvedValueOnce({ + contractVersion: ATTENTION_CONTRACT_VERSION, + streamId: "stream-a", + revision: 1, + generatedAt: "2026-07-28T14:01:00.000Z", + items: [accountAItem], + tombstones: [], + } satisfies AttentionSnapshot) + .mockResolvedValue({ + contractVersion: ATTENTION_CONTRACT_VERSION, + streamId: "stream-b", + revision: 1, + generatedAt: "2026-07-28T14:02:00.000Z", + items: [accountBItem], + tombstones: [], + } satisfies AttentionSnapshot); + const events: string[] = []; + const updateSettings = vi.fn(async (settings: AttentionNotchSettings) => { + const label = settings.hideDetails + ? "fail-closed" + : settings.soundsEnabled + ? "account-a" + : "account-b"; + events.push(`settings:${label}`); + }); + const publishSnapshot = vi.fn(async (snapshot: AttentionSnapshot) => { + events.push(`snapshot:${snapshot.streamId ?? "none"}`); + }); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + attention: { + getSnapshot, + acknowledge: vi.fn(), + reportPresence: vi.fn(), + getPreferences, + putPreferences: vi.fn(), + }, + attentionNotch: { + publishSnapshot, + updateSettings, + setVisible: vi.fn(), + reanchor: vi.fn(), + onAcknowledgeRequested: vi.fn(() => () => {}), + }, + }, + }); + + render(); + await waitFor(() => expect(getPreferences).toHaveBeenCalledTimes(1)); + events.length = 0; + + await act(async () => { + publishAccountStatus({ + signedIn: true, + userId: "account-b", + email: null, + name: null, + expiresAt: null, + provider: null, + imageUrl: null, + }); + }); + + await waitFor(() => { + expect(publishSnapshot.mock.calls.some( + ([snapshot]) => snapshot.streamId === "stream-b", + )).toBe(true); + }); + const firstAccountBSnapshot = events.indexOf("snapshot:stream-b"); + expect(firstAccountBSnapshot).toBeGreaterThan(0); + expect(events.slice(0, firstAccountBSnapshot)).toContain("settings:fail-closed"); + expect(events).toContain("settings:account-b"); + + const settingsCallsBeforeLateAccountA = updateSettings.mock.calls.length; + await act(async () => { + resolveAccountAPreferences?.(accountAPreferences); + await Promise.resolve(); + }); + expect(updateSettings).toHaveBeenCalledTimes(settingsCallsBeforeLateAccountA); + expect(events).not.toContain("settings:account-a"); + }); + + it("requests incremental snapshots from the latest account cursor", async () => { + const current = liveItem(); + attentionStore.setState({ + revision: 9, + itemsById: { [current.id]: current }, + }); + const getSnapshot = vi.fn(async (): Promise => ({ + contractVersion: ATTENTION_CONTRACT_VERSION, + revision: 10, + generatedAt: "2026-07-28T14:02:00.000Z", + items: [], + tombstones: [], + })); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + attention: { + getSnapshot, + acknowledge: vi.fn(), + reportPresence: vi.fn(), + getPreferences: vi.fn(), + putPreferences: vi.fn(), + }, + }, + }); + + await refreshAttentionSnapshot(); + + expect(getSnapshot).toHaveBeenCalledWith(9, null); + expect(attentionStore.getState().itemsById[current.id]).toBe(current); + expect(attentionStore.getState().revision).toBe(10); + }); + + it("hydrates the account snapshot and reports the visible desktop presence", async () => { + publishAccountStatus({ + signedIn: true, + userId: "user-account-snapshot", + email: null, + name: null, + expiresAt: null, + provider: null, + imageUrl: null, + }); + const snapshot: AttentionSnapshot = { + contractVersion: ATTENTION_CONTRACT_VERSION, + revision: 9, + generatedAt: "2026-07-28T14:01:00.000Z", + items: [liveItem()], + tombstones: [], + }; + const getSnapshot = vi.fn(async () => snapshot); + const reportPresence = vi.fn(async () => undefined); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + attention: { + getSnapshot, + acknowledge: vi.fn(), + reportPresence, + getPreferences: vi.fn(), + putPreferences: vi.fn(), + }, + account: { + ...(originalAde?.account ?? {}), + status: vi.fn(async () => ({ + signedIn: true, + userId: "user-account-snapshot", + email: null, + name: null, + expiresAt: null, + provider: null, + imageUrl: null, + })), + getLocalMachineIdentity: vi.fn(async () => ({ + machineKey: "studio", + deviceId: "desktop-device", + })), + listMachines: vi.fn(async () => ({ + state: "ok", + machines: [{ + machineKey: "studio", + deviceId: "desktop-device", + name: "Studio Mac", + platform: "macOS", + deviceType: "desktop", + reachableEndpoints: [], + lastSeenAt: Date.now(), + online: true, + }], + message: null, + })), + }, + }, + }); + + render(); + + await waitFor(() => { + expect(getSnapshot).toHaveBeenCalledWith(0, null); + expect(attentionStore.getState().itemsById["live-account-item"]).toBeTruthy(); + }); + await waitFor(() => { + expect(reportPresence).toHaveBeenCalled(); + const calls = reportPresence.mock.calls as unknown as Array<[{ + deviceId: string; + deviceName: string; + ambientSurfaceVisible: boolean; + visibleItemIds: string[]; + }]>; + expect(calls.some(([presence]) => + presence.deviceId === "desktop-device" + && presence.deviceName === "Studio Mac" + && presence.ambientSurfaceVisible + && presence.visibleItemIds.includes("live-account-item") + )).toBe(true); + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/attention/useAttentionSync.test.ts b/apps/desktop/src/renderer/components/attention/useAttentionSync.test.ts new file mode 100644 index 000000000..3d27be71e --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/useAttentionSync.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + DEFAULT_ATTENTION_PREFERENCES, + type AttentionItem, +} from "../../../shared/types"; +import { + attentionNotchSnapshotSignature, + attentionNotchSettingsFromPreferences, + materializeAttentionNotchSnapshot, +} from "./useAttentionSync"; +import { + attentionStore, + resetAttentionStoreForTests, +} from "../../state/attentionStore"; + +const runningItem: AttentionItem = { + contractVersion: 1, + id: "agent-running", + revision: 2, + fingerprint: "running:2", + kind: "agent", + eventKind: "agent_running", + phase: "running", + machine: { + machineKey: "machine-1", + name: "MacBook Pro", + online: true, + lastSeenAt: null, + }, + project: { + projectId: "project-1", + name: "ADE", + rootPath: "/projects/ADE", + }, + title: "Running", + preview: "Implementing Attention", + privacyPreview: "Agent is working", + destination: { + kind: "session", + sessionId: "session-1", + }, + actions: [], + occurredAt: "2026-07-28T12:00:00.000Z", + updatedAt: "2026-07-28T12:00:01.000Z", + seenAt: null, + dismissedAt: null, + expiresAt: null, +}; + +describe("Attention Notch renderer bridge", () => { + beforeEach(() => resetAttentionStoreForTests()); + + it("materializes the merged renderer state rather than forwarding a delta", () => { + attentionStore.setState({ + revision: 8, + generatedAt: "2026-07-28T12:00:02.000Z", + itemsById: { [runningItem.id]: runningItem }, + }); + expect(materializeAttentionNotchSnapshot()).toEqual({ + contractVersion: 1, + streamId: null, + revision: 8, + generatedAt: "2026-07-28T12:00:02.000Z", + items: [runningItem], + tombstones: [], + }); + }); + + it("maps account privacy, celebration, and sound preferences", () => { + expect(attentionNotchSettingsFromPreferences({ + ...DEFAULT_ATTENTION_PREFERENCES, + account: { + ...DEFAULT_ATTENTION_PREFERENCES.account, + hideDetails: true, + celebrationsEnabled: false, + soundsEnabled: false, + }, + }, true)).toEqual({ + enabled: true, + preferredDisplayId: null, + hideDetails: true, + celebrationsEnabled: false, + soundsEnabled: false, + }); + }); + + it("invalidates the native snapshot signature for presence and canonical routing changes", () => { + const originalSignature = attentionNotchSnapshotSignature({ + contractVersion: 1, + streamId: "account-stream", + revision: 8, + generatedAt: "2026-07-28T12:00:02.000Z", + items: [runningItem], + tombstones: [], + }); + const patchedSignature = attentionNotchSnapshotSignature({ + contractVersion: 1, + streamId: "account-stream", + revision: 8, + generatedAt: "2026-07-28T12:00:02.000Z", + items: [{ + ...runningItem, + machine: { + ...runningItem.machine, + accountMachineKey: "canonical-machine-1", + deviceId: "device-machine-1", + name: "MacBook Pro · Remote", + online: false, + lastSeenAt: "2026-07-28T12:05:00.000Z", + }, + }], + tombstones: [], + }); + + expect(patchedSignature).not.toBe(originalSignature); + }); +}); diff --git a/apps/desktop/src/renderer/components/attention/useAttentionSync.ts b/apps/desktop/src/renderer/components/attention/useAttentionSync.ts new file mode 100644 index 000000000..37954b6ab --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/useAttentionSync.ts @@ -0,0 +1,420 @@ +import { useEffect, useMemo, useRef } from "react"; + +import { + ATTENTION_CONTRACT_VERSION, + type AttentionNotchSettings, + type AttentionPresence, + type AttentionSnapshot, +} from "../../../shared/types"; +import { + acknowledgeAttentionItem, + attentionStore, + selectAttentionItems, + useAttentionStore, +} from "../../state/attentionStore"; +import { useAccountStatus } from "../../lib/account"; +import { + attentionNotchSettingsFromPreferences, + readAttentionNotchEnabled, +} from "./attentionNotchLocalSettings"; + +export { attentionNotchSettingsFromPreferences } from "./attentionNotchLocalSettings"; + +const POLL_INTERVAL_MS = 15_000; +const PRESENCE_INTERVAL_MS = 30_000; +const NOTCH_SETTINGS_REFRESH_MS = 60_000; +const MAX_VISIBLE_PRESENCE_ITEMS = 64; + +type AttentionAccountScope = { + generation: number; + ownerId: string; +}; + +let attentionAccountGeneration = 0; +let attentionAccountOwnerId: string | null = null; +let refreshPromise: { generation: number; promise: Promise } | null = null; +let identityPromise: Promise<{ deviceId: string; deviceName: string }> | null = null; +let notchSettingsRefreshPromise: { + scope: AttentionAccountScope; + promise: Promise; +} | null = null; +let notchSettingsRefreshed: { + scope: AttentionAccountScope; + at: number; +} | null = null; +let notchSettingsUpdateQueue: Promise = Promise.resolve(); + +function sameAccountScope( + left: AttentionAccountScope, + right: AttentionAccountScope, +): boolean { + return left.generation === right.generation && left.ownerId === right.ownerId; +} + +function isCurrentAccountScope(scope: AttentionAccountScope): boolean { + return scope.generation === attentionAccountGeneration + && scope.ownerId === attentionAccountOwnerId; +} + +function failClosedAttentionNotchSettings(): AttentionNotchSettings { + return { + enabled: readAttentionNotchEnabled(), + preferredDisplayId: null, + hideDetails: true, + celebrationsEnabled: false, + soundsEnabled: false, + }; +} + +function enqueueAttentionNotchSettingsUpdate( + scope: AttentionAccountScope, + settings: AttentionNotchSettings, +): Promise { + const notchApi = typeof window !== "undefined" ? window.ade?.attentionNotch : null; + if (!notchApi) return Promise.resolve(); + const update = notchSettingsUpdateQueue + .catch(() => { + // A failed older update must not prevent the current account from + // restoring a known-safe native helper state. + }) + .then(async () => { + if (!isCurrentAccountScope(scope)) return; + await notchApi.updateSettings(settings); + }); + notchSettingsUpdateQueue = update.catch(() => {}); + return update; +} + +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim()) return error.message.trim(); + return "ADE couldn’t refresh account attention."; +} + +export async function refreshAttentionSnapshot(): Promise { + const generation = attentionAccountGeneration; + const ownerId = attentionAccountOwnerId; + if (refreshPromise?.generation === generation) return refreshPromise.promise; + const api = typeof window !== "undefined" ? window.ade?.attention : null; + if (!api) { + attentionStore.getState().setSyncStatus("ready"); + return; + } + + attentionStore.getState().setSyncStatus("syncing"); + const promise = api + .getSnapshot( + attentionStore.getState().revision, + attentionStore.getState().streamId, + ) + .then((snapshot) => { + if ( + generation !== attentionAccountGeneration + || ownerId !== attentionAccountOwnerId + ) return; + attentionStore.getState().applySnapshot(snapshot); + if (ownerId) { + void refreshAttentionNotchSettings({ generation, ownerId }); + } + }) + .catch((error) => { + if (generation !== attentionAccountGeneration) return; + attentionStore.getState().setSyncStatus("error", errorMessage(error)); + }) + .finally(() => { + if (refreshPromise?.promise === promise) refreshPromise = null; + }); + refreshPromise = { generation, promise }; + return promise; +} + +export function materializeAttentionNotchSnapshot(): AttentionSnapshot { + const state = attentionStore.getState(); + return { + contractVersion: ATTENTION_CONTRACT_VERSION, + streamId: state.streamId, + revision: state.revision, + generatedAt: state.generatedAt ?? new Date().toISOString(), + items: Object.values(state.itemsById), + tombstones: [], + }; +} + +export function attentionNotchSnapshotSignature( + snapshot = materializeAttentionNotchSnapshot(), +): string { + return JSON.stringify([ + snapshot.streamId ?? null, + snapshot.revision, + ...[...snapshot.items] + .sort((left, right) => left.id.localeCompare(right.id)) + .map((item) => [ + item.id, + item.revision, + item.seenAt, + item.dismissedAt, + item.machine.machineKey, + item.machine.accountMachineKey ?? null, + item.machine.deviceId ?? null, + item.machine.name, + item.machine.online, + item.machine.lastSeenAt, + ]), + ]); +} + +async function publishAttentionNotchSnapshot(): Promise { + const api = typeof window !== "undefined" ? window.ade?.attentionNotch : null; + if (!api) return; + await api.publishSnapshot(materializeAttentionNotchSnapshot()); +} + +async function refreshAttentionNotchSettings( + scope: AttentionAccountScope, + force = false, +): Promise { + if (!isCurrentAccountScope(scope)) return; + if ( + notchSettingsRefreshPromise + && sameAccountScope(notchSettingsRefreshPromise.scope, scope) + ) { + return notchSettingsRefreshPromise.promise; + } + if ( + !force + && notchSettingsRefreshed + && sameAccountScope(notchSettingsRefreshed.scope, scope) + && Date.now() - notchSettingsRefreshed.at < NOTCH_SETTINGS_REFRESH_MS + ) return; + const attentionApi = typeof window !== "undefined" ? window.ade?.attention : null; + const notchApi = typeof window !== "undefined" ? window.ade?.attentionNotch : null; + if (!attentionApi || !notchApi) return; + const promise = attentionApi + .getPreferences(scope.ownerId) + .then(async (preferences) => { + if (!isCurrentAccountScope(scope)) return; + await enqueueAttentionNotchSettingsUpdate( + scope, + attentionNotchSettingsFromPreferences(preferences), + ); + }) + .then(() => { + if (!isCurrentAccountScope(scope)) return; + notchSettingsRefreshed = { scope, at: Date.now() }; + }) + .catch(() => { + // The fail-closed settings applied for this account remain in force if + // its preferences are temporarily unavailable. + }) + .finally(() => { + if (notchSettingsRefreshPromise?.promise === promise) { + notchSettingsRefreshPromise = null; + } + }); + notchSettingsRefreshPromise = { scope, promise }; + return promise; +} + +async function prepareAttentionNotchForAccount( + scope: AttentionAccountScope, +): Promise { + const notchApi = typeof window !== "undefined" ? window.ade?.attentionNotch : null; + if (!notchApi || !isCurrentAccountScope(scope)) return false; + try { + // Never let the previous account's privacy/animation/sound choices govern + // a new stream. Clear the old snapshot only after native presentation is + // private and quiet, then hydrate the new account's preferences. + await enqueueAttentionNotchSettingsUpdate(scope, failClosedAttentionNotchSettings()); + if (!isCurrentAccountScope(scope)) return false; + await publishAttentionNotchSnapshot(); + } catch { + return false; + } + if (!isCurrentAccountScope(scope)) return false; + await refreshAttentionNotchSettings(scope, true); + return isCurrentAccountScope(scope); +} + +function fallbackDeviceIdentity(): { deviceId: string; deviceName: string } { + const storageKey = "ade:attention:desktop-device-id"; + let deviceId = ""; + try { + deviceId = window.localStorage.getItem(storageKey) ?? ""; + if (!deviceId) { + deviceId = globalThis.crypto?.randomUUID?.() ?? `desktop-${Date.now().toString(36)}`; + window.localStorage.setItem(storageKey, deviceId); + } + } catch { + deviceId = `desktop-${Date.now().toString(36)}`; + } + return { deviceId, deviceName: "ADE Desktop" }; +} + +async function resolveDesktopIdentity(): Promise<{ deviceId: string; deviceName: string }> { + if (identityPromise) return identityPromise; + identityPromise = (async () => { + const fallback = fallbackDeviceIdentity(); + try { + const identity = await window.ade?.account?.getLocalMachineIdentity?.(); + if (!identity?.deviceId) return fallback; + let deviceName = fallback.deviceName; + try { + const directory = await window.ade?.account?.listMachines?.(); + const local = directory?.machines.find( + (machine) => + machine.deviceId === identity.deviceId + || machine.machineKey === identity.machineKey, + ); + deviceName = local?.name?.trim() || deviceName; + } catch { + // Presence remains useful with a generic device name. + } + return { deviceId: identity.deviceId, deviceName }; + } catch { + return fallback; + } + })(); + return identityPromise; +} + +function desktopPlatform(): AttentionPresence["platform"] { + if (typeof navigator === "undefined") return "unknown"; + return /Mac/i.test(navigator.userAgent || navigator.platform) ? "macOS" : "unknown"; +} + +async function reportPresence( + ambientSurfaceVisible: boolean, + visibleItemIds: string[], + foreground: boolean, +): Promise { + const api = window.ade?.attention; + if (!api) return; + const identity = await resolveDesktopIdentity(); + await api.reportPresence({ + ...identity, + platform: desktopPlatform(), + appForeground: foreground, + ambientSurfaceVisible, + visibleItemIds: ambientSurfaceVisible + ? visibleItemIds.slice(0, MAX_VISIBLE_PRESENCE_ITEMS) + : [], + observedAt: new Date().toISOString(), + }); +} + +/** + * Keeps the account-wide Attention snapshot and desktop presence warm even + * before the user opens the Attention route, so sidebar badges remain truthful. + */ +export function useAttentionSync(ambientSurfaceVisible: boolean): void { + const { status: accountStatus, loading: accountLoading } = useAccountStatus(); + const accountUserId = accountStatus.signedIn ? accountStatus.userId : null; + const itemsById = useAttentionStore((state) => state.itemsById); + const scope = useAttentionStore((state) => state.scope); + const view = useAttentionStore((state) => state.view); + const visibleItemIds = useMemo( + () => selectAttentionItems({ itemsById, scope, view }).map((item) => item.id), + [itemsById, scope, view], + ); + const visibleItemIdsKey = visibleItemIds.join("\u001f"); + const ambientSurfaceVisibleRef = useRef(ambientSurfaceVisible); + const visibleItemIdsRef = useRef(visibleItemIds); + const foregroundRef = useRef( + typeof document === "undefined" + ? false + : document.visibilityState === "visible" && document.hasFocus(), + ); + ambientSurfaceVisibleRef.current = ambientSurfaceVisible; + visibleItemIdsRef.current = visibleItemIds; + + useEffect(() => { + if (accountLoading) return; + if (attentionAccountOwnerId !== accountUserId) { + attentionAccountGeneration += 1; + attentionAccountOwnerId = accountUserId; + identityPromise = null; + notchSettingsRefreshPromise = null; + notchSettingsRefreshed = null; + attentionStore.getState().resetStream(); + } + if (!accountUserId) { + void publishAttentionNotchSnapshot().catch(() => {}); + return; + } + const accountScope = { + generation: attentionAccountGeneration, + ownerId: accountUserId, + }; + let lastNotchSignature = ""; + let active = true; + let unsubscribe = () => {}; + const publishNotchIfChanged = () => { + const nextSignature = attentionNotchSnapshotSignature(); + if (nextSignature === lastNotchSignature) return; + lastNotchSignature = nextSignature; + void publishAttentionNotchSnapshot().catch(() => {}); + }; + void prepareAttentionNotchForAccount(accountScope).then((prepared) => { + if (!active || !prepared || !isCurrentAccountScope(accountScope)) return; + unsubscribe = attentionStore.subscribe(publishNotchIfChanged); + publishNotchIfChanged(); + }); + const removeNotchAcknowledgeListener = + window.ade?.attentionNotch?.onAcknowledgeRequested((request) => { + void acknowledgeAttentionItem(request.itemId, request.mode) + .finally(publishNotchIfChanged); + }) ?? (() => {}); + void refreshAttentionSnapshot(); + const interval = window.setInterval(() => { + if (document.visibilityState === "visible") void refreshAttentionSnapshot(); + }, POLL_INTERVAL_MS); + const onVisibilityChange = () => { + foregroundRef.current = document.visibilityState === "visible" && document.hasFocus(); + if (foregroundRef.current) void refreshAttentionSnapshot(); + void reportPresence( + ambientSurfaceVisibleRef.current, + visibleItemIdsRef.current, + foregroundRef.current, + ).catch(() => {}); + }; + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + active = false; + window.clearInterval(interval); + document.removeEventListener("visibilitychange", onVisibilityChange); + removeNotchAcknowledgeListener(); + unsubscribe(); + }; + }, [accountLoading, accountUserId]); + + useEffect(() => { + if (accountLoading || !accountUserId) return; + const send = () => { + void reportPresence( + ambientSurfaceVisibleRef.current, + visibleItemIdsRef.current, + foregroundRef.current, + ).catch(() => {}); + }; + send(); + const interval = window.setInterval(send, PRESENCE_INTERVAL_MS); + const onFocus = () => { + foregroundRef.current = true; + send(); + }; + const onBlur = () => { + foregroundRef.current = false; + send(); + }; + window.addEventListener("focus", onFocus); + window.addEventListener("blur", onBlur); + return () => { + window.clearInterval(interval); + window.removeEventListener("focus", onFocus); + window.removeEventListener("blur", onBlur); + }; + }, [accountLoading, accountUserId, ambientSurfaceVisible, visibleItemIdsKey]); + + useEffect(() => () => { + if (accountUserId) void reportPresence(false, [], false).catch(() => {}); + }, [accountUserId]); +} diff --git a/apps/desktop/src/renderer/state/attentionStore.test.ts b/apps/desktop/src/renderer/state/attentionStore.test.ts new file mode 100644 index 000000000..bfd972be5 --- /dev/null +++ b/apps/desktop/src/renderer/state/attentionStore.test.ts @@ -0,0 +1,334 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + ATTENTION_CONTRACT_VERSION, + type AttentionItem, +} from "../../shared/types"; +import { + acknowledgeAttentionItem, + attentionStore, + resetAttentionStoreForTests, + selectAttentionCounts, + selectAttentionItems, + selectAttentionUnseenCount, +} from "./attentionStore"; + +const originalWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); + +function item( + id: string, + phase: AttentionItem["phase"], + patch: Partial = {}, +): AttentionItem { + const updatedAt = patch.updatedAt ?? "2026-07-28T14:00:00.000Z"; + return { + contractVersion: ATTENTION_CONTRACT_VERSION, + id, + revision: patch.revision ?? 1, + fingerprint: `fingerprint-${id}`, + kind: "agent", + eventKind: phase === "completed" ? "agent_completed" : "agent_needs_you", + phase, + machine: { + machineKey: "studio", + name: "Studio Mac", + online: true, + lastSeenAt: updatedAt, + }, + project: { projectId: "ade", name: "ADE", rootPath: "/repo/ade" }, + provider: "codex", + title: `Task ${id}`, + preview: "Working carefully", + privacyPreview: "Agent update", + destination: { kind: "session", sessionId: `session-${id}` }, + actions: [], + occurredAt: updatedAt, + updatedAt, + seenAt: null, + dismissedAt: null, + expiresAt: null, + ...patch, + }; +} + +afterEach(() => { + resetAttentionStoreForTests(); + if (originalWindowDescriptor) { + Object.defineProperty(globalThis, "window", originalWindowDescriptor); + } else { + Reflect.deleteProperty(globalThis, "window"); + } +}); + +describe("attentionStore", () => { + it("merges incremental snapshots and removes only explicit tombstones", () => { + attentionStore.getState().applySnapshot({ + contractVersion: ATTENTION_CONTRACT_VERSION, + revision: 10, + generatedAt: "2026-07-28T14:00:00.000Z", + items: [ + item("a", "running", { revision: 3 }), + item("b", "needs_you", { revision: 2 }), + ], + }); + attentionStore.getState().applySnapshot({ + contractVersion: ATTENTION_CONTRACT_VERSION, + revision: 11, + generatedAt: "2026-07-28T14:01:00.000Z", + items: [ + item("a", "blocked", { revision: 4 }), + item("c", "running", { revision: 1 }), + ], + }); + + expect(Object.keys(attentionStore.getState().itemsById).sort()).toEqual(["a", "b", "c"]); + expect(attentionStore.getState().itemsById.a?.phase).toBe("blocked"); + + attentionStore.getState().applySnapshot({ + contractVersion: ATTENTION_CONTRACT_VERSION, + revision: 12, + generatedAt: "2026-07-28T14:02:00.000Z", + items: [], + tombstones: [ + { + id: "b", + revision: 3, + deletedAt: "2026-07-28T14:02:00.000Z", + }, + ], + }); + + expect(Object.keys(attentionStore.getState().itemsById).sort()).toEqual(["a", "c"]); + }); + + it("keeps newer item revisions and honors tombstones", () => { + attentionStore.getState().upsertItem(item("a", "running", { revision: 3 })); + attentionStore.getState().upsertItem(item("a", "failed", { revision: 2 })); + expect(attentionStore.getState().itemsById.a?.phase).toBe("running"); + + attentionStore.getState().removeItem({ + id: "a", + revision: 3, + deletedAt: "2026-07-28T14:05:00.000Z", + }); + expect(attentionStore.getState().itemsById.a).toBeUndefined(); + + attentionStore.getState().upsertItem(item("a", "failed", { revision: 3 })); + expect(attentionStore.getState().itemsById.a).toBeUndefined(); + + attentionStore.getState().upsertItem(item("a", "failed", { revision: 4 })); + expect(attentionStore.getState().itemsById.a?.phase).toBe("failed"); + }); + + it("clears the prior account when the revision stream resets", () => { + attentionStore.getState().applySnapshot({ + contractVersion: ATTENTION_CONTRACT_VERSION, + revision: 40, + generatedAt: "2026-07-28T14:00:00.000Z", + items: [item("private-account-a", "needs_you")], + }); + attentionStore.setState({ + pendingAcknowledgements: { + "private-account-a": { + previous: item("private-account-a", "needs_you"), + seenAt: "2026-07-28T14:01:00.000Z", + }, + }, + acknowledgementErrors: { + "private-account-a": "Network unavailable", + }, + }); + + attentionStore.getState().applySnapshot({ + contractVersion: ATTENTION_CONTRACT_VERSION, + revision: 2, + generatedAt: "2026-07-28T14:02:00.000Z", + items: [item("account-b", "running")], + }); + + expect(Object.keys(attentionStore.getState().itemsById)).toEqual(["account-b"]); + expect(attentionStore.getState().pendingAcknowledgements).toEqual({}); + expect(attentionStore.getState().acknowledgementErrors).toEqual({}); + expect(attentionStore.getState().revision).toBe(2); + }); + + it.each([ + ["lower", 2], + ["equal", 40], + ["higher", 80], + ])("clears account A when account B has a %s revision", (_label, nextRevision) => { + attentionStore.getState().applySnapshot({ + contractVersion: ATTENTION_CONTRACT_VERSION, + streamId: "account-a", + revision: 40, + generatedAt: "2026-07-28T14:00:00.000Z", + items: [item("private-account-a", "needs_you")], + }); + + attentionStore.getState().applySnapshot({ + contractVersion: ATTENTION_CONTRACT_VERSION, + streamId: "account-b", + revision: nextRevision, + generatedAt: "2026-07-28T14:02:00.000Z", + items: [item("account-b", "running")], + }); + + expect(attentionStore.getState().streamId).toBe("account-b"); + expect(Object.keys(attentionStore.getState().itemsById)).toEqual(["account-b"]); + }); + + it("refreshes retained item presence without requiring a new item revision", () => { + const retained = item("remote-item", "running"); + attentionStore.getState().applySnapshot({ + contractVersion: ATTENTION_CONTRACT_VERSION, + streamId: "account-a", + revision: 3, + generatedAt: "2026-07-28T14:00:00.000Z", + items: [retained], + }); + + attentionStore.getState().applySnapshot({ + contractVersion: ATTENTION_CONTRACT_VERSION, + streamId: "account-a", + revision: 3, + generatedAt: "2026-07-28T14:02:00.000Z", + machines: [{ + ...retained.machine, + online: false, + lastSeenAt: "2026-07-28T13:58:00.000Z", + }], + items: [], + }); + + expect(attentionStore.getState().itemsById[retained.id]?.machine).toMatchObject({ + online: false, + lastSeenAt: "2026-07-28T13:58:00.000Z", + }); + }); + + it("filters global items by view and project scope", () => { + attentionStore.setState({ + itemsById: { + live: item("live", "running"), + inbox: item("inbox", "needs_you"), + recent: item("recent", "completed", { + seenAt: "2026-07-28T14:02:00.000Z", + updatedAt: "2026-07-28T14:02:00.000Z", + }), + other: item("other", "running", { + project: { projectId: "other-project", name: "Other" }, + }), + }, + scope: { kind: "project", projectId: "ade", label: "ADE" }, + view: "live", + }); + + expect(selectAttentionItems(attentionStore.getState()).map((entry) => entry.id)).toEqual([ + "inbox", + "live", + ]); + + attentionStore.getState().setView("recent"); + expect( + selectAttentionItems( + attentionStore.getState(), + Date.parse("2026-07-28T15:00:00.000Z"), + ).map((entry) => entry.id), + ).toEqual(["recent"]); + }); + + it("tracks scoped counts separately from the global unseen badge", () => { + attentionStore.setState({ + itemsById: { + needs: item("needs", "needs_you"), + seen: item("seen", "completed", { + seenAt: "2026-07-28T14:01:00.000Z", + }), + other: item("other", "needs_you", { + machine: { + machineKey: "laptop", + name: "Laptop", + online: true, + lastSeenAt: "2026-07-28T14:00:00.000Z", + }, + }), + }, + scope: { kind: "machine", machineKey: "studio", label: "Studio Mac" }, + }); + + expect(selectAttentionCounts(attentionStore.getState()).inbox).toBe(1); + expect(selectAttentionUnseenCount(attentionStore.getState())).toBe(2); + }); + + it("excludes expired work from views, counts, and the global badge", () => { + attentionStore.setState({ + itemsById: { + expired: item("expired", "needs_you", { + expiresAt: "2020-01-01T00:00:00.000Z", + }), + current: item("current", "running", { + expiresAt: "2099-01-01T00:00:00.000Z", + }), + }, + view: "live", + }); + const now = Date.parse("2026-07-28T14:00:00.000Z"); + + expect(selectAttentionItems(attentionStore.getState(), now).map((entry) => entry.id)).toEqual([ + "current", + ]); + expect(selectAttentionCounts(attentionStore.getState(), now)).toMatchObject({ + live: 1, + inbox: 0, + }); + expect(selectAttentionUnseenCount(attentionStore.getState())).toBe(0); + }); + + it("rolls back only acknowledgement fields when a newer snapshot arrives", async () => { + let rejectAcknowledgement: (error: Error) => void = () => {}; + const acknowledgement = new Promise((_, reject) => { + rejectAcknowledgement = reject; + }); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + ade: { + attention: { + acknowledge: vi.fn(() => acknowledgement), + }, + }, + }, + }); + attentionStore.setState({ + revision: 1, + itemsById: { + needs: item("needs", "needs_you"), + }, + }); + + const pending = acknowledgeAttentionItem("needs", "seen"); + expect(attentionStore.getState().itemsById.needs?.seenAt).not.toBeNull(); + + attentionStore.getState().applySnapshot({ + contractVersion: ATTENTION_CONTRACT_VERSION, + revision: 2, + generatedAt: "2026-07-28T14:03:00.000Z", + items: [ + item("needs", "blocked", { + revision: 2, + title: "New server title", + }), + ], + }); + rejectAcknowledgement(new Error("Network unavailable")); + await expect(pending).rejects.toThrow("Network unavailable"); + + expect(attentionStore.getState().itemsById.needs).toMatchObject({ + revision: 2, + phase: "blocked", + title: "New server title", + seenAt: null, + dismissedAt: null, + }); + }); +}); diff --git a/apps/desktop/src/renderer/state/attentionStore.ts b/apps/desktop/src/renderer/state/attentionStore.ts new file mode 100644 index 000000000..4394f3a56 --- /dev/null +++ b/apps/desktop/src/renderer/state/attentionStore.ts @@ -0,0 +1,379 @@ +import { useStore } from "zustand"; +import { createStore } from "zustand/vanilla"; + +import { + attentionItemIsLive, + attentionItemNeedsInbox, + sortAttentionItems, + type AttentionItem, + type AttentionSnapshot, + type AttentionTombstone, +} from "../../shared/types"; + +export type AttentionView = "live" | "inbox" | "recent"; + +export type AttentionScope = + | { kind: "all" } + | { kind: "machine"; machineKey: string; label: string } + | { kind: "project"; projectId: string; label: string; machineKey?: string | null }; + +export type AttentionSyncStatus = "idle" | "syncing" | "ready" | "error"; + +type PendingAttentionAcknowledgement = { + previous: AttentionItem; + seenAt: string; + dismissedAt?: string; +}; + +export type AttentionStoreState = { + streamId: string | null; + revision: number; + generatedAt: string | null; + itemsById: Record; + tombstonesById: Record; + view: AttentionView; + scope: AttentionScope; + selectedItemId: string | null; + syncStatus: AttentionSyncStatus; + syncError: string | null; + pendingAcknowledgements: Record; + acknowledgementErrors: Record; + resetStream: () => void; + applySnapshot: (snapshot: AttentionSnapshot) => void; + upsertItem: (item: AttentionItem) => void; + removeItem: (tombstone: AttentionTombstone) => void; + setView: (view: AttentionView) => void; + setScope: (scope: AttentionScope) => void; + selectItem: (itemId: string | null) => void; + setSyncStatus: (status: AttentionSyncStatus, error?: string | null) => void; + markSeen: (itemId: string, seenAt?: string) => void; + dismiss: (itemId: string, dismissedAt?: string) => void; +}; + +const RECENT_WINDOW_MS = 24 * 60 * 60 * 1_000; + +function itemMatchesScope(item: AttentionItem, scope: AttentionScope): boolean { + if (scope.kind === "all") return true; + if (scope.kind === "machine") return item.machine.machineKey === scope.machineKey; + return item.project.projectId === scope.projectId + && (!scope.machineKey || item.machine.machineKey === scope.machineKey); +} + +function isExpiredItem(item: AttentionItem, now: number): boolean { + if (!item.expiresAt) return false; + const expiresAt = Date.parse(item.expiresAt); + return Number.isFinite(expiresAt) && expiresAt <= now; +} + +function isRecentItem(item: AttentionItem, now: number): boolean { + if (item.dismissedAt || isExpiredItem(item, now) || attentionItemIsLive(item)) return false; + const timestamp = Date.parse(item.seenAt ?? item.updatedAt); + if (!Number.isFinite(timestamp)) return false; + return now - timestamp <= RECENT_WINDOW_MS; +} + +function sortRecentItems(items: readonly AttentionItem[]): AttentionItem[] { + return [...items].sort((left, right) => { + const timestamp = Date.parse(right.updatedAt) - Date.parse(left.updatedAt); + if (Number.isFinite(timestamp) && timestamp !== 0) return timestamp; + return left.id.localeCompare(right.id); + }); +} + +export function selectAttentionItems( + state: Pick, + now = Date.now(), +): AttentionItem[] { + const scoped = Object.values(state.itemsById).filter( + (item) => itemMatchesScope(item, state.scope) && !isExpiredItem(item, now), + ); + if (state.view === "live") { + return sortAttentionItems(scoped.filter((item) => !item.dismissedAt && attentionItemIsLive(item))); + } + if (state.view === "inbox") { + return sortAttentionItems(scoped.filter(attentionItemNeedsInbox)); + } + return sortRecentItems(scoped.filter((item) => isRecentItem(item, now))); +} + +export function selectAttentionCounts( + state: Pick, + now = Date.now(), +): Record { + const scoped = Object.values(state.itemsById).filter( + (item) => itemMatchesScope(item, state.scope) && !isExpiredItem(item, now), + ); + return { + live: scoped.filter((item) => !item.dismissedAt && attentionItemIsLive(item)).length, + inbox: scoped.filter(attentionItemNeedsInbox).length, + recent: scoped.filter((item) => isRecentItem(item, now)).length, + }; +} + +export function selectAttentionUnseenCount( + state: Pick, +): number { + const now = Date.now(); + return Object.values(state.itemsById).filter( + (item) => !isExpiredItem(item, now) && item.seenAt === null && attentionItemNeedsInbox(item), + ).length; +} + +function createInitialState(): Pick< + AttentionStoreState, + | "streamId" + | "revision" + | "generatedAt" + | "itemsById" + | "tombstonesById" + | "view" + | "scope" + | "selectedItemId" + | "syncStatus" + | "syncError" + | "pendingAcknowledgements" + | "acknowledgementErrors" +> { + return { + streamId: null, + revision: 0, + generatedAt: null, + itemsById: {}, + tombstonesById: {}, + view: "live", + scope: { kind: "all" }, + selectedItemId: null, + syncStatus: "idle", + syncError: null, + pendingAcknowledgements: {}, + acknowledgementErrors: {}, + }; +} + +export const attentionStore = createStore((set) => ({ + ...createInitialState(), + resetStream: () => set(createInitialState()), + applySnapshot: (snapshot) => + set((state) => { + // Account revisions are monotonic only inside one verified account. + // Stream identity is authoritative; the lower-revision check preserves + // safe behavior while older relays roll forward to stream-aware payloads. + const incomingStreamId = snapshot.streamId?.trim() || null; + const streamReset = ( + Boolean(state.streamId && incomingStreamId && state.streamId !== incomingStreamId) + || snapshot.revision < state.revision + ); + const tombstonesById = streamReset ? {} : { ...state.tombstonesById }; + for (const tombstone of snapshot.tombstones ?? []) { + const existing = tombstonesById[tombstone.id]; + if (!existing || tombstone.revision >= existing.revision) { + tombstonesById[tombstone.id] = tombstone; + } + } + const itemsById: Record = streamReset || state.revision === 0 + ? {} + : { ...state.itemsById }; + for (const tombstone of snapshot.tombstones ?? []) { + delete itemsById[tombstone.id]; + } + for (const item of snapshot.items) { + const tombstone = tombstonesById[item.id]; + if (!tombstone || item.revision > tombstone.revision) { + const pending = streamReset ? undefined : state.pendingAcknowledgements[item.id]; + itemsById[item.id] = pending + ? { + ...item, + seenAt: pending.seenAt, + ...(pending.dismissedAt ? { dismissedAt: pending.dismissedAt } : {}), + } + : item; + } + } + const presenceByMachine = new Map( + (snapshot.machines ?? []).map((machine) => [machine.machineKey, machine]), + ); + for (const [itemId, item] of Object.entries(itemsById)) { + const presence = presenceByMachine.get(item.machine.machineKey); + if (!presence) continue; + itemsById[itemId] = { + ...item, + machine: { + ...item.machine, + name: presence.name, + online: presence.online, + lastSeenAt: presence.lastSeenAt, + }, + }; + } + return { + streamId: incomingStreamId ?? (streamReset ? null : state.streamId), + revision: snapshot.revision, + generatedAt: snapshot.generatedAt, + itemsById, + tombstonesById, + syncStatus: "ready", + syncError: null, + pendingAcknowledgements: streamReset ? {} : state.pendingAcknowledgements, + acknowledgementErrors: streamReset ? {} : state.acknowledgementErrors, + selectedItemId: state.selectedItemId && itemsById[state.selectedItemId] + ? state.selectedItemId + : null, + }; + }), + upsertItem: (item) => + set((state) => { + const existing = state.itemsById[item.id]; + const tombstone = state.tombstonesById[item.id]; + if (existing && existing.revision >= item.revision) return state; + if (tombstone && tombstone.revision >= item.revision) return state; + return { + itemsById: { ...state.itemsById, [item.id]: item }, + }; + }), + removeItem: (tombstone) => + set((state) => { + const existingTombstone = state.tombstonesById[tombstone.id]; + const existingItem = state.itemsById[tombstone.id]; + if (existingTombstone && existingTombstone.revision >= tombstone.revision) return state; + if (existingItem && existingItem.revision > tombstone.revision) return state; + const itemsById = { ...state.itemsById }; + delete itemsById[tombstone.id]; + return { + itemsById, + tombstonesById: { ...state.tombstonesById, [tombstone.id]: tombstone }, + selectedItemId: state.selectedItemId === tombstone.id ? null : state.selectedItemId, + }; + }), + setView: (view) => set({ view, selectedItemId: null }), + setScope: (scope) => set({ scope, selectedItemId: null }), + selectItem: (selectedItemId) => set({ selectedItemId }), + setSyncStatus: (syncStatus, syncError = null) => set({ syncStatus, syncError }), + markSeen: (itemId, seenAt = new Date().toISOString()) => + set((state) => { + const item = state.itemsById[itemId]; + if (!item || item.seenAt) return state; + return { + itemsById: { + ...state.itemsById, + [itemId]: { ...item, seenAt }, + }, + }; + }), + dismiss: (itemId, dismissedAt = new Date().toISOString()) => + set((state) => { + const item = state.itemsById[itemId]; + if (!item || item.dismissedAt) return state; + return { + itemsById: { + ...state.itemsById, + [itemId]: { + ...item, + seenAt: item.seenAt ?? dismissedAt, + dismissedAt, + }, + }, + selectedItemId: state.selectedItemId === itemId ? null : state.selectedItemId, + }; + }), +})); + +export function useAttentionStore(selector: (state: AttentionStoreState) => T): T { + return useStore(attentionStore, selector); +} + +export function applyAttentionSnapshot(snapshot: AttentionSnapshot): void { + attentionStore.getState().applySnapshot(snapshot); +} + +export function upsertAttentionItem(item: AttentionItem): void { + attentionStore.getState().upsertItem(item); +} + +export function removeAttentionItem(tombstone: AttentionTombstone): void { + attentionStore.getState().removeItem(tombstone); +} + +export function resetAttentionStoreForTests(): void { + attentionStore.setState(createInitialState()); +} + +function attentionErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim()) return error.message.trim(); + return "ADE couldn’t update this attention item."; +} + +export async function acknowledgeAttentionItem( + itemId: string, + kind: "seen" | "dismiss", +): Promise { + const before = attentionStore.getState(); + const item = before.itemsById[itemId]; + if (!item || before.pendingAcknowledgements[itemId]) return; + + const timestamp = new Date().toISOString(); + const pending: PendingAttentionAcknowledgement = { + previous: item, + seenAt: timestamp, + ...(kind === "dismiss" ? { dismissedAt: timestamp } : {}), + }; + attentionStore.setState((state) => { + const acknowledgementErrors = { ...state.acknowledgementErrors }; + delete acknowledgementErrors[itemId]; + return { + pendingAcknowledgements: { + ...state.pendingAcknowledgements, + [itemId]: pending, + }, + acknowledgementErrors, + }; + }); + if (kind === "dismiss") attentionStore.getState().dismiss(itemId, timestamp); + else attentionStore.getState().markSeen(itemId, timestamp); + + try { + const api = typeof window !== "undefined" ? window.ade?.attention : null; + if (api) { + await api.acknowledge({ + itemIds: [itemId], + seenAt: timestamp, + ...(kind === "dismiss" ? { dismissedAt: timestamp } : {}), + }); + } + attentionStore.setState((state) => { + const pendingAcknowledgements = { ...state.pendingAcknowledgements }; + delete pendingAcknowledgements[itemId]; + return { pendingAcknowledgements }; + }); + } catch (error) { + const message = attentionErrorMessage(error); + attentionStore.setState((state) => { + const currentPending = state.pendingAcknowledgements[itemId]; + if (!currentPending || currentPending.seenAt !== timestamp) return state; + const current = state.itemsById[itemId]; + const canRollback = current + && current.seenAt === timestamp + && (kind !== "dismiss" || current.dismissedAt === timestamp); + const pendingAcknowledgements = { ...state.pendingAcknowledgements }; + delete pendingAcknowledgements[itemId]; + return { + pendingAcknowledgements, + itemsById: canRollback + ? { + ...state.itemsById, + [itemId]: { + ...current, + seenAt: currentPending.previous.seenAt, + dismissedAt: currentPending.previous.dismissedAt, + }, + } + : state.itemsById, + acknowledgementErrors: { + ...state.acknowledgementErrors, + [itemId]: message, + }, + selectedItemId: canRollback ? itemId : state.selectedItemId, + }; + }); + throw error; + } +} diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 3881fe4d9..dc4de3c1c 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -38,6 +38,15 @@ export const IPC = { appWriteClipboardImage: "ade.app.writeClipboardImage", appOpenPathInEditor: "ade.app.openPathInEditor", appLogDebugEvent: "ade.app.logDebugEvent", + attentionNotchPublishSnapshot: "ade.attentionNotch.publishSnapshot", + attentionNotchUpdateSettings: "ade.attentionNotch.updateSettings", + attentionNotchAcknowledgeRequested: "ade.attentionNotch.acknowledgeRequested", + attentionGetSnapshot: "ade.attention.getSnapshot", + attentionAcknowledge: "ade.attention.acknowledge", + attentionReportPresence: "ade.attention.reportPresence", + attentionGetPreferences: "ade.attention.getPreferences", + attentionPutPreferences: "ade.attention.putPreferences", + attentionOpenItem: "ade.attention.openItem", analyticsCapture: "ade.analytics.capture", analyticsGetStatus: "ade.analytics.getStatus", analyticsSetEnabled: "ade.analytics.setEnabled", diff --git a/apps/desktop/src/shared/types/attention.test.ts b/apps/desktop/src/shared/types/attention.test.ts new file mode 100644 index 000000000..281735e1d --- /dev/null +++ b/apps/desktop/src/shared/types/attention.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { + ATTENTION_CONTRACT_VERSION, + attentionDestinationDeepLink, + attentionItemNeedsInbox, + sanitizeAttentionPreview, + sortAttentionItems, + type AttentionItem, +} from "./attention"; + +function item(overrides: Partial = {}): AttentionItem { + return { + contractVersion: ATTENTION_CONTRACT_VERSION, + id: "agent:one", + revision: 1, + fingerprint: "one", + kind: "agent", + eventKind: "agent_needs_you", + phase: "needs_you", + machine: { machineKey: "machine", name: "Studio Mac", online: true, lastSeenAt: null }, + project: { projectId: "project", name: "ADE" }, + title: "Fix auth", + preview: "Approve the command", + privacyPreview: "Agent needs attention", + destination: { kind: "session", sessionId: "session" }, + actions: [], + occurredAt: "2026-07-28T10:00:00.000Z", + updatedAt: "2026-07-28T10:00:00.000Z", + seenAt: null, + dismissedAt: null, + expiresAt: null, + ...overrides, + }; +} + +describe("attention contract helpers", () => { + it("prioritizes needs-you and failures before running and completed work", () => { + expect( + sortAttentionItems([ + item({ id: "done", phase: "completed", eventKind: "agent_completed" }), + item({ id: "running", phase: "running" }), + item({ id: "failed", phase: "failed", eventKind: "agent_failed" }), + item({ id: "needs", phase: "needs_you" }), + ]).map((entry) => entry.id), + ).toEqual(["needs", "failed", "running", "done"]); + }); + + it("keeps unseen outcomes in Inbox and respects dismissal", () => { + expect(attentionItemNeedsInbox(item({ phase: "completed", eventKind: "agent_completed" }))).toBe(true); + expect(attentionItemNeedsInbox(item({ + phase: "completed", + eventKind: "agent_completed", + seenAt: "2026-07-28T10:05:00.000Z", + }))).toBe(false); + expect(attentionItemNeedsInbox(item({ dismissedAt: "2026-07-28T10:05:00.000Z" }))).toBe(false); + }); + + it("builds exact session and PR deep links", () => { + expect(attentionDestinationDeepLink({ + kind: "session", + sessionId: "session one", + itemId: "approval-1", + })).toBe("ade://session/session%20one?item=approval-1"); + expect(attentionDestinationDeepLink({ + kind: "pull_request", + repoOwner: "open ai", + repoName: "ade", + number: 42, + tab: "checks", + })).toBe("ade://pr/open%20ai/ade/42?tab=checks"); + }); + + it("sanitizes common secret shapes and bounds lock-screen copy", () => { + const preview = sanitizeAttentionPreview( + "Use Bearer abcdefghijklmnopqrstuvwxyz and ghp_abcdefghijklmnopqrstuvwxyz123456 for the request", + 64, + ); + expect(preview).not.toContain("abcdefghijklmnopqrstuvwxyz"); + expect(preview.length).toBeLessThanOrEqual(64); + }); +}); diff --git a/apps/desktop/src/shared/types/attention.ts b/apps/desktop/src/shared/types/attention.ts new file mode 100644 index 000000000..331ffcde5 --- /dev/null +++ b/apps/desktop/src/shared/types/attention.ts @@ -0,0 +1,323 @@ +export const ATTENTION_CONTRACT_VERSION = 1 as const; + +export type AttentionItemKind = "agent" | "pull_request"; + +export type AttentionPhase = + | "starting" + | "running" + | "needs_you" + | "blocked" + | "failed" + | "completed" + | "stale" + | "checks_failing" + | "review_requested" + | "changes_requested" + | "merge_ready" + | "open" + | "merged" + | "closed"; + +export type AttentionEventKind = + | "agent_running" + | "agent_needs_you" + | "agent_failed" + | "agent_completed" + | "pr_checks_failing" + | "pr_review_requested" + | "pr_changes_requested" + | "pr_merge_ready" + | "pr_merged" + | "pr_opened" + | "pr_closed"; + +export type AttentionDeliveryPolicy = "off" | "ambient" | "notify"; + +export type AttentionMachineRef = { + /** Source identity used to authenticate this machine's Attention publisher. */ + machineKey: string; + /** Canonical account-directory/sync-relay identity used for remote routing. */ + accountMachineKey?: string | null; + /** Stable ADE device identity when the publisher can resolve it. */ + deviceId?: string | null; + name: string; + online: boolean; + lastSeenAt: string | null; +}; + +export type AttentionProjectRef = { + projectId: string; + name: string; + rootPath?: string | null; +}; + +export type AttentionSessionDestination = { + kind: "session"; + sessionId: string; + itemId?: string | null; + eventId?: string | null; +}; + +export type AttentionPullRequestDestination = { + kind: "pull_request"; + prId?: string | null; + repoOwner?: string | null; + repoName?: string | null; + number: number; + tab: "overview" | "activity" | "checks" | "files"; + eventId?: string | null; +}; + +export type AttentionDestination = + | AttentionSessionDestination + | AttentionPullRequestDestination; + +export type AttentionActionKind = + | "approve" + | "deny" + | "answer" + | "restart" + | "rerun_checks" + | "mark_seen" + | "dismiss" + | "open"; + +export type AttentionAction = { + id: string; + kind: AttentionActionKind; + label: string; + destructive?: boolean; + payload?: Record; +}; + +export type AttentionItem = { + contractVersion: typeof ATTENTION_CONTRACT_VERSION; + id: string; + revision: number; + fingerprint: string; + kind: AttentionItemKind; + eventKind: AttentionEventKind; + phase: AttentionPhase; + machine: AttentionMachineRef; + project: AttentionProjectRef; + laneId?: string | null; + laneName?: string | null; + provider?: string | null; + model?: string | null; + title: string; + preview: string; + privacyPreview: string; + detail?: string | null; + recentActivity?: string[]; + planProgress?: { + completed: number; + total: number; + current?: string | null; + } | null; + destination: AttentionDestination; + actions: AttentionAction[]; + occurredAt: string; + updatedAt: string; + seenAt: string | null; + dismissedAt: string | null; + expiresAt: string | null; +}; + +export type AttentionTombstone = { + id: string; + revision: number; + deletedAt: string; +}; + +export type AttentionSnapshot = { + contractVersion: typeof ATTENTION_CONTRACT_VERSION; + /** + * Opaque authenticated account stream identity. Revisions are monotonic only + * inside one stream, so clients must reset atomically when this changes. + */ + streamId?: string | null; + revision: number; + generatedAt: string; + /** Current account-machine presence, returned even when no items changed. */ + machines?: AttentionMachineRef[]; + items: AttentionItem[]; + tombstones?: AttentionTombstone[]; +}; + +export type AttentionPresence = { + deviceId: string; + deviceName: string; + platform: "macOS" | "iOS" | "web" | "unknown"; + appForeground: boolean; + ambientSurfaceVisible: boolean; + visibleItemIds: string[]; + observedAt: string; +}; + +export type AttentionPreferenceScope = { + eventPolicies: Record; + notificationsEnabled: boolean; + liveActivitiesEnabled: boolean; + desktopFirstEnabled: boolean; + desktopFirstDelaySeconds: number; + soundsEnabled: boolean; + celebrationsEnabled: boolean; + hideDetails: boolean; + quietHours: { + enabled: boolean; + startMinute: number; + endMinute: number; + timeZone: string; + }; +}; + +export type AttentionPreferences = { + account: AttentionPreferenceScope; + devices: Record>; + projects: Record>; + mutedSessionIds: string[]; +}; + +export type AttentionNotchSettings = { + enabled: boolean; + preferredDisplayId?: number | null; + hideDetails: boolean; + celebrationsEnabled: boolean; + soundsEnabled: boolean; +}; + +export type AttentionNotchAcknowledgeRequest = { + itemId: string; + mode: "seen" | "dismiss"; +}; + +export const BALANCED_ATTENTION_EVENT_POLICIES: Record< + AttentionEventKind, + AttentionDeliveryPolicy +> = { + agent_running: "ambient", + agent_needs_you: "notify", + agent_failed: "notify", + agent_completed: "ambient", + pr_checks_failing: "notify", + pr_review_requested: "notify", + pr_changes_requested: "notify", + pr_merge_ready: "notify", + pr_merged: "ambient", + pr_opened: "ambient", + pr_closed: "ambient", +}; + +export const DEFAULT_ATTENTION_PREFERENCES: AttentionPreferences = { + account: { + eventPolicies: BALANCED_ATTENTION_EVENT_POLICIES, + notificationsEnabled: true, + liveActivitiesEnabled: true, + desktopFirstEnabled: true, + desktopFirstDelaySeconds: 30, + soundsEnabled: false, + celebrationsEnabled: true, + hideDetails: false, + quietHours: { + enabled: false, + startMinute: 22 * 60, + endMinute: 8 * 60, + timeZone: "UTC", + }, + }, + devices: {}, + projects: {}, + mutedSessionIds: [], +}; + +const ATTENTION_PHASE_PRIORITY: Record = { + needs_you: 0, + failed: 1, + checks_failing: 1, + changes_requested: 1, + review_requested: 2, + merge_ready: 2, + blocked: 2, + starting: 3, + running: 3, + open: 4, + stale: 4, + completed: 5, + merged: 5, + closed: 6, +}; + +export function attentionPhasePriority(phase: AttentionPhase): number { + return ATTENTION_PHASE_PRIORITY[phase]; +} + +export function sortAttentionItems(items: readonly AttentionItem[]): AttentionItem[] { + return [...items].sort((left, right) => { + const priority = attentionPhasePriority(left.phase) - attentionPhasePriority(right.phase); + if (priority !== 0) return priority; + const timestamp = Date.parse(right.updatedAt) - Date.parse(left.updatedAt); + if (Number.isFinite(timestamp) && timestamp !== 0) return timestamp; + return left.id.localeCompare(right.id); + }); +} + +export function attentionItemNeedsInbox(item: AttentionItem): boolean { + if (item.dismissedAt) return false; + if ( + item.phase === "needs_you" + || item.phase === "failed" + || item.phase === "checks_failing" + || item.phase === "changes_requested" + || item.phase === "review_requested" + || item.phase === "merge_ready" + ) { + return true; + } + return (item.phase === "completed" || item.phase === "merged") && item.seenAt === null; +} + +export function attentionItemIsLive(item: AttentionItem): boolean { + return ( + item.phase === "starting" + || item.phase === "running" + || item.phase === "needs_you" + || item.phase === "blocked" + || item.phase === "failed" + || item.phase === "stale" + || item.phase === "checks_failing" + || item.phase === "review_requested" + || item.phase === "changes_requested" + || item.phase === "merge_ready" + ); +} + +export function attentionDestinationDeepLink(destination: AttentionDestination): string { + if (destination.kind === "session") { + const query = new URLSearchParams(); + if (destination.itemId) query.set("item", destination.itemId); + if (destination.eventId) query.set("event", destination.eventId); + const suffix = query.size > 0 ? `?${query.toString()}` : ""; + return `ade://session/${encodeURIComponent(destination.sessionId)}${suffix}`; + } + + const query = new URLSearchParams(); + if (destination.tab !== "overview") query.set("tab", destination.tab); + if (destination.eventId) query.set("event", destination.eventId); + const suffix = query.size > 0 ? `?${query.toString()}` : ""; + if (destination.repoOwner && destination.repoName) { + return `ade://pr/${encodeURIComponent(destination.repoOwner)}/${encodeURIComponent( + destination.repoName, + )}/${destination.number}${suffix}`; + } + return `ade://pr/${destination.number}${suffix}`; +} + +export function sanitizeAttentionPreview(value: string, maxLength = 160): string { + const normalized = value + .replace(/\b(?:sk|pk|ghp|github_pat|xox[baprs])_[A-Za-z0-9_-]{12,}\b/gi, "[redacted]") + .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}\b/gi, "Bearer [redacted]") + .replace(/\s+/g, " ") + .trim(); + if (normalized.length <= maxLength) return normalized; + return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`; +} diff --git a/apps/desktop/src/shared/types/index.ts b/apps/desktop/src/shared/types/index.ts index c2298f118..f0341fda1 100644 --- a/apps/desktop/src/shared/types/index.ts +++ b/apps/desktop/src/shared/types/index.ts @@ -38,3 +38,4 @@ export * from "./externalSessions"; export * from "./recovery"; export * from "./productAnalytics"; export * from "./account"; +export * from "./attention"; diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index a7f2f4346..58f81f35b 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -1641,6 +1641,7 @@ ADE_POSTHOG_HOST = ""; ADE_POSTHOG_PROJECT_TOKEN = ""; ADE_ACCOUNT_DIRECTORY_BASE_URL = "https://ade-account-directory.arulsharma1028.workers.dev"; + ADE_PUSH_RELAY_BASE_URL = "https://ade-push-relay.arulsharma1028.workers.dev"; CLERK_PUBLISHABLE_KEY = "pk_test_Y29oZXJlbnQtZm94aG91bmQtNjIuY2xlcmsuYWNjb3VudHMuZGV2JA"; CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGNING_ALLOWED = YES; @@ -1722,6 +1723,7 @@ ADE_POSTHOG_HOST = ""; ADE_POSTHOG_PROJECT_TOKEN = ""; ADE_ACCOUNT_DIRECTORY_BASE_URL = "https://ade-account-directory-production.arulsharma1028.workers.dev"; + ADE_PUSH_RELAY_BASE_URL = "https://ade-push-relay.arulsharma1028.workers.dev"; CLERK_PUBLISHABLE_KEY = "pk_live_Y2xlcmsuYWRlLWFwcC5kZXYk"; CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGNING_ALLOWED = YES; diff --git a/apps/ios/ADE/App/ADEApp.swift b/apps/ios/ADE/App/ADEApp.swift index 4bc2d2852..9a14f4199 100644 --- a/apps/ios/ADE/App/ADEApp.swift +++ b/apps/ios/ADE/App/ADEApp.swift @@ -65,6 +65,7 @@ struct ADEApp: App { // process while the bridge wasn't reachable (cold launch drains via // register(); this covers warm foregrounds). Task { await ADEIntentCommandRegistry.drainPendingCommands() } + Task { await accountService.refreshAttentionSnapshot() } guard didBootstrapSync else { return } let now = Date() guard now.timeIntervalSince(lastActivationSyncAt) > 1.0 else { return } diff --git a/apps/ios/ADE/App/ContentView.swift b/apps/ios/ADE/App/ContentView.swift index 101a6dc44..9c156898e 100644 --- a/apps/ios/ADE/App/ContentView.swift +++ b/apps/ios/ADE/App/ContentView.swift @@ -186,8 +186,12 @@ struct ContentView: View { selectedTab = .work } } - .onChange(of: syncService.requestedPrNavigation?.id) { _, requestId in - guard requestId != nil else { return } + .task(id: syncService.requestedPrNavigation?.id) { + guard let request = syncService.requestedPrNavigation, + await syncService.ensureAccountMachineForNavigation( + request.accountMachineKey + ), + syncService.requestedPrNavigation?.id == request.id else { return } syncService.closeProjectHub() if selectedTab != .prs { selectedTab = .prs @@ -293,6 +297,10 @@ private struct WorkSessionNavigationModifier: ViewModifier { // changes; onChange alone misses the initial value before this root mounts. content.task(id: syncService.requestedWorkSessionNavigation?.id) { guard let request = syncService.requestedWorkSessionNavigation else { return } + guard await syncService.ensureAccountMachineForNavigation( + request.accountMachineKey + ), + syncService.requestedWorkSessionNavigation?.id == request.id else { return } // A scoped or roster-resolved session may belong to any project. Keep // the machine-wide Hub mounted so it can activate and hydrate the target. if syncService.navigationDestination(request) == .hub { diff --git a/apps/ios/ADE/App/DeepLinkRouter.swift b/apps/ios/ADE/App/DeepLinkRouter.swift index 2b9ab690b..0d5343e0e 100644 --- a/apps/ios/ADE/App/DeepLinkRouter.swift +++ b/apps/ios/ADE/App/DeepLinkRouter.swift @@ -51,6 +51,9 @@ final class DeepLinkRouter { repoOwner: scope.repoOwner, repoName: scope.repoName, branch: scope.branch, + accountMachineKey: scope.accountMachineKey, + itemId: scope.itemId, + eventId: scope.eventId, event: scope.event, offset: scope.offset ) @@ -71,12 +74,20 @@ final class DeepLinkRouter { prNumber: number, repoOwner: owner, repoName: repo, - detailTab: prDetailTab(from: url) + detailTab: prDetailTab(from: url), + accountMachineKey: accountMachineKey(from: url), + eventId: eventId(from: url) ) return } guard let raw = pathComponents.first, !raw.isEmpty else { return } - post(kind: "pr", identifier: raw, detailTab: prDetailTab(from: url)) + post( + kind: "pr", + identifier: raw, + detailTab: prDetailTab(from: url), + accountMachineKey: accountMachineKey(from: url), + eventId: eventId(from: url) + ) case "lane": // Lanes are a local-only desktop concept — the iOS client has no // counterpart UI, so we surface a "Send to your Mac" card instead of @@ -175,6 +186,9 @@ final class DeepLinkRouter { repoOwner: scope.repoOwner, repoName: scope.repoName, branch: scope.branch, + accountMachineKey: scope.accountMachineKey, + itemId: scope.itemId, + eventId: scope.eventId, event: scope.event, offset: scope.offset ) @@ -194,9 +208,24 @@ final class DeepLinkRouter { postSendToMac(url: url) case "pr": guard let number = ADEDeepLinkURLParsing.positiveInteger(query["number"]) else { return true } + if let accountMachineKey = query["accountmachinekey"], + !ADEDeepLinkURLParsing.isValidOpaqueId(accountMachineKey) { + return true + } + if let eventId = query["event"], + !ADEDeepLinkURLParsing.isValidOpaqueId(eventId) { + return true + } let detailTab = prDetailTab(from: query["tab"]) if query["repo"]?.isEmpty ?? true { - post(kind: "pr", identifier: "\(number)", prNumber: number, detailTab: detailTab) + post( + kind: "pr", + identifier: "\(number)", + prNumber: number, + detailTab: detailTab, + accountMachineKey: query["accountmachinekey"], + eventId: query["event"] + ) return true } guard let repo = ADEDeepLinkURLParsing.splitRepo(query["repo"]) else { return true } @@ -206,7 +235,9 @@ final class DeepLinkRouter { prNumber: number, repoOwner: repo.owner, repoName: repo.repo, - detailTab: detailTab + detailTab: detailTab, + accountMachineKey: query["accountmachinekey"], + eventId: query["event"] ) case "linear-issue": guard let identifier = query["issue"], @@ -235,7 +266,10 @@ final class DeepLinkRouter { laneId: stringValue(from: userInfo["laneId"]), repoOwner: stringValue(from: userInfo["repoOwner"]), repoName: stringValue(from: userInfo["repoName"]), - branch: stringValue(from: userInfo["branch"]) + branch: stringValue(from: userInfo["branch"]), + accountMachineKey: stringValue(from: userInfo["accountMachineKey"]), + itemId: stringValue(from: userInfo["itemId"]), + eventId: stringValue(from: userInfo["eventId"]) ) return } @@ -244,7 +278,9 @@ final class DeepLinkRouter { kind: "pr", identifier: prId, prNumber: prNumberValue(from: userInfo["prNumber"]), - detailTab: prDetailTab(from: stringValue(from: userInfo["detailTab"])) + detailTab: prDetailTab(from: stringValue(from: userInfo["detailTab"])), + accountMachineKey: stringValue(from: userInfo["accountMachineKey"]), + eventId: stringValue(from: userInfo["eventId"]) ) return } @@ -257,7 +293,9 @@ final class DeepLinkRouter { prNumber: prNumberValue(from: userInfo["prNumber"]), repoOwner: stringValue(from: userInfo["repoOwner"]), repoName: stringValue(from: userInfo["repoName"]), - detailTab: prDetailTab(from: stringValue(from: userInfo["detailTab"])) + detailTab: prDetailTab(from: stringValue(from: userInfo["detailTab"])), + accountMachineKey: stringValue(from: userInfo["accountMachineKey"]), + eventId: stringValue(from: userInfo["eventId"]) ) } } @@ -271,6 +309,9 @@ final class DeepLinkRouter { repoName: String? = nil, detailTab: PrDetailTab? = nil, branch: String? = nil, + accountMachineKey: String? = nil, + itemId: String? = nil, + eventId: String? = nil, event: Int? = nil, offset: Int? = nil ) { @@ -295,11 +336,20 @@ final class DeepLinkRouter { let scopedRepoOwner = repoOwner?.trimmingCharacters(in: .whitespacesAndNewlines) let scopedRepoName = repoName?.trimmingCharacters(in: .whitespacesAndNewlines) let scopedBranch = branch?.trimmingCharacters(in: .whitespacesAndNewlines) + let scopedAccountMachineKey = accountMachineKey? + .trimmingCharacters(in: .whitespacesAndNewlines) + let scopedItemId = itemId?.trimmingCharacters(in: .whitespacesAndNewlines) + let scopedEventId = eventId?.trimmingCharacters(in: .whitespacesAndNewlines) if let scopedLaneId, !scopedLaneId.isEmpty { userInfo["laneId"] = scopedLaneId } if let scopedRepoOwner, !scopedRepoOwner.isEmpty { userInfo["repoOwner"] = scopedRepoOwner } if let scopedRepoName, !scopedRepoName.isEmpty { userInfo["repoName"] = scopedRepoName } if let detailTab { userInfo["detailTab"] = detailTab.rawValue } if let scopedBranch, !scopedBranch.isEmpty { userInfo["branch"] = scopedBranch } + if let scopedAccountMachineKey, !scopedAccountMachineKey.isEmpty { + userInfo["accountMachineKey"] = scopedAccountMachineKey + } + if let scopedItemId, !scopedItemId.isEmpty { userInfo["itemId"] = scopedItemId } + if let scopedEventId, !scopedEventId.isEmpty { userInfo["eventId"] = scopedEventId } NotificationCenter.default.post( name: .adeDeepLinkRequested, object: nil, @@ -312,6 +362,9 @@ final class DeepLinkRouter { repoOwner: scopedRepoOwner, repoName: scopedRepoName, branch: scopedBranch, + accountMachineKey: scopedAccountMachineKey, + itemId: scopedItemId, + eventId: scopedEventId, event: event, offset: offset ) @@ -327,18 +380,24 @@ final class DeepLinkRouter { prNumber: number, repoOwner: scopedRepoOwner, repoName: scopedRepoName, - detailTab: detailTab + detailTab: detailTab, + accountMachineKey: scopedAccountMachineKey, + eventId: scopedEventId ) } else if let prId = resolvePrId(from: trimmed) { SyncService.shared?.requestedPrNavigation = PrNavigationRequest( prId: prId, prNumber: prNumber ?? Int(trimmed), - detailTab: detailTab + detailTab: detailTab, + accountMachineKey: scopedAccountMachineKey, + eventId: scopedEventId ) } else if let prNumber = Int(trimmed), prNumber > 0 { SyncService.shared?.requestedPrNavigation = PrNavigationRequest( prNumber: prNumber, - detailTab: detailTab + detailTab: detailTab, + accountMachineKey: scopedAccountMachineKey, + eventId: scopedEventId ) } } @@ -353,6 +412,24 @@ final class DeepLinkRouter { ) } + private func accountMachineKey(from url: URL) -> String? { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return nil + } + let value = ADEDeepLinkURLParsing.adeQueryValues(from: components)["accountmachinekey"] + guard ADEDeepLinkURLParsing.isValidOpaqueId(value) else { return nil } + return value + } + + private func eventId(from url: URL) -> String? { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return nil + } + let value = ADEDeepLinkURLParsing.adeQueryValues(from: components)["event"] + guard ADEDeepLinkURLParsing.isValidOpaqueId(value) else { return nil } + return value + } + private func prDetailTab(from rawValue: String?) -> PrDetailTab? { switch rawValue?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { case "overview", "activity": @@ -368,7 +445,17 @@ final class DeepLinkRouter { private func sessionNavigationScope( from url: URL - ) -> (laneId: String?, repoOwner: String?, repoName: String?, branch: String?, event: Int?, offset: Int?)? { + ) -> ( + laneId: String?, + repoOwner: String?, + repoName: String?, + branch: String?, + accountMachineKey: String?, + itemId: String?, + eventId: String?, + event: Int?, + offset: Int? + )? { guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil } @@ -377,7 +464,17 @@ final class DeepLinkRouter { private func sessionNavigationScope( from query: [String: String] - ) -> (laneId: String?, repoOwner: String?, repoName: String?, branch: String?, event: Int?, offset: Int?)? { + ) -> ( + laneId: String?, + repoOwner: String?, + repoName: String?, + branch: String?, + accountMachineKey: String?, + itemId: String?, + eventId: String?, + event: Int?, + offset: Int? + )? { if let lane = query["lane"], !ADEDeepLinkURLParsing.isValidUUID(lane) { return nil } @@ -386,8 +483,23 @@ final class DeepLinkRouter { if let branch = query["branch"], !ADEDeepLinkURLParsing.isValidBranch(branch) { return nil } - let event = ADEDeepLinkURLParsing.nonNegativeInteger(query["event"]) - if query["event"] != nil && event == nil { return nil } + if let accountMachineKey = query["accountmachinekey"], + !ADEDeepLinkURLParsing.isValidOpaqueId(accountMachineKey) { + return nil + } + if let itemId = query["item"], + !ADEDeepLinkURLParsing.isValidOpaqueId(itemId) { + return nil + } + let rawEvent = query["event"] + let event = ADEDeepLinkURLParsing.nonNegativeInteger(rawEvent) + let eventId: String? + if event == nil, let rawEvent { + guard ADEDeepLinkURLParsing.isValidOpaqueId(rawEvent) else { return nil } + eventId = rawEvent + } else { + eventId = nil + } let offset = ADEDeepLinkURLParsing.nonNegativeInteger(query["offset"]) if query["offset"] != nil && offset == nil { return nil } return ( @@ -395,6 +507,9 @@ final class DeepLinkRouter { repoOwner: repo?.owner, repoName: repo?.repo, branch: query["branch"], + accountMachineKey: query["accountmachinekey"], + itemId: query["item"], + eventId: eventId, event: event, offset: offset ) diff --git a/apps/ios/ADE/Info.plist b/apps/ios/ADE/Info.plist index 82ad1dcf4..a780e4a47 100644 --- a/apps/ios/ADE/Info.plist +++ b/apps/ios/ADE/Info.plist @@ -41,6 +41,8 @@ $(CLERK_PUBLISHABLE_KEY) ADEAccountDirectoryBaseURL $(ADE_ACCOUNT_DIRECTORY_BASE_URL) + ADEPushRelayBaseURL + $(ADE_PUSH_RELAY_BASE_URL) LSRequiresIPhoneOS NSAppTransportSecurity diff --git a/apps/ios/ADE/Services/AccountDirectory.swift b/apps/ios/ADE/Services/AccountDirectory.swift index 662c49994..efe64d0e8 100644 --- a/apps/ios/ADE/Services/AccountDirectory.swift +++ b/apps/ios/ADE/Services/AccountDirectory.swift @@ -199,3 +199,265 @@ struct AccountDirectoryClient { } } } + +/// Authenticated client for the account-wide Attention API hosted by the push +/// relay. It intentionally shares Clerk session semantics with the account +/// directory but stores the resulting snapshot in the App Group so widgets +/// never need network or authentication access. +struct AccountAttentionRelayClient { + enum RelayError: LocalizedError, Equatable { + case unauthorized + case staleOwnership + case server(Int) + case transport + case invalidSnapshot + + var errorDescription: String? { + switch self { + case .unauthorized: return "Your session expired. Sign in again." + case .staleOwnership: return "A newer device owner has already been registered." + case .server(let status): return "Attention service error (\(status))." + case .transport: return "Couldn't reach the Attention service." + case .invalidSnapshot: return "The Attention service returned unreadable data." + } + } + } + + var session: URLSession = .shared + + func fetchSnapshot( + baseURL: URL, + token: String, + since revision: Int, + streamId: String? = nil, + refreshToken: (() async -> String?)? = nil + ) async throws -> AccountAttentionSnapshot { + var components = URLComponents( + url: endpoint(baseURL, "snapshot"), + resolvingAgainstBaseURL: false + ) + var queryItems = [URLQueryItem(name: "since", value: "\(max(0, revision))")] + if let streamId = streamId?.trimmingCharacters(in: .whitespacesAndNewlines), + !streamId.isEmpty { + queryItems.append(URLQueryItem(name: "streamId", value: streamId)) + } + components?.queryItems = queryItems + guard let url = components?.url else { throw RelayError.invalidSnapshot } + let data = try await perform( + url: url, + method: "GET", + token: token, + body: nil, + refreshToken: refreshToken + ) + guard let snapshot = ADESharedContainer.decodeAttentionSnapshot(from: data), + snapshot.contractVersion == ADEAttentionContractVersion else { + throw RelayError.invalidSnapshot + } + return snapshot + } + + func acknowledge( + baseURL: URL, + token: String, + itemIds: [String], + dismiss: Bool, + refreshToken: (() async -> String?)? = nil + ) async throws { + guard !itemIds.isEmpty else { return } + let timestamp = ISO8601DateFormatter().string(from: Date()) + var payload: [String: Any] = [ + "itemIds": Array(itemIds.prefix(64)), + "seenAt": timestamp, + ] + if dismiss { payload["dismissedAt"] = timestamp } + let body = try JSONSerialization.data(withJSONObject: payload) + _ = try await perform( + url: endpoint(baseURL, "ack"), + method: "POST", + token: token, + body: body, + refreshToken: refreshToken + ) + } + + func updatePresence( + baseURL: URL, + token: String, + deviceId: String, + deviceName: String, + foreground: Bool, + attentionVisible: Bool, + visibleItemIds: [String], + refreshToken: (() async -> String?)? = nil + ) async throws { + let payload: [String: Any] = [ + "deviceId": deviceId, + "deviceName": deviceName, + "platform": "iOS", + "appForeground": foreground, + "ambientSurfaceVisible": attentionVisible, + "visibleItemIds": Array(visibleItemIds.prefix(64)), + "observedAt": ISO8601DateFormatter().string(from: Date()), + ] + let body = try JSONSerialization.data(withJSONObject: payload) + _ = try await perform( + url: endpoint(baseURL, "presence"), + method: "POST", + token: token, + body: body, + refreshToken: refreshToken + ) + } + + func registerDevice( + baseURL: URL, + token: String, + deviceId: String, + ownershipEpoch: Int, + apnsToken: String?, + pushToStartToken: String?, + bundleId: String, + apsEnvironment: String, + deviceName: String, + preferences: [String: Any], + refreshToken: (() async -> String?)? = nil + ) async throws { + guard !deviceId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + ownershipEpoch > 0, + ownershipEpoch <= AccountDeviceOwnershipState.maximumSafeEpoch else { + throw RelayError.transport + } + var payload: [String: Any] = [ + "ownershipEpoch": ownershipEpoch, + "bundleId": bundleId, + "apsEnvironment": apsEnvironment, + "platform": "iOS", + "deviceName": deviceName, + "preferences": preferences, + ] + if let apnsToken, !apnsToken.isEmpty { payload["apnsToken"] = apnsToken } + if let pushToStartToken, !pushToStartToken.isEmpty { + payload["pushToStartToken"] = pushToStartToken + } + let body = try JSONSerialization.data(withJSONObject: payload) + _ = try await perform( + url: endpoint(baseURL, "devices").appendingPathComponent(deviceId), + method: "PUT", + token: token, + body: body, + refreshToken: refreshToken + ) + } + + func unregisterDevice( + baseURL: URL, + token: String, + deviceId: String, + ownershipEpoch: Int, + timeoutInterval: TimeInterval = 2 + ) async throws { + let normalizedDeviceId = deviceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedDeviceId.isEmpty, + ownershipEpoch > 0, + ownershipEpoch <= AccountDeviceOwnershipState.maximumSafeEpoch else { + throw RelayError.transport + } + let body = try JSONSerialization.data( + withJSONObject: ["ownershipEpoch": ownershipEpoch] + ) + _ = try await perform( + url: endpoint(baseURL, "devices").appendingPathComponent(normalizedDeviceId), + method: "DELETE", + token: token, + body: body, + refreshToken: nil, + timeoutInterval: timeoutInterval + ) + } + + func reportActivityToken( + baseURL: URL, + token accessToken: String, + deviceId: String, + activityId: String, + activityToken: String, + refreshToken: (() async -> String?)? = nil + ) async throws { + let normalizedDeviceId = deviceId.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedActivityId = activityId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedDeviceId.isEmpty, !normalizedActivityId.isEmpty else { + throw RelayError.transport + } + let url = endpoint(baseURL, "devices") + .appendingPathComponent(normalizedDeviceId) + .appendingPathComponent("activities") + .appendingPathComponent(normalizedActivityId) + let normalized = activityToken.trimmingCharacters(in: .whitespacesAndNewlines) + let body = normalized.isEmpty + ? nil + : try JSONSerialization.data(withJSONObject: ["token": normalized]) + _ = try await perform( + url: url, + method: normalized.isEmpty ? "DELETE" : "PUT", + token: accessToken, + body: body, + refreshToken: refreshToken + ) + } + + private func endpoint(_ baseURL: URL, _ leaf: String) -> URL { + baseURL + .appendingPathComponent("attention") + .appendingPathComponent("account") + .appendingPathComponent(leaf) + } + + private func perform( + url: URL, + method: String, + token: String, + body: Data?, + refreshToken: (() async -> String?)?, + timeoutInterval: TimeInterval = 12 + ) async throws -> Data { + func request(using accessToken: String) async throws -> (Data, HTTPURLResponse) { + var request = URLRequest(url: url) + request.httpMethod = method + request.httpBody = body + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if body != nil { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + request.setValue(UUID().uuidString.lowercased(), forHTTPHeaderField: "X-ADE-Correlation-ID") + request.timeoutInterval = timeoutInterval + request.cachePolicy = .reloadIgnoringLocalCacheData + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw RelayError.transport + } + return (data, http) + } catch let error as RelayError { + throw error + } catch { + throw RelayError.transport + } + } + + var (data, response) = try await request(using: token) + if response.statusCode == 401, + let refreshToken, + let refreshed = await refreshToken()?.trimmingCharacters(in: .whitespacesAndNewlines), + !refreshed.isEmpty { + (data, response) = try await request(using: refreshed) + } + switch response.statusCode { + case 200..<300: return data + case 401, 403: throw RelayError.unauthorized + case 409: throw RelayError.staleOwnership + default: throw RelayError.server(response.statusCode) + } + } +} diff --git a/apps/ios/ADE/Services/AccountService.swift b/apps/ios/ADE/Services/AccountService.swift index c69f55ec2..68dbb9ea6 100644 --- a/apps/ios/ADE/Services/AccountService.swift +++ b/apps/ios/ADE/Services/AccountService.swift @@ -1,6 +1,7 @@ import ClerkKit import Foundation import SwiftUI +import UIKit /// Build-time configuration for the account layer, sourced from Info.plist keys /// that are populated from build settings (`CLERK_PUBLISHABLE_KEY`, @@ -17,6 +18,11 @@ enum AccountConfig { return URL(string: raw) } + static var attentionRelayBaseURL: URL? { + guard let raw = infoValue("ADEPushRelayBaseURL") else { return nil } + return URL(string: raw) + } + /// Reads a string Info.plist value, treating empty strings and unexpanded /// `$(BUILD_SETTING)` placeholders as absent. private static func infoValue(_ key: String) -> String? { @@ -78,6 +84,161 @@ struct AccountLocalSignOutState { } } +struct AccountDeviceOwnershipState: Codable, Equatable { + static let maximumSafeEpoch = 9_007_199_254_740_991 + + let ownershipEpoch: Int + let ownerId: String? +} + +struct AccountDeviceOwnershipStore { + private let defaults: UserDefaults + private let key: String + + init( + defaults: UserDefaults = ADESharedContainer.defaults, + key: String = ADESharedContainer.accountDeviceOwnershipStateKey + ) { + self.defaults = defaults + self.key = key + } + + var state: AccountDeviceOwnershipState { + guard let data = defaults.data(forKey: key), + let decoded = try? JSONDecoder().decode(AccountDeviceOwnershipState.self, from: data), + decoded.ownershipEpoch > 0, + decoded.ownershipEpoch <= AccountDeviceOwnershipState.maximumSafeEpoch else { + return AccountDeviceOwnershipState(ownershipEpoch: 1, ownerId: nil) + } + return decoded + } + + /// Commits the account boundary before any network request is allowed to use + /// it. Repeating the same owner is stable; every owner/nil transition + /// advances the per-install epoch monotonically. + @discardableResult + func transition(to ownerId: String?) -> AccountDeviceOwnershipState { + let normalizedOwnerId = ownerId? + .trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedOwnerId = normalizedOwnerId?.isEmpty == false ? normalizedOwnerId : nil + let current = state + guard current.ownerId != resolvedOwnerId else { + persist(current) + return current + } + let nextEpoch = min( + AccountDeviceOwnershipState.maximumSafeEpoch, + max(1, current.ownershipEpoch) + 1 + ) + let next = AccountDeviceOwnershipState( + ownershipEpoch: nextEpoch, + ownerId: resolvedOwnerId + ) + persist(next) + return next + } + + private func persist(_ state: AccountDeviceOwnershipState) { + guard let data = try? JSONEncoder().encode(state) else { return } + defaults.set(data, forKey: key) + defaults.synchronize() + } +} + +/// Durable marker proving this installation still owes Relay a device +/// revocation. It deliberately stores no Clerk credential: an explicit +/// sign-out uses its live token immediately, while a later sign-in either +/// retries under the same owner or atomically transfers the device to the new +/// owner by registering it before clearing this marker. +struct PendingAccountDeviceRevocation: Codable, Equatable { + let ownerId: String + let deviceId: String + let ownershipEpoch: Int + let createdAt: Date + + private enum CodingKeys: String, CodingKey { + case ownerId + case deviceId + case ownershipEpoch + case createdAt + } + + init(ownerId: String, deviceId: String, ownershipEpoch: Int, createdAt: Date) { + self.ownerId = ownerId + self.deviceId = deviceId + self.ownershipEpoch = ownershipEpoch + self.createdAt = createdAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + ownerId = try container.decode(String.self, forKey: .ownerId) + deviceId = try container.decode(String.self, forKey: .deviceId) + // v1 pending markers predate ownership epochs. Treat them as the oldest + // valid epoch so the next boundary safely replaces them. + ownershipEpoch = try container.decodeIfPresent(Int.self, forKey: .ownershipEpoch) ?? 1 + createdAt = try container.decode(Date.self, forKey: .createdAt) + } +} + +struct AccountDeviceRevocationStore { + private let defaults: UserDefaults + private let key: String + + init( + defaults: UserDefaults = ADESharedContainer.defaults, + key: String = ADESharedContainer.pendingAccountDeviceRevocationKey + ) { + self.defaults = defaults + self.key = key + } + + var pending: PendingAccountDeviceRevocation? { + guard let data = defaults.data(forKey: key) else { return nil } + return try? JSONDecoder().decode(PendingAccountDeviceRevocation.self, from: data) + } + + @discardableResult + func mark( + ownerId: String, + deviceId: String, + ownershipEpoch: Int, + now: Date = Date() + ) -> PendingAccountDeviceRevocation? { + let normalizedOwnerId = ownerId.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedDeviceId = deviceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedOwnerId.isEmpty, + !normalizedDeviceId.isEmpty, + ownershipEpoch > 0, + ownershipEpoch <= AccountDeviceOwnershipState.maximumSafeEpoch else { + return nil + } + // A newer boundary supersedes every older pending mutation for this + // installation. Preserve only an equal/newer marker. + if let existing = pending, + existing.deviceId == normalizedDeviceId, + existing.ownershipEpoch >= ownershipEpoch { + return existing + } + let record = PendingAccountDeviceRevocation( + ownerId: normalizedOwnerId, + deviceId: normalizedDeviceId, + ownershipEpoch: ownershipEpoch, + createdAt: now + ) + guard let data = try? JSONEncoder().encode(record) else { return nil } + defaults.set(data, forKey: key) + defaults.synchronize() + return record + } + + func clear(ifMatching record: PendingAccountDeviceRevocation? = nil) { + if let record, pending != record { return } + defaults.removeObject(forKey: key) + defaults.synchronize() + } +} + /// Keep token eligibility independently testable from ClerkKit. A cached Clerk /// session is not enough: ADE must currently publish the same signed-in user /// and must not be under a device-local sign-out boundary. @@ -99,6 +260,14 @@ func accountSessionStatusAllowsAccess(_ status: Session.SessionStatus?) -> Bool status == .active } +func accountDeviceMutationMatchesCurrentOwnership( + ownerId: String, + ownershipEpoch: Int, + state: AccountDeviceOwnershipState +) -> Bool { + ownershipEpoch == state.ownershipEpoch && ownerId == state.ownerId +} + /// A point-in-time account authorization used by account-created pairing. /// Pairing performs network work, so owner identity alone is insufficient: the /// generation also changes on local sign-out and account switches, invalidating @@ -113,6 +282,55 @@ struct AccountPairingSession: Sendable { let token: String } +private struct AccountAttentionDeviceRegistrationRequest { + let authorization: AccountPairingAuthorization + let deviceId: String + let ownershipEpoch: Int + let apnsToken: String? + let pushToStartToken: String? + let bundleId: String + let apsEnvironment: String + let preferences: [String: Any] +} + +/// Serializes account device PUTs while coalescing queued refreshes to the most +/// recent request. An in-flight request is allowed to finish because Relay's +/// ownership epoch makes it harmless after an account boundary; no later PUT +/// can overtake it. +@MainActor +final class LatestAccountRegistrationQueue { + private var inFlight: Task? + private var pending: Request? + + func submit( + _ request: Request, + perform: @escaping @MainActor (Request) async -> Bool + ) async -> Bool { + pending = request + if let inFlight { + return await inFlight.value + } + let task = Task { @MainActor [weak self] in + guard let self else { return false } + var result = false + while let request = self.pending { + self.pending = nil + result = await perform(request) + } + // This runs without an actor suspension after observing an empty queue, + // so a new submit can never attach to a completed drain. + self.inFlight = nil + return result + } + inFlight = task + return await task.value + } + + func discardPending() { + pending = nil + } +} + struct AccountRelayTokenPolicy: Equatable { static let production = AccountRelayTokenPolicy(expirationBuffer: 60, skipCache: true) @@ -195,6 +413,9 @@ final class AccountService: ObservableObject { @Published private(set) var machines: [AccountMachine] = [] @Published private(set) var machinesState: MachinesState = .idle @Published private(set) var authenticationOutcome: AccountAuthenticationOutcome = .unknown + /// Bumped after a new account Attention snapshot is committed to the App + /// Group. The in-app model observes this alongside SyncService revisions. + @Published private(set) var attentionSnapshotRevision = 0 /// Transient, user-facing error from the last sign-in attempt. @Published var lastError: String? @@ -202,11 +423,33 @@ final class AccountService: ObservableObject { private var eventTask: Task? private var emailVerificationKind: AccountEmailVerificationKind? private let directory = AccountDirectoryClient() + private let attentionRelay = AccountAttentionRelayClient() private let localSignOutState = AccountLocalSignOutState() + private let deviceRevocationStore = AccountDeviceRevocationStore() + private let deviceOwnershipStore = AccountDeviceOwnershipStore() private var pairingAuthorizationGeneration: UInt64 = 0 + private var lastRelayCredential: (ownerId: String, token: String)? + private var attentionRefreshTask: Task? + private var attentionRefreshId: UUID? + private var isEndingAccountOwnership = false + private let accountRegistrationQueue = + LatestAccountRegistrationQueue() var isConfigured: Bool { phase != .unconfigured } var isSignedIn: Bool { phase == .signedIn } + var hasPendingAttentionDeviceRevocation: Bool { + deviceRevocationStore.pending != nil + } + + /// Stable identity used for every account Attention device endpoint. It + /// intentionally matches the machine-registration device identity whenever + /// SyncService is available so registration, prefs, activities, and sign-out + /// all target the same relay row. + var attentionDeviceId: String { + SyncService.shared?.deviceId + ?? UIDevice.current.identifierForVendor?.uuidString.lowercased() + ?? "ios-device" + } private init() {} @@ -259,8 +502,33 @@ final class AccountService: ObservableObject { let user = Clerk.shared.user { let nextIdentity = Self.identity(from: user) let shouldRefreshMachines = identity?.userId != nextIdentity.userId || phase != .signedIn + let previousOwnership = deviceOwnershipStore.state + let previousOwnerId = previousOwnership.ownerId + let ownerChanged = previousOwnerId != nextIdentity.userId + let accountSwitched = ownerChanged && previousOwnerId != nil + var switchRevocation: PendingAccountDeviceRevocation? + var switchCredential: (ownerId: String, token: String)? + if accountSwitched, let previousOwnerId { + // A direct account switch is two ownership boundaries. The old + // account's DELETE uses the unowned epoch; the new account's PUT gets + // the following epoch, so equal-epoch foreign-owner requests can never + // race at Relay. + let unowned = deviceOwnershipStore.transition(to: nil) + switchRevocation = deviceRevocationStore.mark( + ownerId: previousOwnerId, + deviceId: attentionDeviceId, + ownershipEpoch: unowned.ownershipEpoch + ) + switchCredential = lastRelayCredential + lastRelayCredential = nil + accountRegistrationQueue.discardPending() + cancelAttentionRefresh() + LiveActivityService.shared.prepareForAccountSignOut() + } + deviceOwnershipStore.transition(to: nextIdentity.userId) if identity?.userId != nextIdentity.userId { invalidatePairingAuthorization() + clearAttentionSnapshot() } SyncService.shared?.removeAccountOwnedPairings(exceptOwnerId: nextIdentity.userId) identity = nextIdentity @@ -268,7 +536,28 @@ final class AccountService: ObservableObject { phase = .signedIn } if shouldRefreshMachines { - Task { await self.loadMachines() } + Task { + if accountSwitched { + await LiveActivityService.shared.handleAccountSignOut(immediate: true) + if let switchRevocation, + let switchCredential, + switchCredential.ownerId == switchRevocation.ownerId { + await self.attemptDeviceRevocation( + switchRevocation, + token: switchCredential.token + ) + } + } + await self.processPendingDeviceRevocationAfterSignIn() + // Transfer/revoke this installation before slower directory and + // snapshot hydration, minimizing any window where the prior account + // could still target the same APNs token. + await PushNotificationService.shared.resumeAccountRegistrationIfAuthorized() + async let machines: Void = self.loadMachines() + async let attention: Void = self.refreshAttentionSnapshot() + _ = await (machines, attention) + await LiveActivityService.shared.handleAccountSignIn() + } } } else { publishSignedOut() @@ -276,24 +565,62 @@ final class AccountService: ObservableObject { } private func publishSignedOut() { + isEndingAccountOwnership = true + let previousOwnerId = identity?.userId ?? deviceOwnershipStore.state.ownerId + let signedOutOwnership = deviceOwnershipStore.transition(to: nil) + let cachedCredential = lastRelayCredential + let pendingRevocation: PendingAccountDeviceRevocation? + if !localSignOutState.isSuppressed, let previousOwnerId { + pendingRevocation = deviceRevocationStore.mark( + ownerId: previousOwnerId, + deviceId: attentionDeviceId, + ownershipEpoch: signedOutOwnership.ownershipEpoch + ) + } else { + pendingRevocation = nil + } + lastRelayCredential = nil + accountRegistrationQueue.discardPending() + cancelAttentionRefresh() invalidatePairingAuthorization() SyncService.shared?.removeAccountOwnedPairings(exceptOwnerId: nil) identity = nil machines = [] machinesState = .idle phase = .signedOut + clearAttentionSnapshot() + LiveActivityService.shared.prepareForAccountSignOut() + PushNotificationService.shared.handleAccountSignOut() + Task { + await LiveActivityService.shared.handleAccountSignOut(immediate: true) + if let pendingRevocation, + let cachedCredential, + cachedCredential.ownerId == pendingRevocation.ownerId { + await self.attemptDeviceRevocation( + pendingRevocation, + token: cachedCredential.token + ) + } + } } private func invalidatePairingAuthorization() { pairingAuthorizationGeneration &+= 1 } + private func cancelAttentionRefresh() { + attentionRefreshTask?.cancel() + attentionRefreshTask = nil + attentionRefreshId = nil + } + /// Called only after an explicit sign-in operation completes and Clerk has /// published a real user. Merely receiving a cached auth event never clears /// the local sign-out boundary. private func finishInteractiveSignIn() { guard accountSessionStatusAllowsAccess(Clerk.shared.session?.status), Clerk.shared.user != nil else { return } + isEndingAccountOwnership = false localSignOutState.clearAfterInteractiveSignIn() syncFromClerk() } @@ -375,11 +702,20 @@ final class AccountService: ObservableObject { } func signOut() async { - // Make the user's local choice authoritative before attempting network - // revocation. If that request fails, cached Clerk state and subsequent auth - // events still cannot restore account machines or issue a session token. + // Capture the current credential and persist the cleanup obligation before + // invalidating local authorization. The UI/account boundary is then + // committed before the bounded Relay request begins. + isEndingAccountOwnership = true + accountRegistrationQueue.discardPending() + let revocation = await prepareAttentionDeviceRevocationForSignOut() + // Make the user's local choice authoritative before Clerk revocation. If + // that request fails, cached Clerk state and subsequent auth events still + // cannot restore account machines or issue a session token. localSignOutState.suppress() publishSignedOut() + if let revocation, let token = revocation.token { + await attemptDeviceRevocation(revocation.pending, token: token) + } do { try await Clerk.shared.auth.signOut() } catch { @@ -391,6 +727,70 @@ final class AccountService: ObservableObject { syncFromClerk() } + private func prepareAttentionDeviceRevocationForSignOut() async -> ( + pending: PendingAccountDeviceRevocation, + token: String? + )? { + guard let ownerId = identity?.userId ?? deviceOwnershipStore.state.ownerId else { + return nil + } + let cachedToken = lastRelayCredential?.ownerId == ownerId + ? lastRelayCredential?.token + : nil + let token: String? + if let cachedToken { + token = cachedToken + } else { + token = (await pairingSession())?.token + } + // Token acquisition may suspend, so device registration is blocked by + // `isEndingAccountOwnership` above. Commit the epoch only after that await + // and immediately before publishing the local sign-out boundary. + let signedOutOwnership = deviceOwnershipStore.transition(to: nil) + guard let pending = deviceRevocationStore.mark( + ownerId: ownerId, + deviceId: attentionDeviceId, + ownershipEpoch: signedOutOwnership.ownershipEpoch + ), pending.ownerId == ownerId else { + return nil + } + return (pending, token) + } + + private func attemptDeviceRevocation( + _ pending: PendingAccountDeviceRevocation, + token: String + ) async { + guard let baseURL = AccountConfig.attentionRelayBaseURL else { return } + do { + try await attentionRelay.unregisterDevice( + baseURL: baseURL, + token: token, + deviceId: pending.deviceId, + ownershipEpoch: pending.ownershipEpoch, + timeoutInterval: 2 + ) + deviceRevocationStore.clear(ifMatching: pending) + } catch AccountAttentionRelayClient.RelayError.staleOwnership { + // Relay has already accepted a newer ownership boundary for this + // installation. Retrying this older DELETE can never improve state. + deviceRevocationStore.clear(ifMatching: pending) + } catch { + // Keep the durable marker. The same owner can retry on its next login; + // another owner clears it only after Relay atomically transfers the + // device during registration. + } + } + + private func processPendingDeviceRevocationAfterSignIn() async { + guard let pending = deviceRevocationStore.pending, + pending.ownerId == identity?.userId, + let session = await pairingSession() else { + return + } + await attemptDeviceRevocation(pending, token: session.token) + } + // MARK: - Token var currentPairingAuthorization: AccountPairingAuthorization? { @@ -426,6 +826,7 @@ final class AccountService: ObservableObject { guard let authorization = currentPairingAuthorization, let token = try? await Clerk.shared.auth.getToken(), isPairingCommitAuthorized(authorization) else { return nil } + lastRelayCredential = (authorization.ownerId, token) return AccountPairingSession(authorization: authorization, token: token) } @@ -457,6 +858,7 @@ final class AccountService: ObservableObject { } throw AccountRelayTokenError.tokenUnavailable } + lastRelayCredential = (authorization.ownerId, session.token) return session } @@ -467,6 +869,307 @@ final class AccountService: ObservableObject { return await pairingSession()?.token } + // MARK: - Account Attention + + /// Fetch the account delta using a server-fresh Clerk token, merge it with + /// the last complete App Group snapshot, and notify app/widgets. Failures are + /// deliberately quiet: the current workspace snapshot remains usable. + func refreshAttentionSnapshot() async { + if let attentionRefreshTask { + await attentionRefreshTask.value + return + } + let task = Task { @MainActor [weak self] in + guard let self else { return } + await self.performAttentionSnapshotRefresh() + } + let refreshId = UUID() + attentionRefreshTask = task + attentionRefreshId = refreshId + await task.value + if attentionRefreshId == refreshId { + attentionRefreshTask = nil + attentionRefreshId = nil + } + } + + private func performAttentionSnapshotRefresh() async { + guard isSignedIn, + !isEndingAccountOwnership, + let requestedOwnerId = identity?.userId, + let baseURL = AccountConfig.attentionRelayBaseURL, + let initialSession = await pairingSession(), + initialSession.authorization.ownerId == requestedOwnerId, + isPairingCommitAuthorized(initialSession.authorization) else { + return + } + + let existing = ADESharedContainer.readAttentionSnapshot() + do { + let delta = try await attentionRelay.fetchSnapshot( + baseURL: baseURL, + token: initialSession.token, + since: existing?.revision ?? 0, + streamId: existing?.streamId, + refreshToken: { [weak self] in + guard let self, + self.isPairingCommitAuthorized(initialSession.authorization) else { + return nil + } + return try? await self.freshRelaySession( + expectedAuthorization: initialSession.authorization + ).token + } + ) + guard isPairingCommitAuthorized(initialSession.authorization), + identity?.userId == requestedOwnerId else { + return + } + // Another writer may have committed a newer response while this request + // was suspended. Always merge against the latest durable snapshot, never + // the stale preflight read used to construct `since`. + let complete = accountAttentionSnapshotForCommit( + current: ADESharedContainer.readAttentionSnapshot(), + incoming: delta + ) + guard ADESharedContainer.writeAttentionSnapshot(complete) else { return } + attentionSnapshotRevision &+= 1 + WidgetReloadBridge.reloadAllTimelines() + } catch { + // Keep the last-known account snapshot and machine-local fallback. + } + } + + func acknowledgeAttentionItems(_ itemIds: [String], dismiss: Bool) async { + let ids = Array(Set(itemIds.filter { !$0.isEmpty })).prefix(64) + guard !ids.isEmpty, + isSignedIn, + let requestedOwnerId = identity?.userId, + let baseURL = AccountConfig.attentionRelayBaseURL, + let initialSession = await pairingSession(), + initialSession.authorization.ownerId == requestedOwnerId, + isPairingCommitAuthorized(initialSession.authorization) else { + return + } + do { + try await attentionRelay.acknowledge( + baseURL: baseURL, + token: initialSession.token, + itemIds: Array(ids), + dismiss: dismiss, + refreshToken: { [weak self] in + guard let self, + self.isPairingCommitAuthorized(initialSession.authorization) else { + return nil + } + return try? await self.freshRelaySession( + expectedAuthorization: initialSession.authorization + ).token + } + ) + await refreshAttentionSnapshot() + } catch { + // The local seen state remains useful offline. A later snapshot refresh + // will reconcile shared acknowledgment. + } + } + + func updateAttentionPresence( + centerVisible: Bool, + visibleItemIds: [String] + ) async { + guard isSignedIn, + let requestedOwnerId = identity?.userId, + let baseURL = AccountConfig.attentionRelayBaseURL, + let initialSession = await pairingSession(), + initialSession.authorization.ownerId == requestedOwnerId, + isPairingCommitAuthorized(initialSession.authorization) else { + return + } + try? await attentionRelay.updatePresence( + baseURL: baseURL, + token: initialSession.token, + deviceId: attentionDeviceId, + deviceName: UIDevice.current.name, + foreground: true, + attentionVisible: centerVisible, + visibleItemIds: visibleItemIds, + refreshToken: { [weak self] in + guard let self, + self.isPairingCommitAuthorized(initialSession.authorization) else { + return nil + } + return try? await self.freshRelaySession( + expectedAuthorization: initialSession.authorization + ).token + } + ) + } + + func registerAttentionDevice( + deviceId: String, + apnsToken: String?, + pushToStartToken: String?, + bundleId: String, + apsEnvironment: String, + preferences: [String: Any] + ) async -> Bool { + guard isSignedIn, + let requestedOwnerId = identity?.userId, + AccountConfig.attentionRelayBaseURL != nil, + let authorization = currentPairingAuthorization, + authorization.ownerId == requestedOwnerId else { + return false + } + let ownership = deviceOwnershipStore.transition(to: requestedOwnerId) + let request = AccountAttentionDeviceRegistrationRequest( + authorization: authorization, + deviceId: deviceId, + ownershipEpoch: ownership.ownershipEpoch, + apnsToken: apnsToken, + pushToStartToken: pushToStartToken, + bundleId: bundleId, + apsEnvironment: apsEnvironment, + preferences: preferences + ) + return await accountRegistrationQueue.submit(request) { [weak self] request in + guard let self else { return false } + return await self.performAttentionDeviceRegistration(request) + } + } + + private func performAttentionDeviceRegistration( + _ request: AccountAttentionDeviceRegistrationRequest + ) async -> Bool { + guard let baseURL = AccountConfig.attentionRelayBaseURL, + let initialSession = try? await freshRelaySession( + expectedAuthorization: request.authorization + ), + accountDeviceMutationMatchesCurrentOwnership( + ownerId: request.authorization.ownerId, + ownershipEpoch: request.ownershipEpoch, + state: deviceOwnershipStore.state + ) else { + return false + } + do { + let pendingRevocation = deviceRevocationStore.pending + if let pendingRevocation, + pendingRevocation.ownerId == request.authorization.ownerId, + pendingRevocation.deviceId == request.deviceId, + pendingRevocation.ownershipEpoch <= request.ownershipEpoch { + // Same-account retry: delete the stale row first when possible. A + // failed delete does not block PUT—the registration is idempotent and + // still renews this owner's lease. + await attemptDeviceRevocation( + pendingRevocation, + token: initialSession.token + ) + } + try await attentionRelay.registerDevice( + baseURL: baseURL, + token: initialSession.token, + deviceId: request.deviceId, + ownershipEpoch: request.ownershipEpoch, + apnsToken: request.apnsToken, + pushToStartToken: request.pushToStartToken, + bundleId: request.bundleId, + apsEnvironment: request.apsEnvironment, + deviceName: UIDevice.current.name, + preferences: request.preferences, + refreshToken: { [weak self] in + guard let self, + self.isPairingCommitAuthorized(request.authorization), + accountDeviceMutationMatchesCurrentOwnership( + ownerId: request.authorization.ownerId, + ownershipEpoch: request.ownershipEpoch, + state: self.deviceOwnershipStore.state + ) else { + return nil + } + return try? await self.freshRelaySession( + expectedAuthorization: request.authorization + ).token + } + ) + guard isPairingCommitAuthorized(request.authorization), + accountDeviceMutationMatchesCurrentOwnership( + ownerId: request.authorization.ownerId, + ownershipEpoch: request.ownershipEpoch, + state: deviceOwnershipStore.state + ) else { + return false + } + // Registration is Relay's atomic ownership-transfer boundary. Clear a + // prior account's durable revocation only after this device is confirmed + // under the current account. + if let pendingRevocation, + pendingRevocation.deviceId == request.deviceId, + pendingRevocation.ownershipEpoch <= request.ownershipEpoch { + deviceRevocationStore.clear(ifMatching: pendingRevocation) + } + return true + } catch AccountAttentionRelayClient.RelayError.staleOwnership { + // A newer boundary already won at Relay. This request is permanently + // superseded and must not enter the retry loop. + if let pendingRevocation = deviceRevocationStore.pending, + pendingRevocation.deviceId == request.deviceId, + pendingRevocation.ownershipEpoch <= request.ownershipEpoch { + deviceRevocationStore.clear(ifMatching: pendingRevocation) + } + return false + } catch { + return false + } + } + + func reportAttentionActivityToken( + deviceId: String, + activityId: String, + token: String + ) async -> Bool { + guard isSignedIn, + let requestedOwnerId = identity?.userId, + let baseURL = AccountConfig.attentionRelayBaseURL, + let initialSession = await pairingSession(), + initialSession.authorization.ownerId == requestedOwnerId, + isPairingCommitAuthorized(initialSession.authorization) else { + return false + } + do { + try await attentionRelay.reportActivityToken( + baseURL: baseURL, + token: initialSession.token, + deviceId: deviceId, + activityId: activityId, + activityToken: token, + refreshToken: { [weak self] in + guard let self, + self.isPairingCommitAuthorized(initialSession.authorization) else { + return nil + } + return try? await self.freshRelaySession( + expectedAuthorization: initialSession.authorization + ).token + } + ) + return isPairingCommitAuthorized(initialSession.authorization) + } catch { + return false + } + } + + private func clearAttentionSnapshot() { + guard ADESharedContainer.defaults.data( + forKey: ADESharedContainer.attentionSnapshotKey + ) != nil else { + return + } + ADESharedContainer.clearAttentionSnapshot() + attentionSnapshotRevision &+= 1 + WidgetReloadBridge.reloadAllTimelines() + } + // MARK: - Machines /// Load the caller's machines from the directory Worker. Degrades quietly: diff --git a/apps/ios/ADE/Services/LiveActivityService.swift b/apps/ios/ADE/Services/LiveActivityService.swift index 09a5b9767..2cbbe9645 100644 --- a/apps/ios/ADE/Services/LiveActivityService.swift +++ b/apps/ios/ADE/Services/LiveActivityService.swift @@ -1,7 +1,73 @@ import ActivityKit import Foundation -/// Bridges ActivityKit's push-token machinery to the brain's push relay so the +enum LiveActivityTokenRoute: Equatable { + case accountOnly + case pairedMachine +} + +func liveActivityTokenRoute(accountWide: Bool) -> LiveActivityTokenRoute { + accountWide ? .accountOnly : .pairedMachine +} + +struct PendingAccountActivityTokenRegistration: Codable, Equatable { + let deviceId: String + let activityId: String + let token: String + /// Explicitly persisted so a retry can never be reinterpreted as a legacy + /// machine-scoped token after a relaunch or account/network failure. + let accountWide: Bool + let updatedAt: Date +} + +struct AccountActivityTokenRegistrationStore { + private let defaults: UserDefaults + private let key: String + + init( + defaults: UserDefaults = ADESharedContainer.defaults, + key: String = ADESharedContainer.pendingAccountActivityTokenKey + ) { + self.defaults = defaults + self.key = key + } + + var pending: PendingAccountActivityTokenRegistration? { + guard let data = defaults.data(forKey: key) else { return nil } + return try? JSONDecoder().decode(PendingAccountActivityTokenRegistration.self, from: data) + } + + @discardableResult + func persist( + deviceId: String, + activityId: String, + token: String, + now: Date = Date() + ) -> PendingAccountActivityTokenRegistration? { + let normalizedDeviceId = deviceId.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedActivityId = activityId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedDeviceId.isEmpty, !normalizedActivityId.isEmpty else { return nil } + let registration = PendingAccountActivityTokenRegistration( + deviceId: normalizedDeviceId, + activityId: normalizedActivityId, + token: token.trimmingCharacters(in: .whitespacesAndNewlines), + accountWide: true, + updatedAt: now + ) + guard let data = try? JSONEncoder().encode(registration) else { return nil } + defaults.set(data, forKey: key) + defaults.synchronize() + return registration + } + + func clear(ifMatching registration: PendingAccountActivityTokenRegistration? = nil) { + if let registration, pending != registration { return } + defaults.removeObject(forKey: key) + defaults.synchronize() + } +} + +/// Bridges ActivityKit's push-token machinery to ADE's push relays so the /// "agent runs" Live Activity is driven entirely by remote pushes: /// /// * `pushToStartTokenUpdates` → reported in `push.registerDevice` so the relay @@ -10,7 +76,8 @@ import Foundation /// `push.reportLiveActivityToken` so the relay can *update* its content-state. /// /// On foreground it re-attaches observers (which re-yield current tokens) and -/// ends any activity that belongs to a machine we're no longer paired with. +/// ends any machine-scoped activity that belongs to a host we're no longer +/// paired with while preserving the signed-in account aggregate. @MainActor final class LiveActivityService { static let shared = LiveActivityService() @@ -24,6 +91,8 @@ final class LiveActivityService { private var activityUpdatesTask: Task? private var perActivityTokenTasks: [String: Task] = [:] private var started = false + private var accountObserversSuspended = false + private let accountTokenStore = AccountActivityTokenRegistrationStore() private init() {} @@ -38,6 +107,10 @@ final class LiveActivityService { observePushToStartToken() observeActivityUpdates() for activity in Activity.activities { + if activity.attributes.isAccountWide, + (accountObserversSuspended || !AccountService.shared.isSignedIn) { + continue + } observePushToken(for: activity) } } @@ -48,9 +121,27 @@ final class LiveActivityService { func handleForegroundTransition() async { start() await endOrphanedActivities() + await retryPendingAccountWideTokenRegistration() for activity in Activity.activities { + if activity.attributes.isAccountWide, + (accountObserversSuspended || !AccountService.shared.isSignedIn) { + continue + } + observePushToken(for: activity) + } + } + + /// Reattach account observers only after Clerk has established the local + /// account boundary. This covers launch ordering where ActivityKit starts + /// before cached account restoration finishes. + func handleAccountSignIn() async { + start() + accountObserversSuspended = false + for activity in Activity.activities + where activity.attributes.isAccountWide { observePushToken(for: activity) } + await retryPendingAccountWideTokenRegistration() } /// End every running agent-runs activity. `immediate` dismisses without the @@ -64,47 +155,137 @@ final class LiveActivityService { perActivityTokenTasks.removeAll() } + /// End only legacy machine-scoped activities. A user can forget the + /// currently connected Mac while remaining signed in to an account that + /// still has work on other machines; its aggregate activity must survive. + func endMachineScopedActivities(immediate: Bool = false) async { + let policy: ActivityUIDismissalPolicy = immediate ? .immediate : .default + for activity in Activity.activities + where !activity.attributes.isAccountWide { + await activity.end(nil, dismissalPolicy: policy) + perActivityTokenTasks[activity.id]?.cancel() + perActivityTokenTasks[activity.id] = nil + } + } + + /// End only the signed-in account aggregate during account sign-out. Any + /// independently paired machine activity remains valid. + func endAccountWideActivities(immediate: Bool = false) async { + let policy: ActivityUIDismissalPolicy = immediate ? .immediate : .default + let accountActivities = Activity.activities.filter { + $0.attributes.isAccountWide + } + // Cancel first so ending the local activity cannot enqueue a fresh + // account-token DELETE after the sign-out boundary has been committed. + for activity in accountActivities { + perActivityTokenTasks[activity.id]?.cancel() + perActivityTokenTasks[activity.id] = nil + } + for activity in accountActivities { + await activity.end(nil, dismissalPolicy: policy) + } + } + + /// Account sign-out is a local privacy boundary: remove every persisted + /// account token retry in addition to dismissing the aggregate activity. + /// Relay cleanup is protected independently by the durable device + /// revocation marker in `AccountService`. + func prepareForAccountSignOut() { + accountObserversSuspended = true + accountTokenStore.clear() + for activity in Activity.activities + where activity.attributes.isAccountWide { + perActivityTokenTasks[activity.id]?.cancel() + perActivityTokenTasks[activity.id] = nil + } + } + + func handleAccountSignOut(immediate: Bool = true) async { + prepareForAccountSignOut() + await endAccountWideActivities(immediate: immediate) + accountTokenStore.clear() + } + // MARK: - Observation private func observePushToStartToken() { pushToStartTask?.cancel() - pushToStartTask = Task { [weak self] in + pushToStartTask = Task { for await tokenData in Activity.pushToStartTokenUpdates { - await PushNotificationService.shared.updateLiveActivityPushToStartToken(tokenData.adePushHexString) + PushNotificationService.shared.updateLiveActivityPushToStartToken(tokenData.adePushHexString) } } } private func observeActivityUpdates() { activityUpdatesTask?.cancel() - activityUpdatesTask = Task { [weak self] in + activityUpdatesTask = Task { @MainActor [weak self] in for await activity in Activity.activityUpdates { + if activity.attributes.isAccountWide, + (self?.accountObserversSuspended == true + || !AccountService.shared.isSignedIn) { + await activity.end(nil, dismissalPolicy: .immediate) + continue + } self?.observePushToken(for: activity) } } } private func observePushToken(for activity: Activity) { + guard !activity.attributes.isAccountWide + || (!accountObserversSuspended + && AccountService.shared.isSignedIn) else { + return + } // Replace any prior observer for this activity so a re-attach on // foreground doesn't stack duplicate reporters. perActivityTokenTasks[activity.id]?.cancel() perActivityTokenTasks[activity.id] = Task { [weak self] in for await tokenData in activity.pushTokenUpdates { - await self?.report(token: tokenData.adePushHexString) + await self?.report( + token: tokenData.adePushHexString, + accountWide: activity.attributes.isAccountWide + ) } // A re-attach on foreground cancels this task and stores a replacement // under the same id; only the observer that ran to natural completion // (activity ended) may report the stop-targeting empty token and clear // the entry, or it would clobber the live replacement. if Task.isCancelled { return } - await self?.report(token: "") + await self?.report( + token: "", + accountWide: activity.attributes.isAccountWide + ) self?.perActivityTokenTasks[activity.id] = nil } } // MARK: - Reporting - private func report(token hex: String) async { + private func report(token hex: String, accountWide: Bool) async { + switch liveActivityTokenRoute(accountWide: accountWide) { + case .accountOnly: + guard let pending = accountTokenStore.persist( + deviceId: AccountService.shared.attentionDeviceId, + activityId: Self.activityId, + token: hex + ) else { return } + let reported = await AccountService.shared.reportAttentionActivityToken( + deviceId: pending.deviceId, + activityId: pending.activityId, + token: pending.token + ) + if reported { + accountTokenStore.clear(ifMatching: pending) + } + // Account-wide activities are account-only by construction. On + // auth/network failure the durable retry remains; never leak the + // token into the paired-machine route below. + return + case .pairedMachine: + break + } guard let sync = SyncService.shared, sync.hasPairedHost else { return } _ = try? await sync.sendPushCommand( action: "push.reportLiveActivityToken", @@ -116,6 +297,21 @@ final class LiveActivityService { ) } + func retryPendingAccountWideTokenRegistration() async { + guard let pending = accountTokenStore.pending, + pending.accountWide else { + return + } + let reported = await AccountService.shared.reportAttentionActivityToken( + deviceId: pending.deviceId, + activityId: pending.activityId, + token: pending.token + ) + if reported { + accountTokenStore.clear(ifMatching: pending) + } + } + private func endOrphanedActivities() async { // Only reap when we actually know the current machine — a transient // disconnect (nil host) must not tear down a valid activity. @@ -124,6 +320,7 @@ final class LiveActivityService { !pairedMachine.isEmpty else { return } for activity in Activity.activities { + guard !activity.attributes.isAccountWide else { continue } let machine = activity.attributes.machineName.trimmingCharacters(in: .whitespacesAndNewlines) guard !machine.isEmpty, machine != pairedMachine else { continue } await activity.end(nil, dismissalPolicy: .immediate) diff --git a/apps/ios/ADE/Services/PushNotificationService.swift b/apps/ios/ADE/Services/PushNotificationService.swift index 956d5b10b..f87077f5a 100644 --- a/apps/ios/ADE/Services/PushNotificationService.swift +++ b/apps/ios/ADE/Services/PushNotificationService.swift @@ -1,6 +1,7 @@ import Foundation import UIKit import UserNotifications +import WidgetKit /// Owns iOS remote-push registration and the local diagnostics the "Push /// delivery" settings panel renders. A single instance (`shared`) is driven by @@ -38,7 +39,7 @@ final class PushNotificationService: ObservableObject { private var registerPending = false private let defaults = ADESharedContainer.defaults - private let prefsKey = "ade.push.prefs" + private let prefsKey = ADESharedContainer.pushPreferencesKey private let diagnosticsKey = "ade.push.diagnostics" private init() { @@ -69,11 +70,11 @@ final class PushNotificationService: ObservableObject { } /// Request notification authorization (full prompt, not provisional) and - /// register for remote notifications — but only when a machine is paired. - /// Safe to call repeatedly (pairing success, every foreground); it no-ops - /// once authorization has been resolved and a token is on file. + /// register for remote notifications once ADE has either a paired machine + /// or a signed-in account. Safe to call repeatedly. func enableIfPaired() async { - guard SyncService.shared?.hasPairedHost == true else { + guard SyncService.shared?.hasPairedHost == true + || AccountService.shared.isSignedIn else { setRegistrationState(.unsupported) relayRefreshError = nil return @@ -109,6 +110,49 @@ final class PushNotificationService: ObservableObject { UIApplication.shared.registerForRemoteNotifications() } + /// Rebind an already-authorized APNs installation after account sign-in or + /// switching accounts without presenting a surprise permission prompt. + /// If iOS has not issued a token in this process yet, asking UIApplication + /// to register causes the normal delegate callback with the stable token. + func resumeAccountRegistrationIfAuthorized() async { + guard AccountService.shared.isSignedIn else { return } + // A failed/remote sign-out can leave a durable old-owner revocation. + // Renew the account device row even before notification authorization + // is resolved so Relay can atomically transfer this installation away + // from the prior account. This does not prompt and nil tokens do not + // create a deliverable APNs target. + if AccountService.shared.hasPendingAttentionDeviceRevocation { + _ = await registerAccountAttentionDevice() + } + let settings = await UNUserNotificationCenter.current().notificationSettings() + permissionStatus = settings.authorizationStatus + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + if apnsTokenHex != nil { + await registerDevice() + } else { + setRegistrationState(.awaitingToken) + UIApplication.shared.registerForRemoteNotifications() + } + case .denied: + setRegistrationState(.permissionDenied) + case .notDetermined: + break + @unknown default: + break + } + } + + /// Account auth ended. Keep the APNs token for any independently paired + /// machine, but do not leave account-only settings claiming registration. + func handleAccountSignOut() { + registerPending = false + guard SyncService.shared?.hasPairedHost != true else { return } + relayStatus = nil + relayRefreshError = nil + setRegistrationState(.unsupported) + } + // MARK: - APNs token lifecycle (from ADEAppDelegate) func didRegisterForRemoteNotifications(deviceToken: Data) { @@ -160,13 +204,29 @@ final class PushNotificationService: ObservableObject { // MARK: - Registration command private func registerDevice() async { + let accountRegistered = await registerAccountAttentionDevice() guard let sync = SyncService.shared, sync.hasPairedHost else { - setRegistrationState(.unsupported) + setRegistrationState(accountRegistered ? .registered : .unsupported) relayRefreshError = nil + if accountRegistered { + updateDiagnostics { diag in + diag.lastRegisteredAt = Date() + diag.lastError = nil + } + } return } guard sync.canSendPushCommands else { - setRegistrationWaitingForMachine() + if accountRegistered { + setRegistrationState(.registered) + relayRefreshError = nil + updateDiagnostics { diag in + diag.lastRegisteredAt = Date() + diag.lastError = nil + } + } else { + setRegistrationWaitingForMachine() + } return } // Coalesce instead of dropping: a push-to-start / APNs token that arrives @@ -202,7 +262,14 @@ final class PushNotificationService: ObservableObject { diag.lastError = nil } } catch { - if let message = PushNotificationService.machineUnavailableMessage( + if accountRegistered { + setRegistrationState(.registered) + relayRefreshError = nil + updateDiagnostics { diag in + diag.lastRegisteredAt = Date() + diag.lastError = nil + } + } else if let message = PushNotificationService.machineUnavailableMessage( for: error, fallback: PushNotificationService.machineSetupUnavailableMessage ) { @@ -215,6 +282,20 @@ final class PushNotificationService: ObservableObject { } while registerPending } + /// Signed-in devices register their APNs and push-to-start tokens directly + /// with the account Attention relay. Machine-scoped registration remains + /// below for mixed-version brains and per-machine Live Activity updates. + private func registerAccountAttentionDevice() async -> Bool { + return await AccountService.shared.registerAttentionDevice( + deviceId: AccountService.shared.attentionDeviceId, + apnsToken: apnsTokenHex, + pushToStartToken: liveActivityPushToStartTokenHex, + bundleId: Self.bundleId, + apsEnvironment: Self.apsEnvironment, + preferences: prefs.commandPayload + ) + } + // MARK: - Preferences /// Update prefs from the settings panel and push them to the brain. When @@ -238,10 +319,15 @@ final class PushNotificationService: ObservableObject { } private func syncPrefs() async { + let accountSynced = await registerAccountAttentionDevice() guard let sync = SyncService.shared, sync.hasPairedHost else { return } guard sync.canSendPushCommands else { - relayRefreshError = PushNotificationService.machinePrefsUnavailableMessage - clearTransientDiagnosticsError() + if accountSynced { + relayRefreshError = nil + } else { + relayRefreshError = PushNotificationService.machinePrefsUnavailableMessage + clearTransientDiagnosticsError() + } return } let args: [String: Any] = [ @@ -252,7 +338,10 @@ final class PushNotificationService: ObservableObject { _ = try await sync.sendPushCommand(action: "push.setPrefs", args: args) relayRefreshError = nil } catch { - if let message = PushNotificationService.machineUnavailableMessage( + if accountSynced { + relayRefreshError = nil + updateDiagnostics { $0.lastError = nil } + } else if let message = PushNotificationService.machineUnavailableMessage( for: error, fallback: PushNotificationService.machinePrefsUnavailableMessage ) { @@ -316,10 +405,12 @@ final class PushNotificationService: ObservableObject { // MARK: - Unregister (forget machine / unpair) - /// Best-effort unregister on unpair: tell the brain to drop the device, - /// clear local diagnostics, and end any running Live Activities. + /// Best-effort unregister from the forgotten machine. When the user remains + /// signed in, preserve the direct account relay registration and aggregate + /// Live Activity so work on their other machines stays visible. func handleUnpair() { let deviceId = SyncService.shared?.deviceId + let preserveAccountAttention = AccountService.shared.isSignedIn Task { if let sync = SyncService.shared, let deviceId { _ = try? await sync.sendPushCommand( @@ -327,17 +418,30 @@ final class PushNotificationService: ObservableObject { args: ["deviceId": deviceId] ) } - await LiveActivityService.shared.endAll(immediate: true) + if preserveAccountAttention { + await LiveActivityService.shared.endMachineScopedActivities(immediate: true) + if apnsTokenHex != nil { + await registerDevice() + } else { + await enableIfPaired() + } + } else { + await LiveActivityService.shared.endAll(immediate: true) + } } - apnsTokenHex = nil - liveActivityPushToStartTokenHex = nil relayStatus = nil relayRefreshError = nil - setRegistrationState(.unsupported) - updateDiagnostics { diag in - diag.tokenSuffix = nil - diag.liveActivityPushToStartTokenSuffix = nil - diag.lastRegisteredAt = nil + if preserveAccountAttention { + setRegistrationState(apnsTokenHex == nil ? .awaitingToken : .registering) + } else { + apnsTokenHex = nil + liveActivityPushToStartTokenHex = nil + setRegistrationState(.unsupported) + updateDiagnostics { diag in + diag.tokenSuffix = nil + diag.liveActivityPushToStartTokenSuffix = nil + diag.lastRegisteredAt = nil + } } } @@ -383,6 +487,8 @@ final class PushNotificationService: ObservableObject { private func persistPrefs() { if let data = try? JSONEncoder().encode(prefs) { defaults.set(data, forKey: prefsKey) + defaults.synchronize() + WidgetCenter.shared.reloadAllTimelines() } } @@ -455,9 +561,9 @@ final class PushNotificationService: ObservableObject { // MARK: - Registration state enum PushRegistrationState: String, Sendable { - /// Never asked (no pairing yet). + /// Never asked. case notDetermined - /// Not applicable — no paired machine. + /// Not applicable — neither a signed-in account nor a paired machine. case unsupported /// User declined the system prompt. case permissionDenied @@ -465,7 +571,7 @@ enum PushRegistrationState: String, Sendable { case awaitingToken /// Sending the registration to the brain. case registering - /// Authorized locally, waiting for a live machine command path. + /// Authorized locally, waiting for the legacy machine command path. case waitingForMachine /// Registered with the brain / relay. case registered @@ -480,6 +586,7 @@ enum PushRegistrationState: String, Sendable { struct PushPrefs: Codable, Equatable { var enabled = true var liveActivitiesEnabled = true + var hideDetails = false var mutedSessionIds: [String] = [] var quietHoursEnabled = false var quietHoursStart = "22:00" @@ -492,6 +599,7 @@ struct PushPrefs: Codable, Equatable { var payload: [String: Any] = [ "enabled": enabled, "liveActivitiesEnabled": liveActivitiesEnabled, + "hideDetails": hideDetails, "mutedSessionIds": mutedSessionIds, ] if quietHoursEnabled { @@ -505,6 +613,32 @@ struct PushPrefs: Codable, Equatable { } return payload } + + private enum CodingKeys: String, CodingKey { + case enabled + case liveActivitiesEnabled + case hideDetails + case mutedSessionIds + case quietHoursEnabled + case quietHoursStart + case quietHoursEnd + case quietHoursTimezone + } + + init() {} + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? true + liveActivitiesEnabled = try container.decodeIfPresent(Bool.self, forKey: .liveActivitiesEnabled) ?? true + hideDetails = try container.decodeIfPresent(Bool.self, forKey: .hideDetails) ?? false + mutedSessionIds = try container.decodeIfPresent([String].self, forKey: .mutedSessionIds) ?? [] + quietHoursEnabled = try container.decodeIfPresent(Bool.self, forKey: .quietHoursEnabled) ?? false + quietHoursStart = try container.decodeIfPresent(String.self, forKey: .quietHoursStart) ?? "22:00" + quietHoursEnd = try container.decodeIfPresent(String.self, forKey: .quietHoursEnd) ?? "08:00" + quietHoursTimezone = try container.decodeIfPresent(String.self, forKey: .quietHoursTimezone) + ?? TimeZone.current.identifier + } } // MARK: - Diagnostics model (persisted in the App Group) diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index d39776e5b..33fa4670d 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -1983,6 +1983,9 @@ struct WorkSessionNavigationRequest: Equatable, Identifiable { let repoOwner: String? let repoName: String? let branch: String? + let accountMachineKey: String? + let itemId: String? + let eventId: String? /// Optional anchors parsed from ADE session deeplinks. The Work view keeps /// them for parity with desktop, but currently ignores them because iOS has /// no route-level chat/terminal scroll hook. @@ -1999,12 +2002,20 @@ struct WorkSessionNavigationRequest: Equatable, Identifiable { repoName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } + var hasCanonicalScope: Bool { + hasRepositoryScope + || accountMachineKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + } + init( sessionId: String, laneId: String? = nil, repoOwner: String? = nil, repoName: String? = nil, branch: String? = nil, + accountMachineKey: String? = nil, + itemId: String? = nil, + eventId: String? = nil, event: Int? = nil, offset: Int? = nil ) { @@ -2014,6 +2025,9 @@ struct WorkSessionNavigationRequest: Equatable, Identifiable { self.repoOwner = repoOwner self.repoName = repoName self.branch = branch + self.accountMachineKey = accountMachineKey + self.itemId = itemId + self.eventId = eventId self.event = event self.offset = offset } @@ -2024,6 +2038,35 @@ enum WorkSessionNavigationDestination: Equatable { case hub } +func syncAccountMachineNavigationTarget( + rawMachineKey: String?, + machines: [AccountMachine] +) -> AccountMachine? { + guard let machineKey = rawMachineKey? + .trimmingCharacters(in: .whitespacesAndNewlines), + !machineKey.isEmpty else { + return nil + } + return machines.first { $0.machineKey == machineKey } +} + +func syncAccountMachineNavigationIsCurrent( + targetDeviceId: String?, + activeHostIdentity: String?, + connectionState: RemoteConnectionState +) -> Bool { + guard connectionState == .connected, + let targetDeviceId = targetDeviceId? + .trimmingCharacters(in: .whitespacesAndNewlines), + !targetDeviceId.isEmpty, + let activeHostIdentity = activeHostIdentity? + .trimmingCharacters(in: .whitespacesAndNewlines), + !activeHostIdentity.isEmpty else { + return false + } + return targetDeviceId == activeHostIdentity +} + /// One decision table shared by the app root and both possible consumers. The /// active project's persisted row wins even when a copied link carries lane or /// branch hints, preserving compatibility with hosts that predate the roster. @@ -2084,33 +2127,45 @@ struct PrNavigationRequest: Equatable, Identifiable { let id: String let target: PrNavigationRequestTarget let detailTab: PrDetailTab? + let accountMachineKey: String? + let eventId: String? init( prId: String, prNumber: Int? = nil, laneId: String? = nil, - detailTab: PrDetailTab? = nil + detailTab: PrDetailTab? = nil, + accountMachineKey: String? = nil, + eventId: String? = nil ) { self.id = UUID().uuidString self.target = .detail(prId: prId, prNumber: prNumber, laneId: laneId) self.detailTab = detailTab + self.accountMachineKey = accountMachineKey + self.eventId = eventId } init( prNumber: Int, repoOwner: String? = nil, repoName: String? = nil, - detailTab: PrDetailTab? = nil + detailTab: PrDetailTab? = nil, + accountMachineKey: String? = nil, + eventId: String? = nil ) { self.id = UUID().uuidString self.target = .githubNumber(prNumber, repoOwner: repoOwner, repoName: repoName) self.detailTab = detailTab + self.accountMachineKey = accountMachineKey + self.eventId = eventId } init(createLaneId: String) { self.id = UUID().uuidString self.target = .create(laneId: createLaneId) self.detailTab = nil + self.accountMachineKey = nil + self.eventId = nil } var laneId: String? { @@ -2551,6 +2606,11 @@ final class SyncService: ObservableObject { @Published private(set) var accountConnectStageLabel: String? /// A brief success affordance shared by the access gate, Hub, and Settings. @Published private(set) var accountConnectSuccessLabel: String? + private var accountNavigationInFlight: ( + id: UUID, + machineKey: String, + task: Task + )? /// Direct route to offer when a legacy (no identity pubkey) Relay adoption /// fails and PIN pairing is the only safe fallback. @Published private(set) var accountPairingPinFallbackHost: DiscoveredSyncHost? @@ -5400,6 +5460,68 @@ final class SyncService: ObservableObject { } } + /// Ensures a notification/Attention deeplink is resolved against its owning + /// account machine before any project/session/PR lookup runs. A nil key is a + /// legacy local link and needs no machine transition. + func ensureAccountMachineForNavigation(_ rawMachineKey: String?) async -> Bool { + guard let machineKey = syncNonEmpty(rawMachineKey) else { return true } + if let current = accountNavigationInFlight { + let result = await current.task.value + if accountNavigationInFlight?.id == current.id { + accountNavigationInFlight = nil + } + if current.machineKey == machineKey { + return result + } + } + let id = UUID() + let task = Task { @MainActor [weak self] in + guard let self else { return false } + return await self.performAccountMachineNavigation(machineKey) + } + accountNavigationInFlight = (id, machineKey, task) + let result = await task.value + if accountNavigationInFlight?.id == id { + accountNavigationInFlight = nil + } + return result + } + + private func performAccountMachineNavigation(_ machineKey: String) async -> Bool { + var machine = syncAccountMachineNavigationTarget( + rawMachineKey: machineKey, + machines: AccountService.shared.machines + ) + if machine == nil { + await AccountService.shared.loadMachines() + machine = syncAccountMachineNavigationTarget( + rawMachineKey: machineKey, + machines: AccountService.shared.machines + ) + } + guard let machine else { + lastError = "That Mac is not available in your ADE account." + return false + } + + let targetIdentity = syncNonEmpty(machine.deviceId) + let activeIdentity = syncNonEmpty( + activeHostProfile?.hostIdentity ?? activeHostProfile?.lastHostDeviceId + ) + if syncAccountMachineNavigationIsCurrent( + targetDeviceId: targetIdentity, + activeHostIdentity: activeIdentity, + connectionState: connectionState + ) { + return true + } + guard let authorization = AccountService.shared.currentPairingAuthorization else { + lastError = "Sign in again to open work from that Mac." + return false + } + return await pairWithAccountMachine(machine, authorization: authorization) + } + /// Adds directory-verified relay metadata to already-paired Macs without /// changing how those pairings are owned. This is a local dedupe only: no /// pairing secret or machine record is uploaded, and direct-owned profiles @@ -17772,7 +17894,7 @@ extension SyncService { hasActiveSession: database.fetchSession(id: request.sessionId) != nil, targetIsActiveProject: target.map { isActiveProject($0.project) }, targetIsKnownChat: target?.chat.isChatTool == true, - hasRepositoryScope: request.hasRepositoryScope + hasRepositoryScope: request.hasCanonicalScope ) } diff --git a/apps/ios/ADE/Shared/ADEAgentActivityAttributes.swift b/apps/ios/ADE/Shared/ADEAgentActivityAttributes.swift index 626288466..5f5b2b134 100644 --- a/apps/ios/ADE/Shared/ADEAgentActivityAttributes.swift +++ b/apps/ios/ADE/Shared/ADEAgentActivityAttributes.swift @@ -7,7 +7,7 @@ import SwiftUI /// content-state update decodes without a translation layer: /// /// attributesType: "ADEAgentRunsAttributes" -/// attributes: { "machineName": String } +/// attributes: { "machineName": String, "accountWide"?: Bool } /// activityId: "agent-runs" /// contentState: { updatedAt, activeCount, runs: [Run], prs: [PullRequest] } /// @@ -61,6 +61,9 @@ public struct ADEAgentRunsAttributes: ActivityAttributes { public let lane: String? public let repoOwner: String? public let repoName: String? + /// Canonical Relay machine identity for account-wide activities. + /// Taps use it to connect to the exact host before opening the PR. + public let accountMachineKey: String? public let updatedAt: Double public init( @@ -71,6 +74,7 @@ public struct ADEAgentRunsAttributes: ActivityAttributes { lane: String? = nil, repoOwner: String? = nil, repoName: String? = nil, + accountMachineKey: String? = nil, updatedAt: Double = 0 ) { self.id = id @@ -80,11 +84,12 @@ public struct ADEAgentRunsAttributes: ActivityAttributes { self.lane = lane self.repoOwner = repoOwner self.repoName = repoName + self.accountMachineKey = accountMachineKey self.updatedAt = updatedAt } private enum CodingKeys: String, CodingKey { - case id, prNumber, title, phase, lane, repoOwner, repoName, updatedAt + case id, prNumber, title, phase, lane, repoOwner, repoName, accountMachineKey, updatedAt } public init(from decoder: Decoder) throws { @@ -96,6 +101,7 @@ public struct ADEAgentRunsAttributes: ActivityAttributes { self.lane = try? c.decodeIfPresent(String.self, forKey: .lane) self.repoOwner = try? c.decodeIfPresent(String.self, forKey: .repoOwner) self.repoName = try? c.decodeIfPresent(String.self, forKey: .repoName) + self.accountMachineKey = try? c.decodeIfPresent(String.self, forKey: .accountMachineKey) self.updatedAt = (try? c.decode(Double.self, forKey: .updatedAt)) ?? 0 } @@ -116,9 +122,22 @@ public struct ADEAgentRunsAttributes: ActivityAttributes { !repo.isEmpty, let encodedOwner = owner.addingPercentEncoding(withAllowedCharacters: Self.pathSegmentAllowed), let encodedRepo = repo.addingPercentEncoding(withAllowedCharacters: Self.pathSegmentAllowed) { - return URL(string: "ade://pr/\(encodedOwner)/\(encodedRepo)/\(prNumber)") + var components = URLComponents( + string: "ade://pr/\(encodedOwner)/\(encodedRepo)/\(prNumber)" + ) + components?.queryItems = accountMachineQueryItems + return components?.url } - return URL(string: "ade://pr/\(prNumber)") + var components = URLComponents(string: "ade://pr/\(prNumber)") + components?.queryItems = accountMachineQueryItems + return components?.url + } + + private var accountMachineQueryItems: [URLQueryItem]? { + guard let key = accountMachineKey? + .trimmingCharacters(in: .whitespacesAndNewlines), + !key.isEmpty else { return nil } + return [URLQueryItem(name: "accountMachineKey", value: key)] } private static var pathSegmentAllowed: CharacterSet { @@ -142,6 +161,8 @@ public struct ADEAgentRunsAttributes: ActivityAttributes { /// intents so they resolve the exact pending request. Optional and /// additive — older payloads without it decode to `nil`. public let itemId: String? + /// Canonical Relay machine identity for account-wide activities. + public let accountMachineKey: String? public init( id: String, @@ -150,7 +171,8 @@ public struct ADEAgentRunsAttributes: ActivityAttributes { model: String? = nil, lane: String? = nil, detail: String? = nil, - itemId: String? = nil + itemId: String? = nil, + accountMachineKey: String? = nil ) { self.id = id self.title = title @@ -159,10 +181,11 @@ public struct ADEAgentRunsAttributes: ActivityAttributes { self.lane = lane self.detail = detail self.itemId = itemId + self.accountMachineKey = accountMachineKey } private enum CodingKeys: String, CodingKey { - case id, title, phase, model, lane, detail, itemId + case id, title, phase, model, lane, detail, itemId, accountMachineKey } public init(from decoder: Decoder) throws { @@ -174,6 +197,7 @@ public struct ADEAgentRunsAttributes: ActivityAttributes { self.lane = try? c.decodeIfPresent(String.self, forKey: .lane) self.detail = try? c.decodeIfPresent(String.self, forKey: .detail) self.itemId = try? c.decodeIfPresent(String.self, forKey: .itemId) + self.accountMachineKey = try? c.decodeIfPresent(String.self, forKey: .accountMachineKey) } public var resolvedPhase: AgentRunPhase { @@ -187,14 +211,46 @@ public struct ADEAgentRunsAttributes: ActivityAttributes { .filter { !$0.isEmpty } return parts.isEmpty ? nil : parts.joined(separator: " · ") } + + public var deepLinkURL: URL? { + var allowed = CharacterSet.alphanumerics + allowed.insert(charactersIn: "-._~") + guard let encoded = id.addingPercentEncoding(withAllowedCharacters: allowed) else { + return nil + } + var components = URLComponents(string: "ade://session/\(encoded)") + var queryItems: [URLQueryItem] = [] + if let itemId = itemId?.trimmingCharacters(in: .whitespacesAndNewlines), + !itemId.isEmpty { + queryItems.append(URLQueryItem(name: "item", value: itemId)) + } + if let key = accountMachineKey? + .trimmingCharacters(in: .whitespacesAndNewlines), + !key.isEmpty { + queryItems.append(URLQueryItem(name: "accountMachineKey", value: key)) + } + components?.queryItems = queryItems.isEmpty ? nil : queryItems + return components?.url + } } - /// Machine that owns these runs. Rendered in the footer so a glance shows - /// which paired Mac is producing output. + /// Machine that owns these runs, or an account-level label when this is an + /// aggregate. Rendered in the footer so scope is always visible. public var machineName: String + /// Account aggregates span several machines and must survive the + /// single-paired-host orphan cleanup. Optional keeps attributes started by + /// older relay versions decodable. + public var accountWide: Bool? - public init(machineName: String) { + public init(machineName: String, accountWide: Bool? = nil) { self.machineName = machineName + self.accountWide = accountWide + } + + public var isAccountWide: Bool { + if accountWide == true { return true } + let marker = machineName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return marker == "all machines" || marker == "account" } } @@ -211,13 +267,13 @@ public enum PullRequestPhase: String, CaseIterable, Sendable { public var tint: Color { switch self { case .opened, .reopened: - return ADESharedTheme.statusSuccess + return ADESharedTheme.statusRunning case .merged, .mergeReady: return ADESharedTheme.statusSuccess case .checksFailing, .changesRequested: return ADESharedTheme.statusFailed case .reviewRequested: - return ADESharedTheme.warningAmber + return ADESharedTheme.statusReview case .closed: return ADESharedTheme.statusIdle } @@ -248,10 +304,10 @@ public enum PullRequestPhase: String, CaseIterable, Sendable { case .reopened: return "Reopened" case .closed: return "Closed" case .merged: return "Merged" - case .checksFailing: return "Checks" - case .reviewRequested: return "Review" - case .changesRequested: return "Changes" - case .mergeReady: return "Ready" + case .checksFailing: return "Checks failing" + case .reviewRequested: return "Review requested" + case .changesRequested: return "Changes requested" + case .mergeReady: return "Ready to merge" } } @@ -284,10 +340,9 @@ public enum AgentRunPhase: String, CaseIterable, Sendable { public var tint: Color { switch self { - case .starting: return ADESharedTheme.statusIdle - case .running: return ADESharedTheme.statusSuccess + case .starting, .running: return ADESharedTheme.statusRunning case .waitingForApproval: return ADESharedTheme.warningAmber - case .waitingForInput: return ADESharedTheme.statusAttention + case .waitingForInput: return ADESharedTheme.warningAmber case .completed: return ADESharedTheme.statusSuccess case .failed: return ADESharedTheme.statusFailed case .stale: return ADESharedTheme.statusIdle @@ -311,9 +366,8 @@ public enum AgentRunPhase: String, CaseIterable, Sendable { switch self { case .starting: return "Starting" case .running: return "Running" - case .waitingForApproval: return "Approve" - case .waitingForInput: return "Reply" - case .completed: return "Done" + case .waitingForApproval, .waitingForInput: return "Needs you" + case .completed: return "Completed" case .failed: return "Failed" case .stale: return "Stale" } diff --git a/apps/ios/ADE/Shared/ADESharedContainer.swift b/apps/ios/ADE/Shared/ADESharedContainer.swift index 28257910f..05f58c54b 100644 --- a/apps/ios/ADE/Shared/ADESharedContainer.swift +++ b/apps/ios/ADE/Shared/ADESharedContainer.swift @@ -45,14 +45,31 @@ public enum ADESharedContainer { /// Widgets read it in their `TimelineProvider`; the main app writes it and /// calls `WidgetCenter.shared.reloadAllTimelines()` on change. public static let workspaceSnapshotKey = "ade.workspaceSnapshot" + public static let attentionSnapshotKey = "ade.attentionSnapshot.v1" + public static let pushPreferencesKey = "ade.push.prefs" + public static let pendingAccountDeviceRevocationKey = + "ade.attention.pending-account-device-revocation.v1" + public static let accountDeviceOwnershipStateKey = + "ade.attention.account-device-ownership.v1" + public static let pendingAccountActivityTokenKey = + "ade.attention.pending-account-activity-token.v1" + + /// Privacy preference shared with Lock Screen widgets and Live Activities. + /// Decode only the field extensions need so app-side preference evolution + /// does not couple WidgetKit to the full settings model. + public static var hideAttentionDetails: Bool { + guard let data = defaults.data(forKey: pushPreferencesKey) else { + return false + } + return (try? JSONDecoder().decode(SharedPushPrivacyPreferences.self, from: data).hideDetails) ?? false + } /// Decodes the most recent snapshot, if any. public static func readWorkspaceSnapshot() -> WorkspaceSnapshot? { guard let data = defaults.data(forKey: workspaceSnapshotKey) else { return nil } - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 + let decoder = attentionJSONDecoder() return try? decoder.decode(WorkspaceSnapshot.self, from: data) } @@ -68,6 +85,82 @@ public enum ADESharedContainer { return true } + /// Account-wide snapshot written by the signed-in attention transport. + /// This is additive: callers fall back to `WorkspaceSnapshot` until the + /// account publisher has produced a revision for this device. + public static func readAttentionSnapshot() -> AccountAttentionSnapshot? { + guard let data = defaults.data(forKey: attentionSnapshotKey) else { + return nil + } + guard let snapshot = decodeAttentionSnapshot(from: data), + snapshot.contractVersion == ADEAttentionContractVersion else { + return nil + } + return snapshot + } + + public static func decodeAttentionSnapshot(from data: Data) -> AccountAttentionSnapshot? { + try? attentionJSONDecoder().decode(AccountAttentionSnapshot.self, from: data) + } + + @discardableResult + public static func writeAttentionSnapshot(_ snapshot: AccountAttentionSnapshot) -> Bool { + guard snapshot.contractVersion == ADEAttentionContractVersion else { return false } + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + guard let data = try? encoder.encode(snapshot) else { return false } + defaults.set(data, forKey: attentionSnapshotKey) + defaults.synchronize() + return true + } + + public static func clearAttentionSnapshot() { + defaults.removeObject(forKey: attentionSnapshotKey) + defaults.synchronize() + } + + /// Relay timestamps include fractional seconds while Swift-written widget + /// snapshots historically did not. Accept both shapes (and unix seconds) + /// so one malformed timestamp never blanks an otherwise valid snapshot. + private static func attentionJSONDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + if let seconds = try? container.decode(Double.self) { + return Date(timeIntervalSince1970: seconds) + } + let raw = try container.decode(String.self) + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: raw) { + return date + } + let standard = ISO8601DateFormatter() + standard.formatOptions = [.withInternetDateTime] + if let date = standard.date(from: raw) { + return date + } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid ISO-8601 attention timestamp" + ) + } + return decoder + } + + private struct SharedPushPrivacyPreferences: Decodable { + let hideDetails: Bool + + private enum CodingKeys: String, CodingKey { + case hideDetails + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + hideDetails = try container.decodeIfPresent(Bool.self, forKey: .hideDetails) ?? false + } + } + /// One-line summary used by the lock-screen inline accessory and the /// accessory-rectangular mono line. Picks the "most interesting" open PR /// (CI failing > review requested > merge ready > first open) and pairs it diff --git a/apps/ios/ADE/Shared/ADESharedModels.swift b/apps/ios/ADE/Shared/ADESharedModels.swift index 7956b90e2..b49eb6ff3 100644 --- a/apps/ios/ADE/Shared/ADESharedModels.swift +++ b/apps/ios/ADE/Shared/ADESharedModels.swift @@ -151,6 +151,13 @@ public struct WorkspaceSnapshot: Codable, Hashable, Sendable { public let awaitingInputCount: Int /// Chats connected but not currently producing output. public let idleCount: Int + /// Optional scope carried by account-aware publishers. Older, machine-local + /// snapshots omit these fields and continue to decode as the current + /// connected workspace. + public let machineId: String? + public let machineName: String? + public let projectId: String? + public let projectName: String? public init( generatedAt: Date, @@ -158,7 +165,11 @@ public struct WorkspaceSnapshot: Codable, Hashable, Sendable { prs: [PrSnapshot], connection: String, awaitingInputCount: Int = 0, - idleCount: Int = 0 + idleCount: Int = 0, + machineId: String? = nil, + machineName: String? = nil, + projectId: String? = nil, + projectName: String? = nil ) { self.generatedAt = generatedAt self.agents = agents @@ -166,10 +177,15 @@ public struct WorkspaceSnapshot: Codable, Hashable, Sendable { self.connection = connection self.awaitingInputCount = awaitingInputCount self.idleCount = idleCount + self.machineId = machineId + self.machineName = machineName + self.projectId = projectId + self.projectName = projectName } private enum CodingKeys: String, CodingKey { case generatedAt, agents, prs, connection, awaitingInputCount, idleCount + case machineId, machineName, projectId, projectName } public init(from decoder: Decoder) throws { @@ -199,6 +215,10 @@ public struct WorkspaceSnapshot: Codable, Hashable, Sendable { if agent.status.lowercased() == "idle" { count += 1 } } } + self.machineId = try c.decodeIfPresent(String.self, forKey: .machineId) + self.machineName = try c.decodeIfPresent(String.self, forKey: .machineName) + self.projectId = try c.decodeIfPresent(String.self, forKey: .projectId) + self.projectName = try c.decodeIfPresent(String.self, forKey: .projectName) } /// Subset of `agents` that are *actively producing output* right now. @@ -223,3 +243,494 @@ public struct WorkspaceSnapshot: Codable, Hashable, Sendable { connection: "disconnected" ) } + +// MARK: - Account attention contract + +/// Swift mirror of the versioned account-wide attention contract used by the +/// desktop and relay. The current iOS sync path still writes +/// `WorkspaceSnapshot`; these DTOs let the app and widgets consume an account +/// snapshot as soon as one is available without changing their presentation +/// model again. +public let ADEAttentionContractVersion = 1 + +public enum AccountAttentionItemKind: String, Codable, Hashable, Sendable { + case agent + case pullRequest = "pull_request" +} + +public enum AccountAttentionPhase: String, Codable, Hashable, Sendable { + case starting + case running + case needsYou = "needs_you" + case blocked + case failed + case completed + case stale + case checksFailing = "checks_failing" + case reviewRequested = "review_requested" + case changesRequested = "changes_requested" + case mergeReady = "merge_ready" + case open + case merged + case closed + + public var displayLabel: String { + switch self { + case .starting: return "Starting" + case .running: return "Running" + case .needsYou: return "Needs you" + case .blocked: return "Blocked" + case .failed: return "Failed" + case .completed: return "Completed" + case .stale: return "Stale" + case .checksFailing: return "Checks failing" + case .reviewRequested: return "Review requested" + case .changesRequested: return "Changes requested" + case .mergeReady: return "Ready to merge" + case .open: return "Open" + case .merged: return "Merged" + case .closed: return "Closed" + } + } +} + +public enum AccountAttentionEventKind: String, Codable, Hashable, Sendable { + case agentRunning = "agent_running" + case agentNeedsYou = "agent_needs_you" + case agentFailed = "agent_failed" + case agentCompleted = "agent_completed" + case prChecksFailing = "pr_checks_failing" + case prReviewRequested = "pr_review_requested" + case prChangesRequested = "pr_changes_requested" + case prMergeReady = "pr_merge_ready" + case prMerged = "pr_merged" + case prOpened = "pr_opened" + case prClosed = "pr_closed" +} + +public struct AccountAttentionMachine: Codable, Hashable, Sendable { + public let machineKey: String + /// Canonical account-directory/sync relay key. `machineKey` remains the + /// Attention publisher identity; this key is what exact mobile deep links + /// use to select the owning remote machine. + public let accountMachineKey: String? + public let name: String + public let online: Bool + public let lastSeenAt: Date? + + public init( + machineKey: String, + accountMachineKey: String? = nil, + name: String, + online: Bool, + lastSeenAt: Date? + ) { + self.machineKey = machineKey + self.accountMachineKey = accountMachineKey + self.name = name + self.online = online + self.lastSeenAt = lastSeenAt + } +} + +public struct AccountAttentionProject: Codable, Hashable, Sendable { + public let projectId: String + public let name: String + public let rootPath: String? + + public init(projectId: String, name: String, rootPath: String? = nil) { + self.projectId = projectId + self.name = name + self.rootPath = rootPath + } +} + +public enum AccountAttentionDestination: Hashable, Sendable { + case session(sessionId: String, itemId: String?, eventId: String?) + case pullRequest( + prId: String?, + repoOwner: String?, + repoName: String?, + number: Int, + tab: String, + eventId: String? + ) + + public var deepLinkURL: URL? { + deepLinkURL(accountMachineKey: nil) + } + + public func deepLinkURL(accountMachineKey: String?) -> URL? { + let normalizedAccountMachineKey = accountMachineKey? + .trimmingCharacters(in: .whitespacesAndNewlines) + let machineQueryItem = normalizedAccountMachineKey?.isEmpty == false + ? URLQueryItem(name: "accountMachineKey", value: normalizedAccountMachineKey) + : nil + switch self { + case .session(let sessionId, let itemId, let eventId): + guard let encoded = Self.encodePathSegment(sessionId) else { return nil } + var components = URLComponents(string: "ade://session/\(encoded)") + let queryItems = [ + itemId.map { URLQueryItem(name: "item", value: $0) }, + eventId.map { URLQueryItem(name: "event", value: $0) }, + machineQueryItem, + ].compactMap { $0 } + components?.queryItems = queryItems.isEmpty ? nil : queryItems + return components?.url + + case .pullRequest(_, let owner, let repo, let number, let tab, let eventId): + guard number > 0 else { return nil } + let base: String + if let owner = Self.encodePathSegment(owner), + let repo = Self.encodePathSegment(repo) { + base = "ade://pr/\(owner)/\(repo)/\(number)" + } else { + base = "ade://pr/\(number)" + } + var components = URLComponents(string: base) + let queryItems = [ + tab == "overview" ? nil : URLQueryItem(name: "tab", value: tab), + eventId.map { URLQueryItem(name: "event", value: $0) }, + machineQueryItem, + ].compactMap { $0 } + components?.queryItems = queryItems.isEmpty ? nil : queryItems + return components?.url + } + } + + private static func encodePathSegment(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { + return nil + } + var allowed = CharacterSet.alphanumerics + allowed.insert(charactersIn: "-._~") + return value.addingPercentEncoding(withAllowedCharacters: allowed) + } +} + +extension AccountAttentionDestination: Codable { + private enum CodingKeys: String, CodingKey { + case kind, sessionId, itemId, eventId + case prId, repoOwner, repoName, number, tab + } + + private enum Kind: String, Codable { + case session + case pullRequest = "pull_request" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(Kind.self, forKey: .kind) { + case .session: + self = .session( + sessionId: try container.decode(String.self, forKey: .sessionId), + itemId: try container.decodeIfPresent(String.self, forKey: .itemId), + eventId: try container.decodeIfPresent(String.self, forKey: .eventId) + ) + case .pullRequest: + self = .pullRequest( + prId: try container.decodeIfPresent(String.self, forKey: .prId), + repoOwner: try container.decodeIfPresent(String.self, forKey: .repoOwner), + repoName: try container.decodeIfPresent(String.self, forKey: .repoName), + number: try container.decode(Int.self, forKey: .number), + tab: try container.decodeIfPresent(String.self, forKey: .tab) ?? "overview", + eventId: try container.decodeIfPresent(String.self, forKey: .eventId) + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .session(let sessionId, let itemId, let eventId): + try container.encode(Kind.session, forKey: .kind) + try container.encode(sessionId, forKey: .sessionId) + try container.encodeIfPresent(itemId, forKey: .itemId) + try container.encodeIfPresent(eventId, forKey: .eventId) + case .pullRequest(let prId, let owner, let repo, let number, let tab, let eventId): + try container.encode(Kind.pullRequest, forKey: .kind) + try container.encodeIfPresent(prId, forKey: .prId) + try container.encodeIfPresent(owner, forKey: .repoOwner) + try container.encodeIfPresent(repo, forKey: .repoName) + try container.encode(number, forKey: .number) + try container.encode(tab, forKey: .tab) + try container.encodeIfPresent(eventId, forKey: .eventId) + } + } +} + +public enum AccountAttentionActionKind: String, Codable, Hashable, Sendable { + case approve + case deny + case answer + case restart + case rerunChecks = "rerun_checks" + case markSeen = "mark_seen" + case dismiss + case open +} + +public enum AccountAttentionPayloadValue: Codable, Hashable, Sendable { + case string(String) + case integer(Int) + case number(Double) + case boolean(Bool) + case null + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .boolean(value) + } else if let value = try? container.decode(Int.self) { + self = .integer(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else { + self = .string(try container.decode(String.self)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): try container.encode(value) + case .integer(let value): try container.encode(value) + case .number(let value): try container.encode(value) + case .boolean(let value): try container.encode(value) + case .null: try container.encodeNil() + } + } +} + +public struct AccountAttentionAction: Codable, Hashable, Sendable { + public let id: String + public let kind: AccountAttentionActionKind + public let label: String + public let destructive: Bool? + public let payload: [String: AccountAttentionPayloadValue]? + + public init( + id: String, + kind: AccountAttentionActionKind, + label: String, + destructive: Bool? = nil, + payload: [String: AccountAttentionPayloadValue]? = nil + ) { + self.id = id + self.kind = kind + self.label = label + self.destructive = destructive + self.payload = payload + } +} + +public struct AccountAttentionPlanProgress: Codable, Hashable, Sendable { + public let completed: Int + public let total: Int + public let current: String? + + public init(completed: Int, total: Int, current: String? = nil) { + self.completed = completed + self.total = total + self.current = current + } +} + +public struct AccountAttentionItem: Codable, Hashable, Identifiable, Sendable { + public let contractVersion: Int + public let id: String + public let revision: Int + public let fingerprint: String + public let kind: AccountAttentionItemKind + public let eventKind: AccountAttentionEventKind + public let phase: AccountAttentionPhase + public let machine: AccountAttentionMachine + public let project: AccountAttentionProject + public let laneId: String? + public let laneName: String? + public let provider: String? + public let model: String? + public let title: String + public let preview: String + public let privacyPreview: String + public let detail: String? + public let recentActivity: [String]? + public let planProgress: AccountAttentionPlanProgress? + public let destination: AccountAttentionDestination + public let actions: [AccountAttentionAction] + public let occurredAt: Date + public let updatedAt: Date + public let seenAt: Date? + public let dismissedAt: Date? + public let expiresAt: Date? + + public init( + contractVersion: Int = ADEAttentionContractVersion, + id: String, + revision: Int, + fingerprint: String, + kind: AccountAttentionItemKind, + eventKind: AccountAttentionEventKind, + phase: AccountAttentionPhase, + machine: AccountAttentionMachine, + project: AccountAttentionProject, + laneId: String? = nil, + laneName: String? = nil, + provider: String? = nil, + model: String? = nil, + title: String, + preview: String, + privacyPreview: String, + detail: String? = nil, + recentActivity: [String]? = nil, + planProgress: AccountAttentionPlanProgress? = nil, + destination: AccountAttentionDestination, + actions: [AccountAttentionAction] = [], + occurredAt: Date, + updatedAt: Date, + seenAt: Date? = nil, + dismissedAt: Date? = nil, + expiresAt: Date? = nil + ) { + self.contractVersion = contractVersion + self.id = id + self.revision = revision + self.fingerprint = fingerprint + self.kind = kind + self.eventKind = eventKind + self.phase = phase + self.machine = machine + self.project = project + self.laneId = laneId + self.laneName = laneName + self.provider = provider + self.model = model + self.title = title + self.preview = preview + self.privacyPreview = privacyPreview + self.detail = detail + self.recentActivity = recentActivity + self.planProgress = planProgress + self.destination = destination + self.actions = actions + self.occurredAt = occurredAt + self.updatedAt = updatedAt + self.seenAt = seenAt + self.dismissedAt = dismissedAt + self.expiresAt = expiresAt + } + + public var isLive: Bool { + switch phase { + case .starting, .running, .needsYou, .blocked, .failed, .stale, + .checksFailing, .reviewRequested, .changesRequested, .mergeReady: + return true + case .open, .completed, .merged, .closed: + return false + } + } + + public var needsInbox: Bool { + guard dismissedAt == nil else { return false } + switch phase { + case .needsYou, .failed, .checksFailing, .changesRequested, + .reviewRequested, .mergeReady: + return true + case .completed, .merged: + return seenAt == nil + case .starting, .running, .blocked, .open, .stale, .closed: + return false + } + } + + public var deepLinkURL: URL? { + destination.deepLinkURL(accountMachineKey: machine.accountMachineKey) + } +} + +public struct AccountAttentionTombstone: Codable, Hashable, Identifiable, Sendable { + public let id: String + public let revision: Int + public let deletedAt: Date +} + +public struct AccountAttentionSnapshot: Codable, Hashable, Sendable { + public let contractVersion: Int + /// Opaque account stream identity assigned by Relay. Older relays and + /// snapshots omit it; once present, a change is an account boundary and + /// the incoming snapshot must replace—not merge with—the prior stream. + public let streamId: String? + public let revision: Int + public let generatedAt: Date + public let items: [AccountAttentionItem] + public let tombstones: [AccountAttentionTombstone]? + + public init( + contractVersion: Int = ADEAttentionContractVersion, + streamId: String? = nil, + revision: Int, + generatedAt: Date, + items: [AccountAttentionItem], + tombstones: [AccountAttentionTombstone]? = nil + ) { + self.contractVersion = contractVersion + self.streamId = streamId + self.revision = revision + self.generatedAt = generatedAt + self.items = items + self.tombstones = tombstones + } + + /// Apply an incremental relay response to the last full snapshot. Relay + /// deltas contain only items/tombstones newer than `since`; merging here + /// keeps widgets and the app backed by one complete App Group snapshot. + public func merging(_ delta: AccountAttentionSnapshot) -> AccountAttentionSnapshot { + guard delta.contractVersion == contractVersion else { + return self + } + // A non-nil Relay stream id is authoritative. nil remains compatible + // with snapshots from older servers, while nil -> value intentionally + // resets any legacy/unknown account data. + if let incomingStreamId = delta.streamId, + incomingStreamId != streamId { + return delta + } + guard delta.revision >= revision else { return self } + var byId = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) }) + for item in delta.items { + if let existing = byId[item.id], existing.revision > item.revision { + continue + } + byId[item.id] = item + } + for tombstone in delta.tombstones ?? [] { + guard let existing = byId[tombstone.id], + existing.revision <= tombstone.revision else { + continue + } + byId.removeValue(forKey: tombstone.id) + } + return AccountAttentionSnapshot( + contractVersion: contractVersion, + streamId: delta.streamId ?? streamId, + revision: delta.revision, + generatedAt: delta.generatedAt, + items: Array(byId.values), + tombstones: delta.tombstones + ) + } +} + +/// Centralizes the read-before-commit merge used by account Attention refresh. +/// Re-reading at commit time prevents a slower rev11 request from overwriting a +/// rev12 snapshot that completed while it was suspended on the network. +public func accountAttentionSnapshotForCommit( + current: AccountAttentionSnapshot?, + incoming: AccountAttentionSnapshot +) -> AccountAttentionSnapshot { + current?.merging(incoming) ?? incoming +} diff --git a/apps/ios/ADE/Shared/ADESharedTheme.swift b/apps/ios/ADE/Shared/ADESharedTheme.swift index a2fc16880..0b21486ca 100644 --- a/apps/ios/ADE/Shared/ADESharedTheme.swift +++ b/apps/ios/ADE/Shared/ADESharedTheme.swift @@ -39,20 +39,76 @@ public enum ADESharedTheme { } } + /// Bundled provider mark shared by the main app and widget extension. + /// Unknown providers intentionally return nil so callers can keep a clear + /// status-symbol fallback without introducing a new image dependency. + public static func providerAssetName(for providerSlug: String?) -> String? { + guard let providerSlug else { return nil } + switch providerSlug.lowercased() { + case "claude": return "ProviderClaude" + case "anthropic": return "ProviderAnthropic" + case "codex": return "ProviderCodex" + case "openai": return "ProviderOpenAI" + case "cursor": return "ProviderCursor" + case "opencode": return "ProviderOpenCode" + case "droid", "factory": return "ProviderDroid" + case "github": return "ProviderGitHub" + default: return nil + } + } + + public static func providerDisplayName(for providerSlug: String?) -> String? { + guard let providerSlug else { return nil } + switch providerSlug.lowercased() { + case "claude": return "Claude" + case "anthropic": return "Anthropic" + case "codex": return "Codex" + case "openai": return "OpenAI" + case "cursor": return "Cursor" + case "opencode": return "OpenCode" + case "droid", "factory": return "Droid" + case "google", "gemini": return "Gemini" + case "mistral": return "Mistral" + case "deepseek": return "DeepSeek" + case "xai", "grok": return "Grok" + case "groq": return "Groq" + case "cto": return "CTO" + case "github": return "GitHub" + case "ade": return "ADE" + default: + let value = providerSlug.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + } + + /// Live Activity payloads carry a model id rather than a provider slug. + /// Infer only high-confidence families so an unknown model keeps the phase + /// symbol instead of being assigned the wrong brand. + public static func providerSlug(forModel model: String?) -> String? { + guard let model else { return nil } + let value = model.lowercased() + if value.contains("claude") || value.contains("anthropic") { return "claude" } + if value.contains("codex") { return "codex" } + if value.contains("gpt") || value.contains("openai") || value.hasPrefix("o3") || value.hasPrefix("o4") { + return "openai" + } + if value.contains("cursor") { return "cursor" } + if value.contains("opencode") { return "opencode" } + if value.contains("droid") || value.contains("factory") { return "droid" } + if value.contains("gemini") || value.contains("google") { return "google" } + return nil + } + // MARK: - Semantic status colors - /// Red used for failed / CI-failing states. Matches the XAI brand red to - /// keep the palette tight; the two states are not visually confusable - /// because they carry distinct SF Symbols. - public static let statusFailed = brandXAI - /// Green used for passing / completed. Derived from the Codex teal. - public static let statusSuccess = brandCodex - /// Amber used for awaiting-input / warnings. - public static let statusAttention = Color(red: 0xF5 / 255.0, green: 0x9E / 255.0, blue: 0x0B / 255.0) - /// Brighter amber used in the mockup palette for attention pulses, review - /// states, and inline warning chips. Matches `STATUS.attention` / - /// `STATUS.review` from `surfaces.jsx`. + /// Attention phase colors mirror the desktop attention center so the same + /// state reads identically on every ADE surface. + public static let statusRunning = Color(red: 0x60 / 255.0, green: 0xA5 / 255.0, blue: 0xFA / 255.0) // #60A5FA + public static let statusFailed = Color(red: 0xF8 / 255.0, green: 0x71 / 255.0, blue: 0x71 / 255.0) // #F87171 + public static let statusReview = Color(red: 0xA7 / 255.0, green: 0x8B / 255.0, blue: 0xFA / 255.0) // #A78BFA + public static let statusSuccess = Color(red: 0x34 / 255.0, green: 0xD3 / 255.0, blue: 0x99 / 255.0) // #34D399 public static let warningAmber = Color(red: 0xFB / 255.0, green: 0xBF / 255.0, blue: 0x24 / 255.0) // #FBBF24 + public static let statusAttention = warningAmber /// Neutral gray used for idle / pending. public static let statusIdle = Color(red: 0x71 / 255.0, green: 0x71 / 255.0, blue: 0x7A / 255.0) diff --git a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift index f207e2f47..9822439c5 100644 --- a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift +++ b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift @@ -9,6 +9,26 @@ public enum AttentionKind: String, Codable, Hashable, Sendable { case ciFailing case reviewRequested case mergeReady + case running + case open + case completed + case merged + case stale +} + +@available(iOS 17.0, *) +public enum AttentionCollection: String, CaseIterable, Hashable, Sendable { + case needsYou + case live + case recent +} + +@available(iOS 17.0, *) +public struct AttentionProjectLens: Identifiable, Equatable, Sendable { + public let id: String + public let name: String + public let machineCount: Int + public let itemCount: Int } /// A single row rendered inside the in-app Attention Drawer sheet. @@ -28,6 +48,19 @@ public struct AttentionItem: Identifiable, Equatable { public let prNumber: Int? public let deepLink: URL? public let timestamp: Date + public let collection: AttentionCollection + public let machineId: String + public let machineName: String + public let machineOnline: Bool + public let projectId: String + public let projectName: String + public let laneName: String? + public let phaseLabel: String + public let seenAt: Date? + /// Inline App Intents execute against the currently paired host. Account + /// items can belong to another machine, so they must navigate to their exact + /// destination instead of invoking a local-host action. + public let inlineActionsAllowed: Bool public init( id: String, @@ -40,7 +73,17 @@ public struct AttentionItem: Identifiable, Equatable { prId: String? = nil, prNumber: Int? = nil, deepLink: URL? = nil, - timestamp: Date + timestamp: Date, + collection: AttentionCollection = .needsYou, + machineId: String = "current-machine", + machineName: String = "Connected Mac", + machineOnline: Bool = true, + projectId: String = "current-project", + projectName: String = "Current project", + laneName: String? = nil, + phaseLabel: String? = nil, + seenAt: Date? = nil, + inlineActionsAllowed: Bool = true ) { self.id = id self.kind = kind @@ -53,29 +96,60 @@ public struct AttentionItem: Identifiable, Equatable { self.prNumber = prNumber self.deepLink = deepLink self.timestamp = timestamp + self.collection = collection + self.machineId = machineId + self.machineName = machineName + self.machineOnline = machineOnline + self.projectId = projectId + self.projectName = projectName + self.laneName = laneName + self.phaseLabel = phaseLabel ?? Self.defaultPhaseLabel(for: kind) + self.seenAt = seenAt + self.inlineActionsAllowed = inlineActionsAllowed + } + + public var scopeLabel: String { + let project = projectName.trimmingCharacters(in: .whitespacesAndNewlines) + let machine = machineName.trimmingCharacters(in: .whitespacesAndNewlines) + if machine.isEmpty { return project } + if project.isEmpty { return machine } + return "\(machine) · \(project)" } + private static func defaultPhaseLabel(for kind: AttentionKind) -> String { + switch kind { + case .awaitingInput: return "Needs you" + case .failed: return "Failed" + case .ciFailing: return "Checks failing" + case .reviewRequested: return "Review" + case .mergeReady: return "Ready" + case .running: return "Working" + case .open: return "Open" + case .completed: return "Done" + case .merged: return "Merged" + case .stale: return "Offline" + } + } } /// Source of truth for the in-app Attention Drawer. /// -/// Reducer-only: never opens its own WebSocket. It -/// subscribes to the `SyncService` `@Published var activeSessions` + -/// `@Published var localStateRevision` publishers and rebuilds its -/// `items` array from the `WorkspaceSnapshot` written to the App Group by -/// `SyncService.writeWorkspaceSnapshotNow()`. -/// -/// `unreadCount` reflects items whose `timestamp > lastSeenAt`, where -/// `lastSeenAt` is persisted to the shared `UserDefaults` under -/// `ade.attention.lastSeenAt` so the badge survives relaunches. +/// Reducer-only: never opens its own transport. It prefers the account-wide +/// snapshot written to the App Group and falls back to SyncService's current +/// workspace snapshot. Per-item seen IDs are persisted so opening one event +/// never clears another machine's unread badge. @available(iOS 17.0, *) @MainActor public final class AttentionDrawerModel: ObservableObject { @Published public private(set) var items: [AttentionItem] = [] + @Published public private(set) var liveItems: [AttentionItem] = [] + @Published public private(set) var recentItems: [AttentionItem] = [] @Published public private(set) var unreadCount: Int = 0 + @Published public private(set) var selectedProjectId: String? public static let lastSeenAtKey = "ade.attention.lastSeenAt" public static let dismissedItemIDsKey = "ade.attention.dismissedItemIDs" + public static let seenItemIDsKey = "ade.attention.seenItemIDs" private var lastSeenAt: Date { didSet { @@ -89,6 +163,8 @@ public final class AttentionDrawerModel: ObservableObject { private let defaults: UserDefaults private var dismissedItemIDs: Set + private var seenItemIDs: Set + private var accountBackedItemIDs: Set = [] public init(defaults: UserDefaults = ADESharedContainer.defaults) { self.defaults = defaults @@ -97,6 +173,7 @@ public final class AttentionDrawerModel: ObservableObject { ? Date(timeIntervalSince1970: stored) : .distantPast self.dismissedItemIDs = Set(defaults.stringArray(forKey: Self.dismissedItemIDsKey) ?? []) + self.seenItemIDs = Set(defaults.stringArray(forKey: Self.seenItemIDsKey) ?? []) } // MARK: - Reducer @@ -106,8 +183,16 @@ public final class AttentionDrawerModel: ObservableObject { /// newest timestamp first. `unreadCount` is recomputed against /// `lastSeenAt`. public func rebuild(from snapshot: WorkspaceSnapshot) { + accountBackedItemIDs = [] var result: [AttentionItem] = [] + var live: [AttentionItem] = [] + var recent: [AttentionItem] = [] let generated = snapshot.generatedAt + let machineId = Self.nonEmpty(snapshot.machineId) ?? "current-machine" + let machineName = Self.nonEmpty(snapshot.machineName) ?? "Connected Mac" + let projectId = Self.nonEmpty(snapshot.projectId) ?? "current-project" + let projectName = Self.nonEmpty(snapshot.projectName) ?? "Current project" + let machineOnline = snapshot.connection.lowercased() != "disconnected" for agent in snapshot.agents { if agent.awaitingInput { @@ -123,7 +208,13 @@ public final class AttentionDrawerModel: ObservableObject { sessionId: agent.sessionId, itemId: agent.pendingInputItemId, deepLink: URL(string: "ade://session/\(agent.sessionId)"), - timestamp: agent.lastActivityAt + timestamp: agent.lastActivityAt, + machineId: machineId, + machineName: machineName, + machineOnline: machineOnline, + projectId: projectId, + projectName: projectName, + laneName: agent.laneName ) ) } else if Self.isAgentFailed(agent) { @@ -136,7 +227,56 @@ public final class AttentionDrawerModel: ObservableObject { providerSlug: agent.provider, sessionId: agent.sessionId, deepLink: URL(string: "ade://session/\(agent.sessionId)"), - timestamp: agent.lastActivityAt + timestamp: agent.lastActivityAt, + machineId: machineId, + machineName: machineName, + machineOnline: machineOnline, + projectId: projectId, + projectName: projectName, + laneName: agent.laneName + ) + ) + } else if Self.isAgentCompleted(agent) { + guard Date().timeIntervalSince(agent.lastActivityAt) <= 86_400 else { continue } + recent.append( + AttentionItem( + id: "completed:\(agent.sessionId)", + kind: .completed, + title: Self.humanAgentTitle(agent), + subtitle: Self.nonEmpty(agent.preview) ?? "Agent work completed", + providerSlug: agent.provider, + sessionId: agent.sessionId, + deepLink: URL(string: "ade://session/\(agent.sessionId)"), + timestamp: agent.lastActivityAt, + collection: .recent, + machineId: machineId, + machineName: machineName, + machineOnline: machineOnline, + projectId: projectId, + projectName: projectName, + laneName: agent.laneName + ) + ) + } else if Self.isAgentLive(agent) { + live.append( + AttentionItem( + id: "live:\(agent.sessionId)", + kind: machineOnline ? .running : .stale, + title: Self.humanAgentTitle(agent), + subtitle: Self.nonEmpty(agent.preview) + ?? Self.agentPhaseLabel(agent.phase) + ?? "Working", + providerSlug: agent.provider, + sessionId: agent.sessionId, + deepLink: URL(string: "ade://session/\(agent.sessionId)"), + timestamp: agent.lastActivityAt, + collection: .live, + machineId: machineId, + machineName: machineName, + machineOnline: machineOnline, + projectId: projectId, + projectName: projectName, + laneName: agent.laneName ) ) } @@ -154,7 +294,12 @@ public final class AttentionDrawerModel: ObservableObject { prId: pr.id, prNumber: pr.number, deepLink: URL(string: "ade://pr/\(pr.number)"), - timestamp: prTimestamp + timestamp: prTimestamp, + machineId: machineId, + machineName: machineName, + machineOnline: machineOnline, + projectId: projectId, + projectName: projectName ) ) } else if pr.mergeReady { @@ -167,7 +312,12 @@ public final class AttentionDrawerModel: ObservableObject { prId: pr.id, prNumber: pr.number, deepLink: URL(string: "ade://pr/\(pr.number)"), - timestamp: prTimestamp + timestamp: prTimestamp, + machineId: machineId, + machineName: machineName, + machineOnline: machineOnline, + projectId: projectId, + projectName: projectName ) ) } else if pr.review == "pending" || pr.review == "changes_requested" { @@ -182,7 +332,12 @@ public final class AttentionDrawerModel: ObservableObject { prId: pr.id, prNumber: pr.number, deepLink: URL(string: "ade://pr/\(pr.number)"), - timestamp: prTimestamp + timestamp: prTimestamp, + machineId: machineId, + machineName: machineName, + machineOnline: machineOnline, + projectId: projectId, + projectName: projectName ) ) } @@ -190,23 +345,131 @@ public final class AttentionDrawerModel: ObservableObject { pruneDismissedItems(activeIDs: Set(result.map(\.id))) result.removeAll { dismissedItemIDs.contains($0.id) } - - result.sort { lhs, rhs in - let lp = Self.kindPriority(lhs.kind) - let rp = Self.kindPriority(rhs.kind) - if lp != rp { return lp < rp } - return lhs.timestamp > rhs.timestamp + for pr in snapshot.prs where pr.state == "merged" || pr.state == "closed" { + let timestamp = pr.updatedAt ?? generated + guard Date().timeIntervalSince(timestamp) <= 86_400 else { continue } + recent.append( + AttentionItem( + id: "\(pr.state):\(pr.id)", + kind: pr.state == "merged" ? .merged : .completed, + title: "PR #\(pr.number) · \(pr.title)", + subtitle: pr.state == "merged" ? "Pull request merged" : "Pull request closed", + prId: pr.id, + prNumber: pr.number, + deepLink: URL(string: "ade://pr/\(pr.number)"), + timestamp: timestamp, + collection: .recent, + machineId: machineId, + machineName: machineName, + machineOnline: machineOnline, + projectId: projectId, + projectName: projectName + ) + ) } + sort(&result) + sort(&live) + sort(&recent) items = result + liveItems = live + recentItems = recent + pruneSeenItems(activeIDs: Set((result + live + recent).map(\.id))) + validateSelectedProject() recomputeUnreadCount() } + /// Rebuild from the account-level contract. This path supplies real + /// machine/project scope and shared seen state; `WorkspaceSnapshot` remains + /// the local fallback until the signed-in transport writes this snapshot. + public func rebuild(from snapshot: AccountAttentionSnapshot) { + let now = Date() + let active = snapshot.items.filter { item in + item.dismissedAt == nil + && (item.expiresAt == nil || item.expiresAt! > now) + } + let converted = active.map(Self.makeItem) + accountBackedItemIDs = Set(converted.map(\.id)) + var needs = converted.filter { $0.collection == .needsYou } + var live = converted.filter { $0.collection == .live } + var recent = converted.filter { item in + guard item.collection == .recent else { return false } + return item.seenAt == nil || snapshot.generatedAt.timeIntervalSince(item.timestamp) <= 86_400 + } + + pruneDismissedItems(activeIDs: Set(needs.map(\.id))) + needs.removeAll { dismissedItemIDs.contains($0.id) } + sort(&needs) + sort(&live) + sort(&recent) + items = needs + liveItems = live + recentItems = recent + pruneSeenItems(activeIDs: Set(converted.map(\.id))) + validateSelectedProject() + recomputeUnreadCount() + } + + public func selectProject(_ projectId: String?) { + selectedProjectId = projectId + } + + public func visibleItems(in collection: AttentionCollection) -> [AttentionItem] { + let source: [AttentionItem] + switch collection { + case .needsYou: source = items + case .live: source = liveItems + case .recent: source = recentItems + } + guard let selectedProjectId else { return source } + return source.filter { $0.projectId == selectedProjectId } + } + + public var projectLenses: [AttentionProjectLens] { + let all = items + liveItems + recentItems + let grouped = Dictionary(grouping: all, by: \.projectId) + return grouped.map { projectId, projectItems in + AttentionProjectLens( + id: projectId, + name: projectItems.first?.projectName ?? "Project", + machineCount: Set(projectItems.map(\.machineId)).count, + itemCount: projectItems.count + ) + } + .sorted { + if $0.itemCount != $1.itemCount { return $0.itemCount > $1.itemCount } + return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + } + + public var visibleMachineCount: Int { + Set( + (items + liveItems + recentItems) + .filter { selectedProjectId == nil || $0.projectId == selectedProjectId } + .map(\.machineId) + ).count + } + /// Dismiss-all entry point. Updates `lastSeenAt` → `Date.now` and /// zeroes `unreadCount`. `items` is untouched (the drawer still lists /// outstanding attention until the underlying state clears). public func markAllSeen() { lastSeenAt = Date() + let ids = accountBackedItemIDs.intersection( + Set((items + recentItems.filter { $0.seenAt == nil }).map(\.id)) + ) + if !ids.isEmpty { + Task { await AccountService.shared.acknowledgeAttentionItems(Array(ids), dismiss: false) } + } + } + + public func markSeen(_ itemId: String) { + seenItemIDs.insert(itemId) + persistSeenItems() + recomputeUnreadCount() + if accountBackedItemIDs.contains(itemId) { + Task { await AccountService.shared.acknowledgeAttentionItems([itemId], dismiss: false) } + } } /// Clear the currently visible attention cards from the drawer. The @@ -219,9 +482,13 @@ public final class AttentionDrawerModel: ObservableObject { } dismissedItemIDs.formUnion(items.map(\.id)) + let accountIds = accountBackedItemIDs.intersection(Set(items.map(\.id))) persistDismissedItems() items.removeAll() markAllSeen() + if !accountIds.isEmpty { + Task { await AccountService.shared.acknowledgeAttentionItems(Array(accountIds), dismiss: true) } + } } // MARK: - Bell affordance @@ -236,7 +503,12 @@ public final class AttentionDrawerModel: ObservableObject { // MARK: - Private private func recomputeUnreadCount() { - unreadCount = items.filter { $0.timestamp > lastSeenAt }.count + let inbox = items + recentItems.filter { $0.seenAt == nil } + unreadCount = inbox.filter { + $0.seenAt == nil + && !seenItemIDs.contains($0.id) + && $0.timestamp > lastSeenAt + }.count } private func pruneDismissedItems(activeIDs: Set) { @@ -250,6 +522,17 @@ public final class AttentionDrawerModel: ObservableObject { defaults.set(Array(dismissedItemIDs).sorted(), forKey: Self.dismissedItemIDsKey) } + private func pruneSeenItems(activeIDs: Set) { + let pruned = seenItemIDs.intersection(activeIDs) + guard pruned != seenItemIDs else { return } + seenItemIDs = pruned + persistSeenItems() + } + + private func persistSeenItems() { + defaults.set(Array(seenItemIDs).sorted(), forKey: Self.seenItemIDsKey) + } + private static func kindPriority(_ kind: AttentionKind) -> Int { switch kind { case .awaitingInput: return 0 @@ -257,21 +540,164 @@ public final class AttentionDrawerModel: ObservableObject { case .ciFailing: return 2 case .reviewRequested: return 3 case .mergeReady: return 4 + case .running: return 5 + case .stale: return 6 + case .open: return 7 + case .completed: return 7 + case .merged: return 7 + } + } + + private func sort(_ values: inout [AttentionItem]) { + values.sort { lhs, rhs in + let lp = Self.kindPriority(lhs.kind) + let rp = Self.kindPriority(rhs.kind) + if lp != rp { return lp < rp } + if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } + return lhs.id < rhs.id } } private static func humanAgentTitle(_ snapshot: AgentSnapshot) -> String { - let provider = snapshot.provider.capitalized + let provider = ADESharedTheme.providerDisplayName(for: snapshot.provider) ?? "Agent" if let title = snapshot.title, !title.isEmpty { return "\(provider) · \(title)" } return "\(provider) · \(snapshot.sessionId)" } + private static func agentPhaseLabel(_ phase: String?) -> String? { + guard let phase = nonEmpty(phase)?.lowercased() else { return nil } + switch phase { + case "starting": return "Starting" + case "running": return "Running" + case "planning", "plan": return "Planning" + case "development", "developing", "implementation", "implementing": return "Building" + case "testing", "test": return "Testing" + case "validation", "validating": return "Validating" + case "review", "reviewing": return "Reviewing" + case "pr", "pull_request": return "Preparing pull request" + case "waiting_for_approval", "needs_approval": return "Needs approval" + case "waiting_for_input", "awaiting_input", "needs_you": return "Needs reply" + case "blocked": return "Blocked" + case "completed", "done": return "Completed" + case "failed", "error": return "Failed" + case "stale": return "Stale" + default: return nil + } + } + private static func isAgentFailed(_ snapshot: AgentSnapshot) -> Bool { let s = snapshot.status.lowercased() return s == "failed" || s == "error" } + + private static func isAgentCompleted(_ snapshot: AgentSnapshot) -> Bool { + let status = snapshot.status.lowercased() + return status == "completed" || status == "ended" + } + + private static func isAgentLive(_ snapshot: AgentSnapshot) -> Bool { + let status = snapshot.status.lowercased() + return status != "idle" + && status != "completed" + && status != "ended" + && status != "failed" + && status != "error" + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { + return nil + } + return value + } + + private static func makeItem(_ source: AccountAttentionItem) -> AttentionItem { + let kind: AttentionKind + let collection: AttentionCollection + switch source.phase { + case .needsYou, .blocked: + kind = .awaitingInput + collection = source.phase == .blocked ? .live : .needsYou + case .failed: + kind = .failed + collection = .needsYou + case .checksFailing: + kind = .ciFailing + collection = .needsYou + case .reviewRequested, .changesRequested: + kind = .reviewRequested + collection = .needsYou + case .mergeReady: + kind = .mergeReady + collection = .needsYou + case .starting, .running: + kind = .running + collection = .live + case .stale: + kind = .stale + collection = .live + case .open: + kind = .open + collection = .recent + case .completed, .closed: + kind = .completed + collection = .recent + case .merged: + kind = .merged + collection = .recent + } + + let destination = source.destination + let session: (String?, String?) + let pullRequest: (String?, Int?) + switch destination { + case .session(let sessionId, let itemId, _): + session = (sessionId, itemId) + pullRequest = (nil, nil) + case .pullRequest(let prId, _, _, let number, _, _): + session = (nil, nil) + pullRequest = (prId, number) + } + + let preview = nonEmpty(source.preview) + ?? nonEmpty(source.detail) + ?? nonEmpty(source.privacyPreview) + ?? source.phase.displayLabel + + return AttentionItem( + id: source.id, + kind: kind, + title: source.title, + subtitle: preview, + providerSlug: source.provider, + sessionId: session.0, + itemId: session.1, + prId: pullRequest.0, + prNumber: pullRequest.1, + deepLink: source.deepLinkURL, + timestamp: source.updatedAt, + collection: collection, + machineId: source.machine.machineKey, + machineName: source.machine.name, + machineOnline: source.machine.online, + projectId: source.project.projectId, + projectName: source.project.name, + laneName: source.laneName, + phaseLabel: source.phase.displayLabel, + seenAt: source.seenAt, + inlineActionsAllowed: false + ) + } + + private func validateSelectedProject() { + guard let selectedProjectId else { return } + if !(items + liveItems + recentItems).contains(where: { $0.projectId == selectedProjectId }) { + self.selectedProjectId = nil + } + } } // MARK: - SyncService wiring @@ -290,6 +716,11 @@ extension AttentionDrawerModel { let refresh: () -> Void = { [weak self, weak syncService] in guard let self, let syncService else { return } + if let attention = ADESharedContainer.readAttentionSnapshot(), + Date().timeIntervalSince(attention.generatedAt) <= 86_400 { + self.rebuild(from: attention) + return + } let snapshot = ADESharedContainer.readWorkspaceSnapshot() ?? WorkspaceSnapshot( generatedAt: Date(), @@ -315,6 +746,11 @@ extension AttentionDrawerModel { .sink { _ in refresh() } .store(in: &bag) + AccountService.shared.$attentionSnapshotRevision + .receive(on: DispatchQueue.main) + .sink { _ in refresh() } + .store(in: &bag) + refresh() return bag } diff --git a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift index 87377975b..3e7e7cb48 100644 --- a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift +++ b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift @@ -1,29 +1,28 @@ import AppIntents import SwiftUI -/// Presented as a `medium`/`large` sheet from any root screen when the user -/// taps `AttentionDrawerButton`. Groups every pending attention item by -/// kind and offers the same action chips as the lock-screen card. +/// Account-wide attention center. It renders the same priority stack whether +/// its source is the signed-in account snapshot or the current +/// `WorkspaceSnapshot` fallback. @available(iOS 17.0, *) struct AttentionDrawerSheet: View { - @EnvironmentObject private var syncService: SyncService @EnvironmentObject private var drawer: AttentionDrawerModel + @EnvironmentObject private var accountService: AccountService @Environment(\.dismiss) private var dismiss @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var didAppear = false - // Sections are rendered in this fixed priority order so the UI feels - // consistent even as counts shift. - private static let sectionOrder: [AttentionKind] = [ - .awaitingInput, .failed, .ciFailing, .reviewRequested, .mergeReady, - ] + private var needsYou: [AttentionItem] { drawer.visibleItems(in: .needsYou) } + private var live: [AttentionItem] { drawer.visibleItems(in: .live) } + private var recent: [AttentionItem] { drawer.visibleItems(in: .recent) } var body: some View { NavigationStack { Group { - if drawer.items.isEmpty { + if needsYou.isEmpty && live.isEmpty && recent.isEmpty { emptyState } else { - list + priorityStack } } .navigationTitle("Attention") @@ -33,10 +32,22 @@ struct AttentionDrawerSheet: View { Button("Done") { dismiss() } } ToolbarItem(placement: .topBarTrailing) { - Button("Clear all") { - drawer.clearVisibleItems() + Menu { + Button { + drawer.markAllSeen() + } label: { + Label("Mark all seen", systemImage: "checkmark.circle") + } + Button(role: .destructive) { + drawer.clearVisibleItems() + } label: { + Label("Dismiss pending", systemImage: "rectangle.stack.badge.minus") + } + .disabled(drawer.items.isEmpty) + } label: { + Image(systemName: "ellipsis.circle") } - .disabled(drawer.items.isEmpty) + .accessibilityLabel("Attention actions") } } .adeScreenBackground() @@ -44,23 +55,272 @@ struct AttentionDrawerSheet: View { } .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) - .onAppear { drawer.markAllSeen() } + .presentationContentInteraction(.scrolls) + .task { + await accountService.refreshAttentionSnapshot() + await accountService.updateAttentionPresence( + centerVisible: true, + visibleItemIds: visibleItemIds + ) + } + .onDisappear { + Task { + await accountService.updateAttentionPresence( + centerVisible: false, + visibleItemIds: [] + ) + } + } + .onChange(of: drawer.selectedProjectId) { + Task { + await accountService.updateAttentionPresence( + centerVisible: true, + visibleItemIds: visibleItemIds + ) + } + } + .onAppear { + guard !didAppear else { return } + if reduceMotion { + didAppear = true + } else { + withAnimation(.spring(response: 0.5, dampingFraction: 0.86)) { + didAppear = true + } + } + } } - // MARK: - Empty state + private var visibleItemIds: [String] { + Array((needsYou + live + recent).map(\.id).prefix(64)) + } + + private var priorityStack: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 22) { + overview + .opacity(didAppear ? 1 : 0) + .offset(y: didAppear ? 0 : 8) + + if !drawer.projectLenses.isEmpty { + projectLensStrip + } + + if !needsYou.isEmpty { + AttentionSectionHeader( + title: "Needs you", + count: needsYou.count, + systemImage: "bell.badge.fill", + tint: ADESharedTheme.warningAmber, + detail: "Decisions, failures, and reviews" + ) + + AttentionHeroCard(item: needsYou[0]) { + follow(needsYou[0]) + } markSeen: { + drawer.markSeen(needsYou[0].id) + } + + ForEach(needsYou.dropFirst()) { item in + AttentionCenterCard(item: item) { + follow(item) + } markSeen: { + drawer.markSeen(item.id) + } + } + } else { + allCaughtUpStrip + } + + if !live.isEmpty { + AttentionSectionHeader( + title: "Live", + count: live.count, + systemImage: "waveform.path.ecg", + tint: ADESharedTheme.statusRunning, + detail: "Work moving across your machines" + ) + + VStack(spacing: 0) { + ForEach(Array(live.enumerated()), id: \.element.id) { index, item in + AttentionLiveRow(item: item) { + follow(item) + } + if index < live.count - 1 { + Divider() + .overlay(Color.white.opacity(0.06)) + .padding(.leading, 50) + } + } + } + .background(ADEColor.cardBackground.opacity(0.9), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(ADEColor.glassBorder, lineWidth: 0.7) + ) + } + + if !recent.isEmpty { + AttentionSectionHeader( + title: "Recent", + count: recent.count, + systemImage: "clock.arrow.circlepath", + tint: ADEColor.textSecondary, + detail: "Outcomes from the last 24 hours" + ) + + VStack(spacing: 10) { + ForEach(recent) { item in + AttentionRecentRow(item: item) { + follow(item) + } + } + } + } + } + .padding(.horizontal, 16) + .padding(.top, 14) + .padding(.bottom, 34) + } + .scrollBounceBehavior(.basedOnSize) + } + + private var overview: some View { + HStack(spacing: 14) { + ZStack { + Circle() + .fill( + RadialGradient( + colors: [ + PrGlassPalette.purple.opacity(0.34), + PrGlassPalette.purple.opacity(0), + ], + center: .center, + startRadius: 2, + endRadius: 42 + ) + ) + .frame(width: 74, height: 74) + .blur(radius: 5) + + Circle() + .fill(.ultraThinMaterial) + .frame(width: 48, height: 48) + .overlay(Circle().strokeBorder(PrGlassPalette.accentGradient, lineWidth: 0.8)) + + Image(systemName: "scope") + .font(.system(size: 21, weight: .semibold)) + .foregroundStyle(PrGlassPalette.purple) + .symbolEffect(.pulse, options: reduceMotion ? .nonRepeating : .repeating) + } + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 5) { + Text(overviewTitle) + .font(.title3.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text(overviewSubtitle) + .font(.subheadline) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(2) + + HStack(spacing: 7) { + AttentionCountPill(count: needsYou.count, label: "need you", tint: ADESharedTheme.warningAmber) + AttentionCountPill(count: live.count, label: "live", tint: ADESharedTheme.statusRunning) + } + } + Spacer(minLength: 0) + } + .padding(14) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder( + LinearGradient( + colors: [Color.white.opacity(0.18), PrGlassPalette.purple.opacity(0.12)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 0.8 + ) + ) + .accessibilityElement(children: .combine) + } + + private var overviewTitle: String { + if let selected = drawer.projectLenses.first(where: { $0.id == drawer.selectedProjectId }) { + return selected.name + } + return "Across your work" + } + + private var overviewSubtitle: String { + let count = drawer.visibleMachineCount + if count == 0 { return "Your connected projects will appear here." } + return count == 1 + ? "One machine, every active thread in one place." + : "\(count) machines, every active thread in one place." + } + + private var projectLensStrip: some View { + ScrollView(.horizontal) { + HStack(spacing: 8) { + ProjectLensButton( + title: "All projects", + count: drawer.projectLenses.reduce(0) { $0 + $1.itemCount }, + selected: drawer.selectedProjectId == nil + ) { + selectProject(nil) + } + + ForEach(drawer.projectLenses) { project in + ProjectLensButton( + title: project.name, + count: project.itemCount, + selected: drawer.selectedProjectId == project.id + ) { + selectProject(project.id) + } + } + } + .padding(.horizontal, 1) + } + .scrollIndicators(.hidden) + .accessibilityLabel("Project filter") + } + + private var allCaughtUpStrip: some View { + HStack(spacing: 11) { + Image(systemName: "checkmark.seal.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(ADESharedTheme.statusSuccess) + .symbolEffect(.bounce, value: didAppear) + VStack(alignment: .leading, spacing: 2) { + Text("Nothing needs you") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text(live.isEmpty ? "Everything is quiet." : "Live work is moving without a blocker.") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + } + Spacer(minLength: 0) + } + .padding(13) + .background(ADESharedTheme.statusSuccess.opacity(0.08), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(ADESharedTheme.statusSuccess.opacity(0.2), lineWidth: 0.7) + ) + } private var emptyState: some View { VStack(spacing: 16) { Spacer() ZStack { - // Radial purple bloom behind the disc — signals the "PRs / attention" surface. Circle() .fill( RadialGradient( - colors: [ - PrGlassPalette.purple.opacity(0.30), - PrGlassPalette.purple.opacity(0.0), - ], + colors: [PrGlassPalette.purple.opacity(0.30), .clear], center: .center, startRadius: 0, endRadius: 56 @@ -72,15 +332,7 @@ struct AttentionDrawerSheet: View { Circle() .fill(.ultraThinMaterial) .frame(width: 64, height: 64) - .overlay( - Circle() - .strokeBorder( - PrGlassPalette.accentGradient, - lineWidth: 1 - ) - .opacity(0.6) - ) - .shadow(color: PrGlassPalette.purple.opacity(0.25), radius: 10, x: 0, y: 4) + .overlay(Circle().strokeBorder(PrGlassPalette.accentGradient, lineWidth: 1).opacity(0.6)) Image(systemName: "sparkles") .font(.system(size: 28, weight: .regular)) @@ -89,10 +341,10 @@ struct AttentionDrawerSheet: View { } VStack(spacing: 6) { - Text("No pending items") + Text("All clear") .font(.title3.weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) - Text("All agents are running smoothly.") + Text("Agent work from your connected machines will gather here.") .font(.subheadline) .foregroundStyle(ADEColor.textSecondary) .multilineTextAlignment(.center) @@ -103,103 +355,165 @@ struct AttentionDrawerSheet: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.horizontal, 32) .accessibilityElement(children: .combine) - .accessibilityLabel("No pending attention items. All agents are running smoothly.") - } - - // MARK: - List - - private var list: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 18) { - ForEach(Self.sectionOrder, id: \.self) { kind in - let subset = drawer.items.filter { $0.kind == kind } - if !subset.isEmpty { - section(kind: kind, items: subset) - } - } - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .padding(.bottom, 24) - } - .scrollBounceBehavior(.basedOnSize) + .accessibilityLabel("All clear. No pending attention items.") } - private func section(kind: AttentionKind, items: [AttentionItem]) -> some View { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 10) { - AttentionBadge(kind: kind, size: 20, pulse: false) - Text(Self.label(for: kind)) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Text("\(items.count)") - .font(.caption.monospacedDigit()) - .foregroundStyle(ADEColor.textSecondary) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background( - Capsule().fill(.ultraThinMaterial) - ) - .overlay( - Capsule() - .strokeBorder(Color.white.opacity(0.10), lineWidth: 0.5) - ) - Spacer(minLength: 0) - } - .padding(.horizontal, 4) - - VStack(spacing: 10) { - ForEach(items) { item in - AttentionDrawerCard(item: item) { - follow(item) - } - } + private func selectProject(_ id: String?) { + if reduceMotion { + drawer.selectProject(id) + } else { + withAnimation(.snappy(duration: 0.28)) { + drawer.selectProject(id) } } } - // MARK: - Deep-link - private func follow(_ item: AttentionItem) { guard let url = item.deepLink else { return } - drawer.markAllSeen() + drawer.markSeen(item.id) dismiss() - // Small delay so the sheet finishes dismissing before the tab - // switch animation fires — otherwise the system cross-fades the - // two transitions and the destination push feels jittery. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + (reduceMotion ? 0 : 0.18)) { DeepLinkRouter.shared.handle(url) } } +} - private static func label(for kind: AttentionKind) -> String { - switch kind { - case .awaitingInput: return "Awaiting input" - case .failed: return "Failed" - case .ciFailing: return "CI failing" - case .reviewRequested: return "Review requested" - case .mergeReady: return "Merge ready" +@available(iOS 17.0, *) +private struct AttentionSectionHeader: View { + let title: String + let count: Int + let systemImage: String + let tint: Color + let detail: String + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 8) { + Image(systemName: systemImage) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(tint) + Text(title) + .font(.headline) + .foregroundStyle(ADEColor.textPrimary) + Text("\(count)") + .font(.caption.weight(.semibold).monospacedDigit()) + .foregroundStyle(tint) + .contentTransition(.numericText()) + Spacer(minLength: 0) + } + Text(detail) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) } + .padding(.horizontal, 2) + .accessibilityElement(children: .combine) } } -/// Lightweight attention card used inside the drawer. Shares the visual -/// language of the lock-screen `AttentionCard` (tinted bg, thin border, -/// badge + copy + action row) but lives inline here so the drawer is -/// self-contained and doesn't drag the widget target's card into the app. @available(iOS 17.0, *) -private struct AttentionDrawerCard: View { +private struct AttentionHeroCard: View { let item: AttentionItem - let onTap: () -> Void + let open: () -> Void + let markSeen: () -> Void var body: some View { let tint = AttentionIcon.tint(for: item.kind) + VStack(alignment: .leading, spacing: 13) { + Button(action: open) { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .center, spacing: 11) { + AttentionBadge(kind: item.kind, size: 38, pulse: item.kind == .awaitingInput) + VStack(alignment: .leading, spacing: 2) { + Text(item.phaseLabel.uppercased()) + .font(.caption2.weight(.bold).monospaced()) + .tracking(0.6) + .foregroundStyle(tint) + Text(item.scopeLabel) + .font(.caption.weight(.medium)) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + } + Spacer(minLength: 0) + OfflineBadge(online: item.machineOnline) + } - VStack(alignment: .leading, spacing: 10) { - Button(action: onTap) { + Text(item.title) + .font(.title3.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(2) + .multilineTextAlignment(.leading) + + Text(item.subtitle) + .font(.subheadline) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(3) + .multilineTextAlignment(.leading) + + HStack(spacing: 7) { + if let provider = item.providerSlug { + BrandDot(slug: provider, size: 14, pulse: item.kind == .running) + Text(ADESharedTheme.providerDisplayName(for: provider) ?? provider) + } + if let lane = item.laneName, !lane.isEmpty { + Text("·") + Text(lane) + } + Spacer(minLength: 0) + Text(item.timestamp, style: .relative) + } + .font(.caption2.weight(.medium)) + .foregroundStyle(ADEColor.textSecondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + AttentionDrawerActionRow(item: item, open: open, markSeen: markSeen) + } + .padding(16) + .background( + ZStack { + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(ADEColor.cardBackground.opacity(0.98)) + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill( + RadialGradient( + colors: [tint.opacity(0.16), tint.opacity(0.02), .clear], + center: .topLeading, + startRadius: 0, + endRadius: 260 + ) + ) + } + ) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder( + LinearGradient( + colors: [tint.opacity(0.45), Color.white.opacity(0.08)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 0.9 + ) + ) + .shadow(color: tint.opacity(0.11), radius: 18, x: 0, y: 8) + .accessibilityElement(children: .contain) + } +} + +@available(iOS 17.0, *) +private struct AttentionCenterCard: View { + let item: AttentionItem + let open: () -> Void + let markSeen: () -> Void + + var body: some View { + let tint = AttentionIcon.tint(for: item.kind) + VStack(alignment: .leading, spacing: 11) { + Button(action: open) { HStack(alignment: .top, spacing: 12) { AttentionBadge(kind: item.kind, size: 30, pulse: item.kind == .awaitingInput) - VStack(alignment: .leading, spacing: 4) { Text(item.title) .font(.subheadline.weight(.semibold)) @@ -211,145 +525,198 @@ private struct AttentionDrawerCard: View { .foregroundStyle(ADEColor.textSecondary) .lineLimit(2) .multilineTextAlignment(.leading) + Text(item.scopeLabel) + .font(.caption2.weight(.medium)) + .foregroundStyle(ADEColor.textSecondary.opacity(0.86)) + .lineLimit(1) } Spacer(minLength: 0) - - if let slug = item.providerSlug { - BrandDot(slug: slug, size: 10, pulse: false) - .padding(.top, 4) - } + Text(item.timestamp, style: .relative) + .font(.caption2) + .foregroundStyle(ADEColor.textSecondary) } } .buttonStyle(.plain) - .accessibilityLabel("\(item.title). \(item.subtitle)") - .accessibilityHint(item.deepLink == nil ? "" : "Opens the related surface.") - AttentionDrawerActionRow(item: item, open: onTap) + AttentionDrawerActionRow(item: item, open: open, markSeen: markSeen) } .padding(14) - .background(cardBackground(tint: tint)) + .background(ADEColor.cardBackground.opacity(0.96), in: RoundedRectangle(cornerRadius: 15, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill( - LinearGradient( - colors: [Color.white.opacity(0.06), .clear], - startPoint: .top, - endPoint: .center - ) - ) - .allowsHitTesting(false) + RoundedRectangle(cornerRadius: 15, style: .continuous) + .strokeBorder(tint.opacity(0.24), lineWidth: 0.7) ) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder( - LinearGradient( - colors: [ - tint.opacity(0.32), - tint.opacity(0.10), - ], - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.75 - ) - ) - .shadow(color: Color.black.opacity(0.18), radius: 3, x: 0, y: 1) - .accessibilityElement(children: .contain) } +} - private func cardBackground(tint: Color) -> some View { - ZStack { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(ADEColor.cardBackground.opacity(0.98)) - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(tint.opacity(0.045)) - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill( - RadialGradient( - colors: [tint.opacity(0.06), tint.opacity(0.0)], - center: .topLeading, - startRadius: 0, - endRadius: 180 - ) - ) +@available(iOS 17.0, *) +private struct AttentionLiveRow: View { + let item: AttentionItem + let open: () -> Void + + var body: some View { + Button(action: open) { + HStack(spacing: 11) { + BrandDot(slug: item.providerSlug ?? "ade", size: 16, pulse: item.machineOnline) + .frame(width: 24) + VStack(alignment: .leading, spacing: 3) { + Text(item.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + Text(item.scopeLabel) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + } + Spacer(minLength: 6) + VStack(alignment: .trailing, spacing: 3) { + Label(item.phaseLabel, systemImage: item.machineOnline ? "waveform.path" : "wifi.slash") + .font(.caption2.weight(.semibold)) + .foregroundStyle(AttentionIcon.tint(for: item.kind)) + .labelStyle(.titleAndIcon) + Text(item.timestamp, style: .relative) + .font(.caption2) + .foregroundStyle(ADEColor.textSecondary) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .accessibilityLabel("\(item.title), \(item.phaseLabel), \(item.scopeLabel)") + .accessibilityHint("Opens the related agent.") } } @available(iOS 17.0, *) -private struct AttentionDrawerActionRow: View { +private struct AttentionRecentRow: View { let item: AttentionItem let open: () -> Void var body: some View { - HStack(spacing: 8) { - switch item.kind { - case .awaitingInput: - let canAnswerInline = !(item.itemId ?? "").isEmpty - if canAnswerInline { - Button(intent: ApproveSessionIntent(sessionId: item.sessionId ?? "", itemId: item.itemId ?? "")) { - AttentionDrawerActionLabel("Approve", systemImage: "checkmark", variant: .primary(ADEColor.success)) - } - .buttonStyle(.plain) - - Button(intent: DenySessionIntent(sessionId: item.sessionId ?? "", itemId: item.itemId ?? "")) { - AttentionDrawerActionLabel("Deny", systemImage: "xmark", variant: .danger) - } - .buttonStyle(.plain) + Button(action: open) { + HStack(spacing: 11) { + Image(systemName: AttentionIcon.symbol(for: item.kind)) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(AttentionIcon.tint(for: item.kind)) + .frame(width: 30, height: 30) + .background(AttentionIcon.tint(for: item.kind).opacity(0.1), in: Circle()) + VStack(alignment: .leading, spacing: 3) { + Text(item.title) + .font(.subheadline.weight(.medium)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + Text(item.scopeLabel) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) } + Spacer(minLength: 6) + Text(item.timestamp, style: .relative) + .font(.caption2) + .foregroundStyle(ADEColor.textSecondary) + } + .padding(12) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(ADEColor.cardBackground.opacity(0.7), in: RoundedRectangle(cornerRadius: 13, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 13, style: .continuous) + .strokeBorder(ADEColor.glassBorder.opacity(0.8), lineWidth: 0.6) + ) + } +} - Button(action: open) { - AttentionDrawerActionLabel(canAnswerInline ? "Reply" : "Open session", systemImage: "text.bubble", variant: .secondary) +@available(iOS 17.0, *) +private struct AttentionDrawerActionRow: View { + let item: AttentionItem + let open: () -> Void + let markSeen: () -> Void + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { buttons } + VStack(spacing: 8) { buttons } + } + } + + @ViewBuilder + private var buttons: some View { + switch item.kind { + case .awaitingInput: + let canAnswerInline = item.inlineActionsAllowed + && !(item.itemId ?? "").isEmpty + && item.machineOnline + if canAnswerInline { + Button(intent: ApproveSessionIntent(sessionId: item.sessionId ?? "", itemId: item.itemId ?? "")) { + AttentionDrawerActionLabel("Approve", systemImage: "checkmark", variant: .primary(ADEColor.success)) } .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded(markSeen)) - case .failed: - Button(action: open) { - AttentionDrawerActionLabel("Open agent", systemImage: "arrow.right", variant: .primary(ADEColor.accent)) + Button(intent: DenySessionIntent(sessionId: item.sessionId ?? "", itemId: item.itemId ?? "")) { + AttentionDrawerActionLabel("Deny", systemImage: "xmark", variant: .danger) } .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded(markSeen)) + } + Button(action: open) { + AttentionDrawerActionLabel(canAnswerInline ? "Reply" : "Open session", systemImage: "text.bubble", variant: .secondary) + } + .buttonStyle(.plain) + case .failed: + Button(action: open) { + AttentionDrawerActionLabel("Open agent", systemImage: "arrow.right", variant: .primary(ADEColor.accent)) + } + .buttonStyle(.plain) + if item.inlineActionsAllowed && item.machineOnline { Button(intent: RestartSessionIntent(sessionId: item.sessionId ?? "")) { AttentionDrawerActionLabel("Restart", systemImage: "arrow.uturn.backward", variant: .secondary) } .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded(markSeen)) + } - case .ciFailing: - Button(action: open) { - AttentionDrawerActionLabel(prLabel("Open"), systemImage: "arrow.triangle.branch", variant: .primary(ADEColor.accent)) - } - .buttonStyle(.plain) - + case .ciFailing: + Button(action: open) { + AttentionDrawerActionLabel(prLabel("Open"), systemImage: "arrow.triangle.branch", variant: .primary(ADEColor.accent)) + } + .buttonStyle(.plain) + if item.inlineActionsAllowed && item.machineOnline { Button(intent: RetryCheckIntent(prNumber: item.prNumber ?? 0, prId: item.prId ?? "")) { AttentionDrawerActionLabel("Rerun CI", systemImage: "arrow.uturn.backward", variant: .secondary) } .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded(markSeen)) + } - case .reviewRequested: - Button(action: open) { - AttentionDrawerActionLabel(prLabel("Review"), systemImage: "eye", variant: .primary(ADEColor.accent)) - } - .buttonStyle(.plain) + case .reviewRequested: + Button(action: open) { + AttentionDrawerActionLabel(prLabel("Review"), systemImage: "eye", variant: .primary(ADEColor.accent)) + } + .buttonStyle(.plain) - case .mergeReady: - Button(action: open) { - AttentionDrawerActionLabel(prLabel("Merge"), systemImage: "checkmark.seal", variant: .primary(ADEColor.success)) - } - .buttonStyle(.plain) + case .mergeReady: + Button(action: open) { + AttentionDrawerActionLabel(prLabel("Review merge"), systemImage: "checkmark.seal", variant: .primary(ADEColor.success)) + } + .buttonStyle(.plain) - Button(action: open) { - AttentionDrawerActionLabel("View", systemImage: "arrow.right", variant: .secondary) - } - .buttonStyle(.plain) + case .running, .open, .completed, .merged, .stale: + Button(action: open) { + AttentionDrawerActionLabel("Open", systemImage: "arrow.right", variant: .secondary) } + .buttonStyle(.plain) } } private func prLabel(_ verb: String) -> String { - if let number = item.prNumber, number > 0 { - return "\(verb) #\(number)" - } - return "\(verb) PR" + guard let number = item.prNumber, number > 0 else { return "\(verb) PR" } + return "\(verb) #\(number)" } } @@ -387,14 +754,10 @@ private enum AttentionDrawerActionVariant { @available(iOS 17.0, *) private struct AttentionDrawerActionLabel: View { let title: String - let systemImage: String? + let systemImage: String let variant: AttentionDrawerActionVariant - init( - _ title: String, - systemImage: String? = nil, - variant: AttentionDrawerActionVariant - ) { + init(_ title: String, systemImage: String, variant: AttentionDrawerActionVariant) { self.title = title self.systemImage = systemImage self.variant = variant @@ -402,25 +765,89 @@ private struct AttentionDrawerActionLabel: View { var body: some View { HStack(spacing: 5) { - if let systemImage { - Image(systemName: systemImage) - .font(.system(size: 10, weight: .bold)) - } + Image(systemName: systemImage) + .font(.system(size: 10, weight: .bold)) Text(title) - .font(.system(size: 12, weight: .semibold)) + .font(.caption.weight(.semibold)) .lineLimit(1) - .minimumScaleFactor(0.82) + .minimumScaleFactor(0.76) } .foregroundStyle(variant.foreground) .frame(maxWidth: .infinity) - .padding(.vertical, 7) + .padding(.vertical, 8) .padding(.horizontal, 10) - .background(variant.background, in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + .background(variant.background, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: 9, style: .continuous) + RoundedRectangle(cornerRadius: 10, style: .continuous) .strokeBorder(variant.stroke, lineWidth: 0.6) ) - .contentShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } +} + +@available(iOS 17.0, *) +private struct AttentionCountPill: View { + let count: Int + let label: String + let tint: Color + + var body: some View { + Text("\(count) \(label)") + .font(.caption2.weight(.semibold).monospacedDigit()) + .foregroundStyle(count > 0 ? tint : ADEColor.textSecondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background((count > 0 ? tint : ADEColor.textSecondary).opacity(0.1), in: Capsule()) + } +} + +@available(iOS 17.0, *) +private struct ProjectLensButton: View { + let title: String + let count: Int + let selected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + Text(title) + .lineLimit(1) + Text("\(count)") + .font(.caption2.monospacedDigit()) + .opacity(0.72) + } + .font(.caption.weight(.semibold)) + .foregroundStyle(selected ? Color.white : ADEColor.textSecondary) + .padding(.horizontal, 11) + .padding(.vertical, 7) + .background( + selected ? AnyShapeStyle(PrGlassPalette.accentGradient) : AnyShapeStyle(Color.white.opacity(0.055)), + in: Capsule(style: .continuous) + ) + .overlay( + Capsule(style: .continuous) + .strokeBorder(selected ? Color.white.opacity(0.2) : ADEColor.glassBorder, lineWidth: 0.7) + ) + } + .buttonStyle(.plain) + .accessibilityAddTraits(selected ? .isSelected : []) + } +} + +@available(iOS 17.0, *) +private struct OfflineBadge: View { + let online: Bool + + var body: some View { + if !online { + Label("Offline", systemImage: "wifi.slash") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADESharedTheme.statusIdle) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(ADESharedTheme.statusIdle.opacity(0.1), in: Capsule()) + } } } @@ -428,21 +855,27 @@ private struct AttentionDrawerActionLabel: View { private enum AttentionIcon { static func symbol(for kind: AttentionKind) -> String { switch kind { - case .awaitingInput: return "bell.badge.fill" - case .failed: return "xmark.octagon.fill" - case .ciFailing: return "exclamationmark.triangle.fill" + case .awaitingInput: return "bell.badge.fill" + case .failed: return "xmark.octagon.fill" + case .ciFailing: return "exclamationmark.triangle.fill" case .reviewRequested: return "eye.fill" - case .mergeReady: return "checkmark.seal.fill" + case .mergeReady: return "checkmark.seal.fill" + case .running: return "waveform.path.ecg" + case .open: return "arrow.triangle.pull" + case .completed: return "checkmark.circle.fill" + case .merged: return "arrow.triangle.merge" + case .stale: return "wifi.slash" } } static func tint(for kind: AttentionKind) -> Color { switch kind { - case .awaitingInput: return ADESharedTheme.warningAmber - case .failed: return ADESharedTheme.statusFailed - case .ciFailing: return ADESharedTheme.statusFailed - case .reviewRequested: return ADESharedTheme.warningAmber - case .mergeReady: return ADESharedTheme.statusSuccess + case .awaitingInput: return ADESharedTheme.warningAmber + case .failed, .ciFailing: return ADESharedTheme.statusFailed + case .reviewRequested: return ADESharedTheme.statusReview + case .mergeReady, .completed, .merged: return ADESharedTheme.statusSuccess + case .running, .open: return ADESharedTheme.statusRunning + case .stale: return ADESharedTheme.statusIdle } } } @@ -452,7 +885,6 @@ private struct AttentionBadge: View { let kind: AttentionKind let size: CGFloat let pulse: Bool - @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { @@ -466,10 +898,10 @@ private struct AttentionBadge: View { Circle() .stroke(color, lineWidth: 1.5) .frame(width: size, height: size) - .phaseAnimator([0, 1]) { circle, phase in + .phaseAnimator([false, true]) { circle, expanded in circle - .scaleEffect(phase == 0 ? 1.0 : 1.5) - .opacity(phase == 0 ? 0.9 : 0) + .scaleEffect(expanded ? 1.5 : 1) + .opacity(expanded ? 0 : 0.9) } animation: { _ in .easeOut(duration: 1.6) } @@ -489,7 +921,6 @@ private struct BrandDot: View { let slug: String let size: CGFloat let pulse: Bool - @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { @@ -499,18 +930,31 @@ private struct BrandDot: View { Circle() .fill(color) .frame(width: size, height: size) - .phaseAnimator([0, 1]) { circle, phase in + .phaseAnimator([false, true]) { circle, expanded in circle - .scaleEffect(phase == 0 ? 1.0 : 1.4) - .opacity(phase == 0 ? 0.35 : 0) + .scaleEffect(expanded ? 1.6 : 1) + .opacity(expanded ? 0 : 0.34) } animation: { _ in - .easeInOut(duration: 1.4) + .easeInOut(duration: 1.5) } } Circle() - .fill(color) + .fill(color.opacity(0.18)) .frame(width: size, height: size) - .shadow(color: color.opacity(0.4), radius: size * 0.3, x: 0, y: 0) + .overlay { + if let assetName = ADESharedTheme.providerAssetName(for: slug) { + Image(assetName) + .resizable() + .scaledToFit() + .frame(width: size * 0.7, height: size * 0.7) + } else { + Circle() + .fill(color) + .frame(width: size * 0.48, height: size * 0.48) + } + } + .overlay(Circle().strokeBorder(color.opacity(0.32), lineWidth: 0.6)) + .shadow(color: color.opacity(0.3), radius: size * 0.22) } .frame(width: size, height: size) .accessibilityHidden(true) @@ -523,19 +967,16 @@ private struct BellWiggle: ViewModifier { func body(content: Content) -> some View { if active { - content.keyframeAnimator( - initialValue: 0.0, - repeating: true - ) { view, rotation in + content.keyframeAnimator(initialValue: 0.0, repeating: true) { view, rotation in view.rotationEffect(.degrees(rotation)) } keyframes: { _ in KeyframeTrack { - LinearKeyframe(0, duration: 1.32) - CubicKeyframe(-14, duration: 0.176) - CubicKeyframe(12, duration: 0.176) - CubicKeyframe(-8, duration: 0.176) - CubicKeyframe(5, duration: 0.176) - CubicKeyframe(0, duration: 0.176) + LinearKeyframe(0, duration: 1.35) + CubicKeyframe(-13, duration: 0.16) + CubicKeyframe(11, duration: 0.16) + CubicKeyframe(-7, duration: 0.16) + CubicKeyframe(4, duration: 0.16) + CubicKeyframe(0, duration: 0.16) } } } else { @@ -544,8 +985,6 @@ private struct BellWiggle: ViewModifier { } } -/// `.symbolEffect(.pulse)` is gated behind Reduce Motion — when the user has -/// that system setting on, the glyph renders statically. @available(iOS 17.0, *) private struct DrawerPulseEffect: ViewModifier { let active: Bool diff --git a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift index 6b4b6b828..bdb8af208 100644 --- a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift @@ -1360,6 +1360,10 @@ struct PRsTabView: View { @MainActor private func handleRequestedPrNavigation() async { guard let request = syncService.requestedPrNavigation else { return } + guard await syncService.ensureAccountMachineForNavigation( + request.accountMachineKey + ), + syncService.requestedPrNavigation?.id == request.id else { return } if let createLaneId = request.createLaneId?.trimmingCharacters(in: .whitespacesAndNewlines), !createLaneId.isEmpty { await reload(refreshRemote: false) diff --git a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift index c5952d594..f0185274c 100644 --- a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift +++ b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift @@ -295,6 +295,7 @@ private final class SettingsConnectionPresentationModel: ObservableObject { private weak var boundService: SyncService? private var cancellable: AnyCancellable? private var pushCancellable: AnyCancellable? + private var accountCancellable: AnyCancellable? func bind(to syncService: SyncService) { guard boundService !== syncService else { @@ -319,6 +320,11 @@ private final class SettingsConnectionPresentationModel: ObservableObject { .sink { [weak self] _ in Task { @MainActor in self?.refreshPushSnapshot() } } + accountCancellable = AccountService.shared.objectWillChange + .throttle(for: .milliseconds(200), scheduler: RunLoop.main, latest: true) + .sink { [weak self] _ in + Task { @MainActor in self?.refreshPushSnapshot() } + } } private func refresh(from syncService: SyncService) { @@ -386,6 +392,7 @@ private final class SettingsConnectionPresentationModel: ObservableObject { snapshot.relayRefreshError = push.relayRefreshError snapshot.canRefreshRelayStatus = boundService?.canSendPushCommands == true snapshot.isPaired = boundService?.hasPairedHost == true + snapshot.accountDeliveryAvailable = AccountService.shared.isSignedIn snapshot.liveActivityTokenPresent = diagnostics.liveActivityPushToStartTokenSuffix != nil if let relay = push.relayStatus { snapshot.relayResolved = true diff --git a/apps/ios/ADE/Views/Settings/SettingsPushDeliverySection.swift b/apps/ios/ADE/Views/Settings/SettingsPushDeliverySection.swift index 0edadeecb..4a2f04f0a 100644 --- a/apps/ios/ADE/Views/Settings/SettingsPushDeliverySection.swift +++ b/apps/ios/ADE/Views/Settings/SettingsPushDeliverySection.swift @@ -15,9 +15,11 @@ struct SettingsPushDeliverySnapshot: Equatable { var lastError: String? var relayRefreshError: String? var canRefreshRelayStatus = false - /// Whether this device is paired to a machine. Notifications can't be - /// enabled until it is, so the enable affordance stays disabled while false. + /// Whether this device has a current machine command path. var isPaired = false + /// Signed-in accounts register directly with the account Attention relay, + /// so push delivery does not require a currently paired Mac. + var accountDeliveryAvailable = false var liveActivityTokenPresent = false @@ -35,6 +37,10 @@ struct SettingsPushDeliverySnapshot: Equatable { var needsPermissionPrompt: Bool { permissionStatus == .notDetermined || permissionStatus == .denied } + + var canEnableNotifications: Bool { + isPaired || accountDeliveryAvailable + } } struct SettingsPushDeliverySection: View { @@ -68,6 +74,12 @@ struct SettingsPushDeliverySection: View { subtitle: "Agent runs on the Lock Screen", isOn: liveActivitiesBinding ) + PushToggleRow( + symbol: "eye.slash", + title: "Hide details", + subtitle: "Use private Lock Screen previews", + isOn: hideDetailsBinding + ) PushToggleRow( symbol: "moon", title: "Quiet hours", @@ -104,12 +116,10 @@ struct SettingsPushDeliverySection: View { @ViewBuilder private var enableNotificationsControl: some View { let permissionStatus = pushService.permissionStatus - if !snapshot.isPaired { - // Nothing to register with until a Mac is paired: show the affordance - // disabled with a reason rather than a button that can't succeed (M8). + if !snapshot.canEnableNotifications { VStack(alignment: .leading, spacing: 6) { enableNotificationsButton(label: "Enable notifications", enabled: false, action: {}) - Text("Pair a Mac to enable notifications") + Text("Sign in or pair a Mac to enable notifications") .font(.caption) .foregroundStyle(ADEColor.textMuted) .padding(.horizontal, 4) @@ -350,6 +360,13 @@ struct SettingsPushDeliverySection: View { ) } + private var hideDetailsBinding: Binding { + Binding( + get: { pushService.prefs.hideDetails }, + set: { newValue in pushService.updatePrefs { $0.hideDetails = newValue } } + ) + } + private var quietHoursBinding: Binding { Binding( get: { pushService.prefs.quietHoursEnabled }, @@ -393,7 +410,10 @@ struct SettingsPushDeliverySection: View { case .waitingForMachine: return "Waiting for machine" case .failed: return "Failed" case .permissionDenied: return "Permission off" - case .unsupported: return "Pair a machine first" + case .unsupported: + return snapshot.accountDeliveryAvailable + ? "Not registered" + : "Sign in or pair a machine" case .notDetermined: return "Not enabled" } } @@ -405,7 +425,7 @@ struct SettingsPushDeliverySection: View { private var refreshButtonLabel: String { if pushService.isRefreshingStatus { return "Checking relay…" } - return snapshot.canRefreshRelayStatus ? "Refresh status" : "Connect to refresh" + return snapshot.canRefreshRelayStatus ? "Refresh status" : "Connect a Mac to refresh" } private var inlineStatusMessage: String? { diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift index a0f4a2059..5ee81ddf9 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift @@ -591,6 +591,10 @@ extension WorkRootScreen { @MainActor func handleRequestedWorkSessionNavigation() async { guard let request = syncService.requestedWorkSessionNavigation else { return } + guard await syncService.ensureAccountMachineForNavigation( + request.accountMachineKey + ), + syncService.requestedWorkSessionNavigation?.id == request.id else { return } // Scoped links are machine-wide identities, not requests against whichever // project happens to own this mounted Work view. Leave the request intact // and hand it to Hub's roster resolver, including on a cold app launch diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 6793ba7c5..f75f4fa65 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -564,6 +564,88 @@ final class ADETests: XCTestCase { XCTAssertTrue(request.hasProjectScope) } + @MainActor + func testDeepLinkRouterPreservesAccountAttentionSessionIdentityAndAnchors() throws { + let previousShared = SyncService.shared + defer { SyncService.shared = previousShared } + + let database = makeDatabase(baseURL: makeTemporaryDirectory()) + defer { database.close() } + let service = SyncService(database: database) + SyncService.shared = service + + DeepLinkRouter.shared.handle(try XCTUnwrap(URL(string: + "ade://session/session-account?item=pending-approval&event=event-abc&accountMachineKey=machine-relay-key" + ))) + + let request = try XCTUnwrap(service.requestedWorkSessionNavigation) + XCTAssertEqual(request.sessionId, "session-account") + XCTAssertEqual(request.itemId, "pending-approval") + XCTAssertEqual(request.eventId, "event-abc") + XCTAssertNil(request.event) + XCTAssertEqual(request.accountMachineKey, "machine-relay-key") + XCTAssertTrue(request.hasCanonicalScope) + } + + func testAccountAttentionMachineRoutingUsesCanonicalKeyAndConnectionIdentity() throws { + let machines = try JSONDecoder().decode( + [AccountMachine].self, + from: Data( + #""" + [{ + "machineKey": "machine-relay-key", + "deviceId": "device-studio", + "name": "Studio Mac", + "reachableEndpoints": [], + "online": true + }, { + "machineKey": "machine-laptop-key", + "deviceId": "device-laptop", + "name": "Laptop", + "reachableEndpoints": [], + "online": true + }] + """#.utf8 + ) + ) + + let target = try XCTUnwrap( + syncAccountMachineNavigationTarget( + rawMachineKey: "machine-relay-key", + machines: machines + ) + ) + XCTAssertEqual(target.deviceId, "device-studio") + XCTAssertNil( + syncAccountMachineNavigationTarget( + rawMachineKey: "Studio Mac", + machines: machines + ), + "Display names must never substitute for the canonical account machine key" + ) + XCTAssertTrue( + syncAccountMachineNavigationIsCurrent( + targetDeviceId: target.deviceId, + activeHostIdentity: "device-studio", + connectionState: .connected + ) + ) + XCTAssertFalse( + syncAccountMachineNavigationIsCurrent( + targetDeviceId: target.deviceId, + activeHostIdentity: "device-laptop", + connectionState: .connected + ) + ) + XCTAssertFalse( + syncAccountMachineNavigationIsCurrent( + targetDeviceId: target.deviceId, + activeHostIdentity: "device-studio", + connectionState: .disconnected + ) + ) + } + @MainActor func testDeepLinkRouterPreservesHttpsSessionProjectScope() throws { let previousShared = SyncService.shared @@ -585,7 +667,7 @@ final class ADETests: XCTestCase { } @MainActor - func testDeepLinkRouterRejectsMalformedSessionProjectScope() throws { + func testDeepLinkRouterRejectsMalformedCrossMachineScope() throws { let previousShared = SyncService.shared defer { SyncService.shared = previousShared } @@ -600,6 +682,21 @@ final class ADETests: XCTestCase { ))) XCTAssertNil(service.requestedWorkSessionNavigation) + + DeepLinkRouter.shared.handle(try XCTUnwrap(URL(string: + "ade://session/foreign-chat?accountMachineKey=not%20valid" + ))) + XCTAssertNil(service.requestedWorkSessionNavigation) + + DeepLinkRouter.shared.handle(try XCTUnwrap(URL(string: + "ade://session/foreign-chat?item=%2F" + ))) + XCTAssertNil(service.requestedWorkSessionNavigation) + + DeepLinkRouter.shared.handle(try XCTUnwrap(URL(string: + "ade://pr/arul28/ADE/42?accountMachineKey=not%20valid" + ))) + XCTAssertNil(service.requestedPrNavigation) } @MainActor @@ -639,13 +736,17 @@ final class ADETests: XCTestCase { let service = SyncService(database: database) SyncService.shared = service - DeepLinkRouter.shared.handle(try XCTUnwrap(URL(string: "ade://pr/arul28/ADE/729?tab=checks"))) + DeepLinkRouter.shared.handle(try XCTUnwrap(URL(string: + "ade://pr/arul28/ADE/729?tab=checks&event=checks-failed&accountMachineKey=machine-relay-key" + ))) XCTAssertEqual( service.requestedPrNavigation?.target, .githubNumber(729, repoOwner: "arul28", repoName: "ADE") ) XCTAssertEqual(service.requestedPrNavigation?.detailTab, .checks) + XCTAssertEqual(service.requestedPrNavigation?.eventId, "checks-failed") + XCTAssertEqual(service.requestedPrNavigation?.accountMachineKey, "machine-relay-key") } @MainActor @@ -661,12 +762,40 @@ final class ADETests: XCTestCase { DeepLinkRouter.shared.handleNotificationUserInfo([ "prId": "pr_123", "prNumber": "42", + "accountMachineKey": "machine-relay-key", + "eventId": "checks-failed", ]) XCTAssertEqual( service.requestedPrNavigation?.target, .detail(prId: "pr_123", prNumber: 42, laneId: nil) ) + XCTAssertEqual(service.requestedPrNavigation?.accountMachineKey, "machine-relay-key") + XCTAssertEqual(service.requestedPrNavigation?.eventId, "checks-failed") + } + + @MainActor + func testAccountSessionNotificationRoutesToExactMachineAndPendingItem() throws { + let previousShared = SyncService.shared + defer { SyncService.shared = previousShared } + + let database = makeDatabase(baseURL: makeTemporaryDirectory()) + defer { database.close() } + let service = SyncService(database: database) + SyncService.shared = service + + DeepLinkRouter.shared.handleNotificationUserInfo([ + "sessionId": "session-remote", + "accountMachineKey": "machine-studio", + "itemId": "approval-7", + "eventId": "question-7", + ]) + + let request = try XCTUnwrap(service.requestedWorkSessionNavigation) + XCTAssertEqual(request.sessionId, "session-remote") + XCTAssertEqual(request.accountMachineKey, "machine-studio") + XCTAssertEqual(request.itemId, "approval-7") + XCTAssertEqual(request.eventId, "question-7") } @MainActor @@ -10069,7 +10198,7 @@ final class ADETests: XCTestCase { "updatedAt": 1720000000, "activeCount": 2, "runs": [ - { "id": "c", "title": "Release checklist", "phase": "waiting_for_approval", "itemId": "item_release_push" }, + { "id": "c", "title": "Release checklist", "phase": "waiting_for_approval", "itemId": "item_release_push", "accountMachineKey": "machine-studio" }, { "id": "a", "title": "Refactor sync transport", "phase": "running" } ] } @@ -10079,7 +10208,13 @@ final class ADETests: XCTestCase { XCTAssertEqual(state.runs.count, 2) XCTAssertEqual(state.runs[0].resolvedPhase, .waitingForApproval) XCTAssertEqual(state.runs[0].itemId, "item_release_push") + XCTAssertEqual(state.runs[0].accountMachineKey, "machine-studio") + XCTAssertEqual( + state.runs[0].deepLinkURL?.absoluteString, + "ade://session/c?item=item_release_push&accountMachineKey=machine-studio" + ) XCTAssertNil(state.runs[1].itemId, "runs without an itemId key decode to nil") + XCTAssertNil(state.runs[1].accountMachineKey) } func testAgentRunsContentStateDecodesPullRequestRows() throws { @@ -10089,7 +10224,7 @@ final class ADETests: XCTestCase { "activeCount": 0, "runs": [], "prs": [ - { "id": "pr-42", "prNumber": 42, "title": "Ship mobile PR view", "phase": "merge_ready", "lane": "Mobile PR lane", "repoOwner": "arul28", "repoName": "ADE", "updatedAt": 1720000000 } + { "id": "pr-42", "prNumber": 42, "title": "Ship mobile PR view", "phase": "merge_ready", "lane": "Mobile PR lane", "repoOwner": "arul28", "repoName": "ADE", "accountMachineKey": "machine-studio", "updatedAt": 1720000000 } ] } """.utf8) @@ -10101,7 +10236,11 @@ final class ADETests: XCTestCase { XCTAssertEqual(state.prs[0].subtitle, "Mobile PR lane") XCTAssertEqual(state.prs[0].repoOwner, "arul28") XCTAssertEqual(state.prs[0].repoName, "ADE") - XCTAssertEqual(state.prs[0].deepLinkURL?.absoluteString, "ade://pr/arul28/ADE/42") + XCTAssertEqual(state.prs[0].accountMachineKey, "machine-studio") + XCTAssertEqual( + state.prs[0].deepLinkURL?.absoluteString, + "ade://pr/arul28/ADE/42?accountMachineKey=machine-studio" + ) } @MainActor diff --git a/apps/ios/ADETests/AttentionDrawerModelTests.swift b/apps/ios/ADETests/AttentionDrawerModelTests.swift index bf95e8167..ad7054f2d 100644 --- a/apps/ios/ADETests/AttentionDrawerModelTests.swift +++ b/apps/ios/ADETests/AttentionDrawerModelTests.swift @@ -189,6 +189,371 @@ final class AttentionDrawerModelTests: XCTestCase { XCTAssertEqual(model.items.map(\.sessionId), ["newer", "older"]) } + func testWorkspaceFallbackBuildsPriorityLiveAndRecentStacks() { + let model = AttentionDrawerModel(defaults: defaults) + let now = Date() + let snapshot = WorkspaceSnapshot( + generatedAt: now, + agents: [ + AgentSnapshot( + sessionId: "waiting", + provider: "claude", + laneName: "Primary", + title: "Approve release", + status: "awaiting_input", + awaitingInput: true, + lastActivityAt: now, + elapsedSeconds: 12, + preview: "Approve the push", + pendingInputItemId: "approval-1", + progress: nil, + phase: "validation", + toolCalls: 2 + ), + AgentSnapshot( + sessionId: "working", + provider: "codex", + laneName: "feature/attention", + title: "Polish mobile UI", + status: "running", + awaitingInput: false, + lastActivityAt: now.addingTimeInterval(-30), + elapsedSeconds: 300, + preview: "Rendering the priority stack", + progress: 0.7, + phase: "development", + toolCalls: 8 + ), + AgentSnapshot( + sessionId: "done", + provider: "codex", + laneName: "feature/attention", + title: "Model contract", + status: "completed", + awaitingInput: false, + lastActivityAt: now.addingTimeInterval(-120), + elapsedSeconds: 180, + preview: "Completed", + progress: 1, + phase: "validation", + toolCalls: 4 + ), + ], + prs: [], + connection: "connected", + machineId: "studio", + machineName: "Studio Mac", + projectId: "ade", + projectName: "ADE" + ) + + model.rebuild(from: snapshot) + + XCTAssertEqual(model.items.map(\.sessionId), ["waiting"]) + XCTAssertEqual(model.liveItems.map(\.sessionId), ["working"]) + XCTAssertEqual(model.recentItems.map(\.sessionId), ["done"]) + XCTAssertEqual(model.projectLenses.map(\.name), ["ADE"]) + XCTAssertEqual(model.visibleMachineCount, 1) + XCTAssertEqual(model.liveItems.first?.scopeLabel, "Studio Mac · ADE") + } + + func testAccountSnapshotSupportsProjectLensAndExactDestinations() { + let model = AttentionDrawerModel(defaults: defaults) + let now = Date() + let studio = AccountAttentionMachine( + machineKey: "studio", + accountMachineKey: "account-studio", + name: "Studio Mac", + online: true, + lastSeenAt: now + ) + let laptop = AccountAttentionMachine( + machineKey: "laptop", + name: "MacBook", + online: false, + lastSeenAt: now.addingTimeInterval(-120) + ) + + let snapshot = AccountAttentionSnapshot( + revision: 7, + generatedAt: now, + items: [ + AccountAttentionItem( + id: "approval", + revision: 2, + fingerprint: "approval:2", + kind: .agent, + eventKind: .agentNeedsYou, + phase: .needsYou, + machine: studio, + project: .init(projectId: "ade", name: "ADE"), + laneName: "Primary", + provider: "claude", + title: "Release ADE", + preview: "Approve git push", + privacyPreview: "Approval required", + destination: .session(sessionId: "session-a", itemId: "item-a", eventId: "event-a"), + occurredAt: now, + updatedAt: now + ), + AccountAttentionItem( + id: "live", + revision: 1, + fingerprint: "live:1", + kind: .agent, + eventKind: .agentCompleted, + phase: .running, + machine: laptop, + project: .init(projectId: "versic", name: "Versic"), + provider: "codex", + title: "Fix Windows sync", + preview: "Running tests", + privacyPreview: "Agent working", + destination: .session(sessionId: "session-b", itemId: nil, eventId: nil), + occurredAt: now, + updatedAt: now + ), + ] + ) + + model.rebuild(from: snapshot) + + XCTAssertEqual(model.projectLenses.map(\.name).sorted(), ["ADE", "Versic"]) + XCTAssertEqual( + model.items.first?.deepLink, + URL( + string: "ade://session/session-a?item=item-a&event=event-a&accountMachineKey=account-studio" + ) + ) + XCTAssertEqual(model.items.first?.inlineActionsAllowed, false) + XCTAssertEqual(model.visibleMachineCount, 2) + XCTAssertEqual( + AccountAttentionDestination.pullRequest( + prId: "pr-42", + repoOwner: "openai", + repoName: "ade", + number: 42, + tab: "checks", + eventId: "event-pr" + ).deepLinkURL(accountMachineKey: studio.accountMachineKey), + URL( + string: "ade://pr/openai/ade/42?tab=checks&event=event-pr&accountMachineKey=account-studio" + ) + ) + + model.selectProject("versic") + XCTAssertTrue(model.visibleItems(in: .needsYou).isEmpty) + XCTAssertEqual(model.visibleItems(in: .live).map(\.id), ["live"]) + XCTAssertEqual(model.visibleMachineCount, 1) + } + + func testAccountSnapshotDeltaHonorsItemAndTombstoneRevisions() { + let now = Date() + let current = AccountAttentionSnapshot( + revision: 8, + generatedAt: now, + items: [ + makeAccountItem(id: "keep", revision: 5, title: "Newest value", now: now), + makeAccountItem(id: "remove", revision: 2, title: "Remove me", now: now), + ] + ) + let delta = AccountAttentionSnapshot( + revision: 9, + generatedAt: now.addingTimeInterval(1), + items: [ + makeAccountItem(id: "keep", revision: 4, title: "Stale value", now: now), + makeAccountItem(id: "add", revision: 1, title: "Added", now: now), + ], + tombstones: [ + AccountAttentionTombstone( + id: "keep", + revision: 4, + deletedAt: now + ), + AccountAttentionTombstone( + id: "remove", + revision: 3, + deletedAt: now + ), + ] + ) + + let merged = current.merging(delta) + + XCTAssertEqual(merged.revision, 9) + XCTAssertEqual(Set(merged.items.map(\.id)), ["keep", "add"]) + XCTAssertEqual( + merged.items.first(where: { $0.id == "keep" })?.title, + "Newest value" + ) + } + + func testOutOfOrderSnapshotCommitCannotRegressRevisionOrDropNewerItems() { + let now = Date() + let base = AccountAttentionSnapshot( + streamId: "account-a", + revision: 10, + generatedAt: now, + items: [ + makeAccountItem(id: "existing", revision: 10, title: "Existing", now: now), + ] + ) + let revisionTwelve = AccountAttentionSnapshot( + streamId: "account-a", + revision: 12, + generatedAt: now.addingTimeInterval(2), + items: [ + makeAccountItem(id: "newer", revision: 12, title: "Newer", now: now), + ] + ) + let revisionEleven = AccountAttentionSnapshot( + streamId: "account-a", + revision: 11, + generatedAt: now.addingTimeInterval(1), + items: [ + makeAccountItem(id: "stale", revision: 11, title: "Stale", now: now), + ] + ) + + let committedTwelve = accountAttentionSnapshotForCommit( + current: base, + incoming: revisionTwelve + ) + let afterLateEleven = accountAttentionSnapshotForCommit( + current: committedTwelve, + incoming: revisionEleven + ) + + XCTAssertEqual(afterLateEleven.revision, 12) + XCTAssertEqual( + Set(afterLateEleven.items.map(\.id)), + ["existing", "newer"] + ) + } + + func testSnapshotStreamChangeResetsPriorAccountItems() { + let now = Date() + let priorAccount = AccountAttentionSnapshot( + streamId: "account-a", + revision: 42, + generatedAt: now, + items: [ + makeAccountItem(id: "private-a", revision: 42, title: "Private A", now: now), + ] + ) + let newAccount = AccountAttentionSnapshot( + streamId: "account-b", + revision: 1, + generatedAt: now.addingTimeInterval(1), + items: [ + makeAccountItem(id: "private-b", revision: 1, title: "Private B", now: now), + ] + ) + + let committed = accountAttentionSnapshotForCommit( + current: priorAccount, + incoming: newAccount + ) + + XCTAssertEqual(committed.streamId, "account-b") + XCTAssertEqual(committed.revision, 1) + XCTAssertEqual(committed.items.map(\.id), ["private-b"]) + } + + func testOpenPullRequestIsRecentAndExpiredItemsAreRemoved() { + let model = AttentionDrawerModel(defaults: defaults) + let now = Date() + let scope = AccountAttentionMachine( + machineKey: "studio", + name: "Studio Mac", + online: true, + lastSeenAt: now + ) + let openPullRequest = AccountAttentionItem( + id: "pr-open", + revision: 1, + fingerprint: "pr-open:1", + kind: .pullRequest, + eventKind: .prOpened, + phase: .open, + machine: scope, + project: .init(projectId: "ade", name: "ADE"), + title: "Open pull request", + preview: "Waiting for activity", + privacyPreview: "Pull request open", + destination: .pullRequest( + prId: "pr-open", + repoOwner: "ade", + repoName: "ade", + number: 42, + tab: "overview", + eventId: nil + ), + occurredAt: now, + updatedAt: now + ) + let expired = AccountAttentionItem( + id: "expired", + revision: 1, + fingerprint: "expired:1", + kind: .agent, + eventKind: .agentNeedsYou, + phase: .needsYou, + machine: scope, + project: .init(projectId: "ade", name: "ADE"), + title: "Old approval", + preview: "No longer actionable", + privacyPreview: "Approval required", + destination: .session(sessionId: "old", itemId: "item-old", eventId: nil), + occurredAt: now.addingTimeInterval(-120), + updatedAt: now.addingTimeInterval(-120), + expiresAt: now.addingTimeInterval(-1) + ) + + model.rebuild(from: .init( + revision: 1, + generatedAt: now.addingTimeInterval(-60), + items: [openPullRequest, expired] + )) + + XCTAssertFalse(openPullRequest.isLive) + XCTAssertTrue(model.items.isEmpty) + XCTAssertTrue(model.liveItems.isEmpty) + XCTAssertEqual(model.recentItems.map(\.id), ["pr-open"]) + XCTAssertEqual(model.recentItems.first?.kind, .open) + } + + func testMarkingOneItemSeenDoesNotClearOtherUnreadItems() { + let model = AttentionDrawerModel(defaults: defaults) + let now = Date() + let agents = ["one", "two"].map { id in + AgentSnapshot( + sessionId: id, + provider: "codex", + title: id, + status: "awaiting_input", + awaitingInput: true, + lastActivityAt: now, + elapsedSeconds: 0, + preview: nil, + progress: nil, + phase: nil, + toolCalls: 0 + ) + } + model.rebuild(from: .init( + generatedAt: now, + agents: agents, + prs: [], + connection: "connected" + )) + + model.markSeen("awaiting:one") + + XCTAssertEqual(model.unreadCount, 1) + XCTAssertEqual(model.badgeLabel, "1") + } + // MARK: - markAllSeen func testMarkAllSeenZeroesUnreadCount() { @@ -460,4 +825,42 @@ final class AttentionDrawerModelTests: XCTestCase { XCTAssertEqual(ADESharedContainer.inlineSummary(for: snapshot), "ADE · idle") } + + func testAccountAttentionPhaseLabelsUseUnifiedVocabulary() { + XCTAssertEqual(AccountAttentionPhase.running.displayLabel, "Running") + XCTAssertEqual(AccountAttentionPhase.needsYou.displayLabel, "Needs you") + XCTAssertEqual(AccountAttentionPhase.checksFailing.displayLabel, "Checks failing") + XCTAssertEqual(AccountAttentionPhase.reviewRequested.displayLabel, "Review requested") + XCTAssertEqual(AccountAttentionPhase.mergeReady.displayLabel, "Ready to merge") + XCTAssertEqual(AccountAttentionPhase.completed.displayLabel, "Completed") + } + + private func makeAccountItem( + id: String, + revision: Int, + title: String, + now: Date + ) -> AccountAttentionItem { + AccountAttentionItem( + id: id, + revision: revision, + fingerprint: "\(id):\(revision)", + kind: .agent, + eventKind: .agentRunning, + phase: .running, + machine: .init( + machineKey: "studio", + name: "Studio Mac", + online: true, + lastSeenAt: now + ), + project: .init(projectId: "ade", name: "ADE"), + title: title, + preview: "Working", + privacyPreview: "Agent working", + destination: .session(sessionId: id, itemId: nil, eventId: nil), + occurredAt: now, + updatedAt: now + ) + } } diff --git a/apps/ios/ADETests/PairingAndDpopTests.swift b/apps/ios/ADETests/PairingAndDpopTests.swift index 492894562..c4b719745 100644 --- a/apps/ios/ADETests/PairingAndDpopTests.swift +++ b/apps/ios/ADETests/PairingAndDpopTests.swift @@ -126,6 +126,590 @@ final class PairingAndDpopTests: XCTestCase { XCTAssertEqual(refreshCount, 1) } + func testAccountAttentionRelayFetchesDeltaWithClerkBearer() async throws { + AccountDirectoryURLProtocolStub.install { request in + XCTAssertEqual(request.url?.path, "/attention/account/snapshot") + XCTAssertEqual(request.url?.query, "since=41") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer clerk-token") + let response = try XCTUnwrap(HTTPURLResponse( + url: request.url ?? URL(string: "https://relay.example")!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )) + let body = #""" + { + "ok": true, + "contractVersion": 1, + "revision": 42, + "generatedAt": "2026-07-28T15:04:05.123Z", + "items": [{ + "contractVersion": 1, + "id": "run-1", + "revision": 3, + "fingerprint": "run-1:3", + "kind": "agent", + "eventKind": "agent_running", + "phase": "running", + "machine": { + "machineKey": "machine-1", + "name": "Studio Mac", + "online": true, + "lastSeenAt": "2026-07-28T15:04:04.999Z" + }, + "project": {"projectId": "ade", "name": "ADE"}, + "laneId": null, + "laneName": "Primary", + "provider": "codex", + "model": "gpt-5", + "title": "Polish mobile Attention", + "preview": "Type checking widgets", + "privacyPreview": "Agent working", + "detail": null, + "recentActivity": [], + "planProgress": null, + "destination": { + "kind": "session", + "sessionId": "session-1", + "itemId": null, + "eventId": "event-2" + }, + "actions": [{ + "id": "open", + "kind": "open", + "label": "Open", + "payload": {"offset": 12, "exact": true} + }], + "occurredAt": "2026-07-28T15:04:00.000Z", + "updatedAt": "2026-07-28T15:04:05.123Z", + "seenAt": null, + "dismissedAt": null, + "expiresAt": null + }], + "tombstones": [] + } + """# + return (response, Data(body.utf8)) + } + defer { AccountDirectoryURLProtocolStub.reset() } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AccountDirectoryURLProtocolStub.self] + let client = AccountAttentionRelayClient( + session: URLSession(configuration: configuration) + ) + + let snapshot = try await client.fetchSnapshot( + baseURL: try XCTUnwrap(URL(string: "https://relay.example")), + token: "clerk-token", + since: 41 + ) + + XCTAssertEqual(snapshot.revision, 42) + XCTAssertEqual(snapshot.items.first?.eventKind, .agentRunning) + XCTAssertEqual(snapshot.items.first?.machine.name, "Studio Mac") + XCTAssertEqual( + snapshot.items.first?.destination.deepLinkURL, + URL(string: "ade://session/session-1?event=event-2") + ) + XCTAssertNil(snapshot.streamId) + } + + func testAccountAttentionRelayPropagatesSnapshotStreamIdentity() async throws { + AccountDirectoryURLProtocolStub.install { request in + XCTAssertEqual(request.url?.path, "/attention/account/snapshot") + XCTAssertEqual(request.url?.query, "since=12&streamId=account-stream-a") + let response = try XCTUnwrap(HTTPURLResponse( + url: request.url ?? URL(string: "https://relay.example")!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )) + return ( + response, + Data( + #""" + { + "contractVersion": 1, + "streamId": "account-stream-a", + "revision": 13, + "generatedAt": "2026-07-28T15:04:05.123Z", + "items": [], + "tombstones": [] + } + """#.utf8 + ) + ) + } + defer { AccountDirectoryURLProtocolStub.reset() } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AccountDirectoryURLProtocolStub.self] + let client = AccountAttentionRelayClient( + session: URLSession(configuration: configuration) + ) + let snapshot = try await client.fetchSnapshot( + baseURL: try XCTUnwrap(URL(string: "https://relay.example")), + token: "clerk-token", + since: 12, + streamId: "account-stream-a" + ) + + XCTAssertEqual(snapshot.streamId, "account-stream-a") + XCTAssertEqual(snapshot.revision, 13) + } + + func testAccountAttentionAcknowledgmentUsesExactItemIds() async throws { + AccountDirectoryURLProtocolStub.install { request in + XCTAssertEqual(request.url?.path, "/attention/account/ack") + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer clerk-token") + let body = try XCTUnwrap(request.httpBody) + let payload = try XCTUnwrap( + try JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + XCTAssertEqual(payload["itemIds"] as? [String], ["item-a", "item-b"]) + XCTAssertNotNil(payload["seenAt"] as? String) + XCTAssertNotNil(payload["dismissedAt"] as? String) + let response = try XCTUnwrap(HTTPURLResponse( + url: request.url ?? URL(string: "https://relay.example")!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )) + return (response, Data(#"{"ok":true,"revision":9}"#.utf8)) + } + defer { AccountDirectoryURLProtocolStub.reset() } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AccountDirectoryURLProtocolStub.self] + let client = AccountAttentionRelayClient( + session: URLSession(configuration: configuration) + ) + + try await client.acknowledge( + baseURL: try XCTUnwrap(URL(string: "https://relay.example")), + token: "clerk-token", + itemIds: ["item-a", "item-b"], + dismiss: true + ) + } + + func testAccountAttentionActivityTokenUsesAccountScopedRoute() async throws { + AccountDirectoryURLProtocolStub.install { request in + XCTAssertEqual( + request.url?.path, + "/attention/account/devices/ios-device/activities/agent-runs" + ) + XCTAssertEqual(request.httpMethod, "PUT") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer clerk-token") + let body = try XCTUnwrap(request.httpBody) + let payload = try XCTUnwrap( + try JSONSerialization.jsonObject(with: body) as? [String: String] + ) + XCTAssertEqual(payload, ["token": "activity-token"]) + let response = try XCTUnwrap(HTTPURLResponse( + url: request.url ?? URL(string: "https://relay.example")!, + statusCode: 204, + httpVersion: nil, + headerFields: nil + )) + return (response, Data()) + } + defer { AccountDirectoryURLProtocolStub.reset() } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AccountDirectoryURLProtocolStub.self] + let client = AccountAttentionRelayClient( + session: URLSession(configuration: configuration) + ) + + try await client.reportActivityToken( + baseURL: try XCTUnwrap(URL(string: "https://relay.example")), + token: "clerk-token", + deviceId: "ios-device", + activityId: "agent-runs", + activityToken: "activity-token" + ) + } + + func testAccountAttentionEmptyActivityTokenDeletesAccountTarget() async throws { + AccountDirectoryURLProtocolStub.install { request in + XCTAssertEqual( + request.url?.path, + "/attention/account/devices/ios-device/activities/agent-runs" + ) + XCTAssertEqual(request.httpMethod, "DELETE") + XCTAssertNil(request.httpBody) + let response = try XCTUnwrap(HTTPURLResponse( + url: request.url ?? URL(string: "https://relay.example")!, + statusCode: 204, + httpVersion: nil, + headerFields: nil + )) + return (response, Data()) + } + defer { AccountDirectoryURLProtocolStub.reset() } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AccountDirectoryURLProtocolStub.self] + let client = AccountAttentionRelayClient( + session: URLSession(configuration: configuration) + ) + + try await client.reportActivityToken( + baseURL: try XCTUnwrap(URL(string: "https://relay.example")), + token: "clerk-token", + deviceId: "ios-device", + activityId: "agent-runs", + activityToken: " " + ) + } + + func testAccountAttentionUnregisterDeletesOnlyCurrentDevice() async throws { + AccountDirectoryURLProtocolStub.install { request in + XCTAssertEqual( + request.url?.path, + "/attention/account/devices/ios-device" + ) + XCTAssertEqual(request.httpMethod, "DELETE") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer clerk-token") + XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json") + let body = try XCTUnwrap(request.httpBody) + let payload = try XCTUnwrap( + JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + XCTAssertEqual(payload["ownershipEpoch"] as? Int, 17) + let response = try XCTUnwrap(HTTPURLResponse( + url: request.url ?? URL(string: "https://relay.example")!, + statusCode: 204, + httpVersion: nil, + headerFields: nil + )) + return (response, Data()) + } + defer { AccountDirectoryURLProtocolStub.reset() } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AccountDirectoryURLProtocolStub.self] + let client = AccountAttentionRelayClient( + session: URLSession(configuration: configuration) + ) + + try await client.unregisterDevice( + baseURL: try XCTUnwrap(URL(string: "https://relay.example")), + token: "clerk-token", + deviceId: "ios-device", + ownershipEpoch: 17 + ) + } + + func testAccountAttentionRegistrationIncludesOwnershipEpoch() async throws { + AccountDirectoryURLProtocolStub.install { request in + XCTAssertEqual( + request.url?.path, + "/attention/account/devices/ios-device" + ) + XCTAssertEqual(request.httpMethod, "PUT") + let body = try XCTUnwrap(request.httpBody) + let payload = try XCTUnwrap( + JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + XCTAssertEqual(payload["ownershipEpoch"] as? Int, 23) + XCTAssertEqual(payload["apnsToken"] as? String, "apns-token") + let response = try XCTUnwrap(HTTPURLResponse( + url: request.url ?? URL(string: "https://relay.example")!, + statusCode: 204, + httpVersion: nil, + headerFields: nil + )) + return (response, Data()) + } + defer { AccountDirectoryURLProtocolStub.reset() } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AccountDirectoryURLProtocolStub.self] + let client = AccountAttentionRelayClient( + session: URLSession(configuration: configuration) + ) + + try await client.registerDevice( + baseURL: try XCTUnwrap(URL(string: "https://relay.example")), + token: "clerk-token", + deviceId: "ios-device", + ownershipEpoch: 23, + apnsToken: "apns-token", + pushToStartToken: nil, + bundleId: "com.ade.ios", + apsEnvironment: "development", + deviceName: "iPhone", + preferences: [:] + ) + } + + func testAccountAttentionDeviceMutationsRejectInvalidOwnershipEpochs() async throws { + let requests = AccountDirectoryRequestRecorder() + AccountDirectoryURLProtocolStub.install { request in + _ = requests.append(request.httpMethod ?? "") + let response = try XCTUnwrap(HTTPURLResponse( + url: request.url ?? URL(string: "https://relay.example")!, + statusCode: 500, + httpVersion: nil, + headerFields: nil + )) + return (response, Data()) + } + defer { AccountDirectoryURLProtocolStub.reset() } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AccountDirectoryURLProtocolStub.self] + let client = AccountAttentionRelayClient( + session: URLSession(configuration: configuration) + ) + let baseURL = try XCTUnwrap(URL(string: "https://relay.example")) + let invalidEpochs = [ + 0, + -1, + AccountDeviceOwnershipState.maximumSafeEpoch + 1, + ] + + for ownershipEpoch in invalidEpochs { + do { + try await client.registerDevice( + baseURL: baseURL, + token: "clerk-token", + deviceId: "ios-device", + ownershipEpoch: ownershipEpoch, + apnsToken: "apns-token", + pushToStartToken: nil, + bundleId: "com.ade.ios", + apsEnvironment: "development", + deviceName: "iPhone", + preferences: [:] + ) + XCTFail("Expected invalid registration ownership epoch \(ownershipEpoch)") + } catch let error as AccountAttentionRelayClient.RelayError { + XCTAssertEqual(error, .transport) + } + + do { + try await client.unregisterDevice( + baseURL: baseURL, + token: "clerk-token", + deviceId: "ios-device", + ownershipEpoch: ownershipEpoch + ) + XCTFail("Expected invalid deletion ownership epoch \(ownershipEpoch)") + } catch let error as AccountAttentionRelayClient.RelayError { + XCTAssertEqual(error, .transport) + } + } + + XCTAssertEqual(requests.snapshot(), [], "invalid epochs must fail before network I/O") + } + + func testAccountAttentionConflictIsNonRetryableStaleOwnership() async throws { + AccountDirectoryURLProtocolStub.install { request in + let response = try XCTUnwrap(HTTPURLResponse( + url: request.url ?? URL(string: "https://relay.example")!, + statusCode: 409, + httpVersion: nil, + headerFields: nil + )) + return (response, Data()) + } + defer { AccountDirectoryURLProtocolStub.reset() } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AccountDirectoryURLProtocolStub.self] + let client = AccountAttentionRelayClient( + session: URLSession(configuration: configuration) + ) + + do { + try await client.unregisterDevice( + baseURL: try XCTUnwrap(URL(string: "https://relay.example")), + token: "clerk-token", + deviceId: "ios-device", + ownershipEpoch: 4 + ) + XCTFail("Expected stale ownership") + } catch let error as AccountAttentionRelayClient.RelayError { + XCTAssertEqual(error, .staleOwnership) + } + } + + func testPendingAccountDeviceRevocationPersistsUntilMatchingSuccess() throws { + let suiteName = "PairingAndDpopTests.revocation.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let key = "pending-revocation" + let store = AccountDeviceRevocationStore(defaults: defaults, key: key) + let createdAt = Date(timeIntervalSince1970: 1_700_000_000) + + let pending = try XCTUnwrap( + store.mark( + ownerId: "user-a", + deviceId: "ios-device", + ownershipEpoch: 2, + now: createdAt + ) + ) + XCTAssertEqual( + AccountDeviceRevocationStore(defaults: defaults, key: key).pending, + pending + ) + XCTAssertEqual( + store.mark(ownerId: "user-a", deviceId: "ios-device", ownershipEpoch: 1), + pending, + "An older boundary must not replace a newer pending revocation" + ) + + let different = PendingAccountDeviceRevocation( + ownerId: "user-b", + deviceId: "ios-device", + ownershipEpoch: 3, + createdAt: createdAt + ) + store.clear(ifMatching: different) + XCTAssertEqual(store.pending, pending) + store.clear(ifMatching: pending) + XCTAssertNil(store.pending) + } + + func testAccountDeviceOwnershipEpochPersistsAcrossSignOutAndAccountSwitch() throws { + let suiteName = "PairingAndDpopTests.ownership.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let key = "ownership" + let store = AccountDeviceOwnershipStore(defaults: defaults, key: key) + + let accountA = store.transition(to: "user-a") + XCTAssertEqual(accountA, AccountDeviceOwnershipState(ownershipEpoch: 2, ownerId: "user-a")) + XCTAssertEqual(store.transition(to: "user-a"), accountA) + + let signedOut = store.transition(to: nil) + XCTAssertEqual(signedOut.ownershipEpoch, 3) + XCTAssertNil(signedOut.ownerId) + + let accountB = store.transition(to: "user-b") + XCTAssertEqual(accountB.ownershipEpoch, 4) + XCTAssertEqual(accountB.ownerId, "user-b") + XCTAssertEqual( + AccountDeviceOwnershipStore(defaults: defaults, key: key).state, + accountB + ) + XCTAssertLessThanOrEqual( + accountB.ownershipEpoch, + AccountDeviceOwnershipState.maximumSafeEpoch + ) + XCTAssertFalse( + accountDeviceMutationMatchesCurrentOwnership( + ownerId: accountA.ownerId ?? "", + ownershipEpoch: accountA.ownershipEpoch, + state: accountB + ), + "A delayed account-A request cannot become effective after sign-out and account-B login" + ) + XCTAssertTrue( + accountDeviceMutationMatchesCurrentOwnership( + ownerId: "user-b", + ownershipEpoch: accountB.ownershipEpoch, + state: accountB + ) + ) + } + + @MainActor + func testAccountRegistrationSerializesDelayedAThenRunsLatestB() async { + let queue = LatestAccountRegistrationQueue() + var starts: [String] = [] + var activeCount = 0 + var maximumActiveCount = 0 + var releaseAccountA: CheckedContinuation? + + let perform: @MainActor (String) async -> Bool = { owner in + starts.append(owner) + activeCount += 1 + maximumActiveCount = max(maximumActiveCount, activeCount) + if owner == "account-a" { + await withCheckedContinuation { continuation in + releaseAccountA = continuation + } + } + activeCount -= 1 + return true + } + + let accountA = Task { @MainActor in + await queue.submit("account-a", perform: perform) + } + while starts.isEmpty { + await Task.yield() + } + let staleAccountBRefresh = Task { @MainActor in + await queue.submit("account-b-stale-refresh", perform: perform) + } + await Task.yield() + let accountB = Task { @MainActor in + await queue.submit("account-b", perform: perform) + } + for _ in 0..<8 { + await Task.yield() + } + + XCTAssertEqual(starts, ["account-a"]) + XCTAssertEqual(maximumActiveCount, 1) + releaseAccountA?.resume() + _ = await (accountA.value, staleAccountBRefresh.value, accountB.value) + + XCTAssertEqual(starts, ["account-a", "account-b"]) + XCTAssertEqual(maximumActiveCount, 1) + } + + func testAccountActivityTokenRetryPreservesAccountOnlyIdentityAndEmptyDelete() throws { + let suiteName = "PairingAndDpopTests.activity-token.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let key = "pending-account-token" + let store = AccountActivityTokenRegistrationStore(defaults: defaults, key: key) + + let pending = try XCTUnwrap( + store.persist( + deviceId: "ios-device", + activityId: "agent-runs", + token: " " + ) + ) + + XCTAssertTrue(pending.accountWide) + XCTAssertEqual(pending.token, "") + XCTAssertEqual( + AccountActivityTokenRegistrationStore(defaults: defaults, key: key).pending, + pending + ) + XCTAssertEqual(liveActivityTokenRoute(accountWide: true), .accountOnly) + XCTAssertEqual(liveActivityTokenRoute(accountWide: false), .pairedMachine) + } + + func testAgentRunsActivityRecognizesAccountWideAndLegacyMarkers() { + XCTAssertTrue( + ADEAgentRunsAttributes( + machineName: "Studio Mac", + accountWide: true + ).isAccountWide + ) + XCTAssertTrue( + ADEAgentRunsAttributes(machineName: "All machines").isAccountWide + ) + XCTAssertFalse( + ADEAgentRunsAttributes( + machineName: "Studio Mac", + accountWide: false + ).isAccountWide + ) + } + // MARK: - Sealed account adoption func testAdoptChannelMatchesTypeScriptChaChaPolyVector() throws { @@ -455,4 +1039,33 @@ final class PairingAndDpopTests: XCTestCase { XCTAssertFalse(verifies(wrongChallengeCanonical)) } } + + func testPushPreferencesDecodeLegacyPayloadAndPublishPrivacyChoice() throws { + let legacy = Data(""" + { + "enabled": true, + "liveActivitiesEnabled": false, + "mutedSessionIds": ["session-1"], + "quietHoursEnabled": true, + "quietHoursStart": "21:30", + "quietHoursEnd": "07:15", + "quietHoursTimezone": "America/New_York" + } + """.utf8) + + var preferences = try JSONDecoder().decode(PushPrefs.self, from: legacy) + XCTAssertFalse(preferences.hideDetails) + XCTAssertEqual(preferences.mutedSessionIds, ["session-1"]) + + preferences.hideDetails = true + XCTAssertEqual(preferences.commandPayload["hideDetails"] as? Bool, true) + + let roundTrip = try JSONDecoder().decode( + PushPrefs.self, + from: JSONEncoder().encode(preferences) + ) + XCTAssertTrue(roundTrip.hideDetails) + XCTAssertEqual(roundTrip.quietHoursStart, "21:30") + XCTAssertEqual(roundTrip.quietHoursEnd, "07:15") + } } diff --git a/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift b/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift index b6fb22661..b28d30020 100644 --- a/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift +++ b/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift @@ -19,6 +19,7 @@ struct ADEAgentActivityWidget: Widget { ) .activityBackgroundTint(Color.black.opacity(0.28)) .activitySystemActionForegroundColor(.primary) + .privacySensitive() } dynamicIsland: { context in let presentation = AgentRunsPresentation( state: context.state, @@ -27,40 +28,77 @@ struct ADEAgentActivityWidget: Widget { ) return DynamicIsland { DynamicIslandExpandedRegion(.leading) { - Image(systemName: presentation.primarySymbol) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(presentation.tint) + HStack(spacing: 5) { + Image(systemName: "point.3.filled.connected.trianglepath.dotted") + .font(.system(size: 14, weight: .bold)) + Text(presentation.compactCountLabel) + .font(.system(size: 13, weight: .bold, design: .rounded)) + } + .foregroundStyle(presentation.tint) } DynamicIslandExpandedRegion(.trailing) { AgentRunsCountBadge(presentation: presentation) } DynamicIslandExpandedRegion(.center) { - VStack(alignment: .leading, spacing: 4) { - ForEach(presentation.runs.prefix(2)) { run in - AgentRunRow(run: run, compact: true) - } - if presentation.runs.isEmpty { - ForEach(presentation.prs.prefix(2)) { pr in - PullRequestActivityRow(pr: pr, compact: true) - } - } - if presentation.isStale { - AgentRunsStaleHint() + Group { + if let run = presentation.primary { + AgentActivityRunHero( + run: run, + compact: true, + allowsInlineActions: !presentation.accountWide, + hideDetails: presentation.hideDetails + ) + } else if let pr = presentation.primaryPr { + AgentActivityPullRequestHero( + pr: pr, + compact: true, + hideDetails: presentation.hideDetails + ) } } .frame(maxWidth: .infinity, alignment: .leading) + .privacySensitive() } DynamicIslandExpandedRegion(.bottom) { - if let machine = presentation.machineFooter { - Text(machine) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .leading, spacing: 5) { + ForEach(presentation.secondaryRuns) { run in + AgentRunRow( + run: run, + compact: true, + hideDetails: presentation.hideDetails + ) + } + ForEach(presentation.secondaryPrs) { pr in + PullRequestActivityRow( + pr: pr, + compact: true, + hideDetails: presentation.hideDetails + ) + } + HStack(spacing: 5) { + if presentation.overflowCount > 0 { + Text("+\(presentation.overflowCount) more") + .font(.system(size: 9.5, weight: .semibold)) + .foregroundStyle(presentation.tint) + } + if presentation.isStale { + AgentRunsStaleHint() + } + Spacer(minLength: 0) + if let machine = presentation.machineFooter { + Text(machine) + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } } + .padding(.horizontal, 4) + .privacySensitive() } } compactLeading: { - Image(systemName: presentation.primarySymbol) - .font(.system(size: 13, weight: .semibold)) + Image(systemName: presentation.waitingCount > 0 ? presentation.primarySymbol : "point.3.filled.connected.trianglepath.dotted") + .font(.system(size: 13, weight: .bold)) .foregroundStyle(presentation.tint) } compactTrailing: { if presentation.waitingCount > 0 { @@ -92,31 +130,65 @@ struct AgentRunsPresentation { let waitingCount: Int let primary: ADEAgentRunsAttributes.Run? let primaryPr: ADEAgentRunsAttributes.PullRequest? + let secondaryRuns: [ADEAgentRunsAttributes.Run] + let secondaryPrs: [ADEAgentRunsAttributes.PullRequest] + let overflowCount: Int let isStale: Bool let machineName: String + let accountWide: Bool + let hideDetails: Bool init(state: ADEAgentRunsAttributes.ContentState, attributes: ADEAgentRunsAttributes, isStale: Bool) { - // Attention-needing runs float to the top of the glance. + // Match the original T3 Code activity hierarchy: user-blocked work, + // failures, in-flight work, then outcomes. PR attention participates in + // the same focus decision instead of being hidden behind a running run. let sorted = state.runs.sorted { lhs, rhs in - let l = lhs.resolvedPhase.needsAttention ? 0 : 1 - let r = rhs.resolvedPhase.needsAttention ? 0 : 1 - return l < r + Self.priority(lhs.resolvedPhase) < Self.priority(rhs.resolvedPhase) } - self.runs = Array(sorted.prefix(3)) + self.runs = sorted let sortedPrs = Array(state.prs.sorted { lhs, rhs in - let l = lhs.resolvedPhase.needsAttention ? 0 : 1 - let r = rhs.resolvedPhase.needsAttention ? 0 : 1 + let l = Self.priority(lhs.resolvedPhase) + let r = Self.priority(rhs.resolvedPhase) if l != r { return l < r } return lhs.updatedAt > rhs.updatedAt - }.prefix(2)) + }) self.prs = sortedPrs self.activeCount = max(state.activeCount, state.runs.count) self.waitingCount = state.runs.filter { $0.resolvedPhase.needsAttention }.count + state.prs.filter { $0.resolvedPhase.needsAttention }.count - self.primary = sorted.first - self.primaryPr = sortedPrs.first + + let attentionRun = sorted.first(where: { $0.resolvedPhase.needsAttention }) + let attentionPr = sortedPrs.first(where: { $0.resolvedPhase.needsAttention }) + if let attentionRun { + self.primary = attentionRun + self.primaryPr = nil + } else if let attentionPr { + self.primary = nil + self.primaryPr = attentionPr + } else if let first = sorted.first { + self.primary = first + self.primaryPr = nil + } else { + self.primary = nil + self.primaryPr = sortedPrs.first + } + + let remainingRuns = sorted.filter { $0.id != self.primary?.id } + self.secondaryRuns = Array(remainingRuns.prefix(2)) + let remainingSlots = max(0, 2 - self.secondaryRuns.count) + self.secondaryPrs = Array( + sortedPrs + .filter { $0.id != self.primaryPr?.id } + .prefix(remainingSlots) + ) + let represented = (self.primary == nil && self.primaryPr == nil ? 0 : 1) + + self.secondaryRuns.count + + self.secondaryPrs.count + self.overflowCount = max(0, self.activeCount + state.prs.count - represented) self.isStale = isStale || state.runs.contains { $0.resolvedPhase == .stale } self.machineName = attributes.machineName + self.accountWide = attributes.isAccountWide + self.hideDetails = ADESharedContainer.hideAttentionDetails } /// Tint of the glance — attention amber wins, otherwise the primary run's @@ -133,12 +205,20 @@ struct AgentRunsPresentation { } var glanceCount: Int { - max(activeCount, prs.count) + activeCount + prs.count + } + + var compactCountLabel: String { + if waitingCount > 0 { return "\(waitingCount)" } + if activeCount == 0, primary?.resolvedPhase == .failed { return "!" } + if activeCount == 0, primaryPr?.resolvedPhase == .checksFailing { return "!" } + return "\(glanceCount)" } /// Footer only earns its space when there's more than one run or a machine /// name worth showing. var machineFooter: String? { + if hideDetails { return "ADE" } let trimmed = machineName.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } if activeCount > runs.count { @@ -150,33 +230,37 @@ struct AgentRunsPresentation { var destinationURL: URL { let workspace = URL(string: "ade://workspace") ?? URL(fileURLWithPath: "/") let attentionRun = runs.first(where: { $0.resolvedPhase.needsAttention }) - if let id = attentionRun?.id.trimmingCharacters(in: .whitespacesAndNewlines), - !id.isEmpty, - let url = sessionURL(for: id) { + if let url = attentionRun?.deepLinkURL { return url } if let prUrl = prs.first(where: { $0.resolvedPhase.needsAttention })?.deepLinkURL { return prUrl } - let target = primary - guard let id = target?.id.trimmingCharacters(in: .whitespacesAndNewlines), - !id.isEmpty else { + guard let target = primary else { if let prUrl = primaryPr?.deepLinkURL { return prUrl } return workspace } - return sessionURL(for: id) ?? workspace + return target.deepLinkURL ?? workspace } - private func sessionURL(for id: String) -> URL? { - var allowed = CharacterSet.alphanumerics - allowed.insert(charactersIn: "-._~") - guard let encoded = id.addingPercentEncoding(withAllowedCharacters: allowed), - let url = URL(string: "ade://session/\(encoded)") else { - return nil + private static func priority(_ phase: AgentRunPhase) -> Int { + switch phase { + case .waitingForApproval, .waitingForInput: return 0 + case .failed: return 1 + case .starting, .running: return 2 + case .completed, .stale: return 3 + } + } + + private static func priority(_ phase: PullRequestPhase) -> Int { + switch phase { + case .checksFailing, .changesRequested: return 1 + case .reviewRequested, .mergeReady: return 2 + case .opened, .reopened: return 3 + case .merged, .closed: return 4 } - return url } } @@ -186,36 +270,65 @@ private struct AgentRunsLockScreenView: View { let presentation: AgentRunsPresentation var body: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 9) { HStack(spacing: 6) { - Image(systemName: presentation.waitingCount > 0 ? "bell.badge.fill" : "circle.dotted") - .font(.system(size: 12, weight: .semibold)) + Image(systemName: "point.3.filled.connected.trianglepath.dotted") + .font(.system(size: 12, weight: .bold)) .foregroundStyle(presentation.tint) Text(headline) - .font(.system(size: 13, weight: .semibold)) + .font(.footnote.weight(.semibold)) .foregroundStyle(.primary) .lineLimit(1) .minimumScaleFactor(0.85) Spacer(minLength: 0) if let footer = presentation.machineFooter { Text(footer) - .font(.system(size: 10, weight: .medium)) + .font(.caption2.weight(.medium)) .foregroundStyle(.secondary) .lineLimit(1) } } - if presentation.runs.isEmpty && presentation.prs.isEmpty { + if presentation.primary == nil && presentation.primaryPr == nil { Text("No active runs") - .font(.system(size: 11)) + .font(.caption) .foregroundStyle(.secondary) } else { VStack(alignment: .leading, spacing: 6) { - ForEach(presentation.runs) { run in - AgentRunRow(run: run, compact: false) + if let run = presentation.primary { + AgentActivityRunHero( + run: run, + compact: false, + allowsInlineActions: !presentation.accountWide, + hideDetails: presentation.hideDetails + ) + } + if let pr = presentation.primaryPr { + AgentActivityPullRequestHero( + pr: pr, + compact: false, + hideDetails: presentation.hideDetails + ) + } + ForEach(presentation.secondaryRuns) { run in + AgentRunRow( + run: run, + compact: true, + hideDetails: presentation.hideDetails + ) } - ForEach(presentation.prs) { pr in - PullRequestActivityRow(pr: pr, compact: false) + ForEach(presentation.secondaryPrs) { pr in + PullRequestActivityRow( + pr: pr, + compact: true, + hideDetails: presentation.hideDetails + ) + } + if presentation.overflowCount > 0 { + Text("+\(presentation.overflowCount) more") + .font(.system(size: 9.5, weight: .semibold)) + .foregroundStyle(presentation.tint) + .padding(.leading, 22) } } } @@ -233,6 +346,10 @@ private struct AgentRunsLockScreenView: View { if presentation.waitingCount > 0 { return presentation.waitingCount == 1 ? "1 item needs you" : "\(presentation.waitingCount) items need you" } + if presentation.activeCount == 0, let phase = presentation.primary?.resolvedPhase { + if phase == .failed { return "Agent work failed" } + if phase == .completed { return "Agent work completed" } + } if presentation.runs.isEmpty && !presentation.prs.isEmpty { return presentation.prs.count == 1 ? "1 pull request updated" : "\(presentation.prs.count) pull requests updated" } @@ -241,11 +358,197 @@ private struct AgentRunsLockScreenView: View { } } +// MARK: - Focus cards + +private struct ProviderActivityMark: View { + let model: String? + let fallbackSymbol: String + let tint: Color + let compact: Bool + + var body: some View { + let provider = ADESharedTheme.providerSlug(forModel: model) + ZStack { + Circle() + .fill(tint.opacity(0.16)) + if let assetName = ADESharedTheme.providerAssetName(for: provider) { + Image(assetName) + .resizable() + .scaledToFit() + .padding(compact ? 2.5 : 3) + } else { + Image(systemName: fallbackSymbol) + .font(.system(size: compact ? 9 : 11, weight: .semibold)) + .foregroundStyle(tint) + .contentTransition(.symbolEffect(.replace)) + } + } + .frame(width: compact ? 16 : 20, height: compact ? 16 : 20) + .overlay(Circle().strokeBorder(tint.opacity(0.24), lineWidth: 0.5)) + .accessibilityHidden(true) + } +} + +private struct AgentActivityRunHero: View { + let run: ADEAgentRunsAttributes.Run + let compact: Bool + let allowsInlineActions: Bool + let hideDetails: Bool + + private var phase: AgentRunPhase { run.resolvedPhase } + private var showsApprovalActions: Bool { + allowsInlineActions + && !compact + && phase == .waitingForApproval + && !(run.itemId ?? "").isEmpty + } + + var body: some View { + VStack(alignment: .leading, spacing: showsApprovalActions ? 7 : 0) { + if let url = run.deepLinkURL { + Link(destination: url) { content } + .buttonStyle(.plain) + } else { + content + } + + if showsApprovalActions { + HStack(spacing: 8) { + Button(intent: ApproveSessionIntent(sessionId: run.id, itemId: run.itemId ?? "")) { + heroAction("Approve", symbol: "checkmark", tint: phase.tint) + } + .buttonStyle(.plain) + Button(intent: DenySessionIntent(sessionId: run.id, itemId: run.itemId ?? "")) { + heroAction("Deny", symbol: "xmark", tint: .secondary) + } + .buttonStyle(.plain) + Spacer(minLength: 0) + } + .padding(.leading, 27) + } + } + .padding(.horizontal, compact ? 5 : 8) + .padding(.vertical, compact ? 3 : 7) + .background( + RoundedRectangle(cornerRadius: compact ? 7 : 10, style: .continuous) + .fill(phase.tint.opacity(phase.needsAttention || phase == .failed ? 0.16 : 0.1)) + ) + .overlay( + RoundedRectangle(cornerRadius: compact ? 7 : 10, style: .continuous) + .stroke(phase.tint.opacity(0.22), lineWidth: 0.6) + ) + } + + private var content: some View { + HStack(spacing: 8) { + ProviderActivityMark( + model: run.model, + fallbackSymbol: phase.symbol, + tint: phase.tint, + compact: compact + ) + VStack(alignment: .leading, spacing: compact ? 0 : 2) { + Text(hideDetails ? "Agent activity" : run.title) + .font(compact ? .system(size: 11.5, weight: .semibold) : .footnote.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.8) + if !compact, let subtitle { + Text(subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer(minLength: 5) + Text(phase.label) + .font(compact ? .system(size: 9.5, weight: .bold) : .caption2.weight(.bold)) + .foregroundStyle(phase.tint) + .lineLimit(1) + } + .contentShape(Rectangle()) + } + + private var subtitle: String? { + guard !hideDetails else { return nil } + let detail = run.detail?.trimmingCharacters(in: .whitespacesAndNewlines) + if let detail, !detail.isEmpty { return detail } + return run.subtitle + } + + private func heroAction(_ title: String, symbol: String, tint: Color) -> some View { + Label(title, systemImage: symbol) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(tint) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(tint.opacity(0.13), in: Capsule()) + } +} + +private struct AgentActivityPullRequestHero: View { + let pr: ADEAgentRunsAttributes.PullRequest + let compact: Bool + let hideDetails: Bool + + private var phase: PullRequestPhase { pr.resolvedPhase } + + var body: some View { + Group { + if let url = pr.deepLinkURL { + Link(destination: url) { content } + .buttonStyle(.plain) + } else { + content + } + } + .padding(.horizontal, compact ? 5 : 8) + .padding(.vertical, compact ? 3 : 7) + .background( + RoundedRectangle(cornerRadius: compact ? 7 : 10, style: .continuous) + .fill(phase.tint.opacity(0.14)) + ) + .overlay( + RoundedRectangle(cornerRadius: compact ? 7 : 10, style: .continuous) + .stroke(phase.tint.opacity(0.22), lineWidth: 0.6) + ) + } + + private var content: some View { + HStack(spacing: 8) { + Image(systemName: phase.symbol) + .font(.system(size: compact ? 11 : 14, weight: .semibold)) + .foregroundStyle(phase.tint) + .frame(width: 18) + VStack(alignment: .leading, spacing: compact ? 0 : 2) { + Text(hideDetails ? "Pull request update" : "#\(pr.prNumber) \(pr.title)") + .font(compact ? .system(size: 11.5, weight: .semibold) : .footnote.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.8) + if !hideDetails, !compact, let subtitle = pr.subtitle { + Text(subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer(minLength: 5) + Text(phase.label) + .font(compact ? .system(size: 9.5, weight: .bold) : .caption2.weight(.bold)) + .foregroundStyle(phase.tint) + .lineLimit(1) + } + .contentShape(Rectangle()) + } +} + // MARK: - Shared row private struct AgentRunRow: View { let run: ADEAgentRunsAttributes.Run let compact: Bool + let hideDetails: Bool private var phase: AgentRunPhase { run.resolvedPhase } @@ -262,11 +565,11 @@ private struct AgentRunRow: View { Group { if showsApprovalActions { VStack(alignment: .leading, spacing: 7) { - rowContent + linkedRowContent approvalActions } } else { - rowContent + linkedRowContent } } .padding(.vertical, phase.needsAttention && !compact ? 3 : 0) @@ -278,22 +581,35 @@ private struct AgentRunRow: View { ) } + @ViewBuilder + private var linkedRowContent: some View { + if let url = run.deepLinkURL { + Link(destination: url) { rowContent } + .buttonStyle(.plain) + } else { + rowContent + } + } + private var rowContent: some View { HStack(spacing: 8) { - Image(systemName: phase.symbol) - .font(.system(size: compact ? 10 : 11, weight: .semibold)) - .foregroundStyle(phase.tint) - .frame(width: 14, alignment: .center) + ProviderActivityMark( + model: run.model, + fallbackSymbol: phase.symbol, + tint: phase.tint, + compact: true + ) + .frame(width: 14, alignment: .center) VStack(alignment: .leading, spacing: 1) { - Text(run.title) - .font(.system(size: compact ? 11 : 12.5, weight: .medium)) + Text(hideDetails ? "Agent activity" : run.title) + .font(compact ? .system(size: 11, weight: .medium) : .caption.weight(.medium)) .foregroundStyle(.primary) .lineLimit(1) .minimumScaleFactor(0.85) if let subtitle = subtitle { Text(subtitle) - .font(.system(size: compact ? 9 : 10, weight: .regular)) + .font(compact ? .system(size: 9, weight: .regular) : .caption2) .foregroundStyle(.secondary) .lineLimit(1) .minimumScaleFactor(0.85) @@ -303,7 +619,7 @@ private struct AgentRunRow: View { Spacer(minLength: 4) Text(phase.label) - .font(.system(size: compact ? 9 : 9.5, weight: .semibold)) + .font(compact ? .system(size: 9, weight: .semibold) : .caption2.weight(.semibold)) .foregroundStyle(phase.needsAttention ? phase.tint : .secondary) .lineLimit(1) } @@ -345,6 +661,7 @@ private struct AgentRunRow: View { /// Prefer the host-supplied detail line; fall back to "lane · model". private var subtitle: String? { + guard !hideDetails else { return nil } let detail = run.detail?.trimmingCharacters(in: .whitespacesAndNewlines) if let detail, !detail.isEmpty { return detail } return run.subtitle @@ -354,10 +671,29 @@ private struct AgentRunRow: View { private struct PullRequestActivityRow: View { let pr: ADEAgentRunsAttributes.PullRequest let compact: Bool + let hideDetails: Bool private var phase: PullRequestPhase { pr.resolvedPhase } var body: some View { + Group { + if let url = pr.deepLinkURL { + Link(destination: url) { rowContent } + .buttonStyle(.plain) + } else { + rowContent + } + } + .padding(.vertical, phase.needsAttention && !compact ? 3 : 0) + .padding(.horizontal, phase.needsAttention && !compact ? 6 : 0) + .background( + phase.needsAttention && !compact + ? RoundedRectangle(cornerRadius: 7, style: .continuous).fill(phase.tint.opacity(0.14)) + : nil + ) + } + + private var rowContent: some View { HStack(spacing: 8) { Image(systemName: phase.symbol) .font(.system(size: compact ? 10 : 11, weight: .semibold)) @@ -365,14 +701,14 @@ private struct PullRequestActivityRow: View { .frame(width: 14, alignment: .center) VStack(alignment: .leading, spacing: 1) { - Text("#\(pr.prNumber) \(pr.title)") - .font(.system(size: compact ? 11 : 12.5, weight: .medium)) + Text(hideDetails ? "Pull request update" : "#\(pr.prNumber) \(pr.title)") + .font(compact ? .system(size: 11, weight: .medium) : .caption.weight(.medium)) .foregroundStyle(.primary) .lineLimit(1) .minimumScaleFactor(0.85) - if let subtitle = pr.subtitle { + if !hideDetails, let subtitle = pr.subtitle { Text(subtitle) - .font(.system(size: compact ? 9 : 10, weight: .regular)) + .font(compact ? .system(size: 9, weight: .regular) : .caption2) .foregroundStyle(.secondary) .lineLimit(1) .minimumScaleFactor(0.85) @@ -382,17 +718,11 @@ private struct PullRequestActivityRow: View { Spacer(minLength: 4) Text(phase.label) - .font(.system(size: compact ? 9 : 9.5, weight: .semibold)) + .font(compact ? .system(size: 9, weight: .semibold) : .caption2.weight(.semibold)) .foregroundStyle(phase.needsAttention ? phase.tint : .secondary) .lineLimit(1) } - .padding(.vertical, phase.needsAttention && !compact ? 3 : 0) - .padding(.horizontal, phase.needsAttention && !compact ? 6 : 0) - .background( - phase.needsAttention && !compact - ? RoundedRectangle(cornerRadius: 7, style: .continuous).fill(phase.tint.opacity(0.14)) - : nil - ) + .contentShape(Rectangle()) } } diff --git a/apps/ios/ADEWidgets/ADELockScreenWidget.swift b/apps/ios/ADEWidgets/ADELockScreenWidget.swift index 9951a23fb..4c932662f 100644 --- a/apps/ios/ADEWidgets/ADELockScreenWidget.swift +++ b/apps/ios/ADEWidgets/ADELockScreenWidget.swift @@ -24,6 +24,20 @@ struct ADELockScreenWidget: Widget { struct ADEStatusEntry: TimelineEntry { let date: Date let snapshot: WorkspaceSnapshot + let attentionSnapshot: AccountAttentionSnapshot? + let hideDetails: Bool + + init( + date: Date, + snapshot: WorkspaceSnapshot, + attentionSnapshot: AccountAttentionSnapshot? = nil, + hideDetails: Bool = false + ) { + self.date = date + self.snapshot = snapshot + self.attentionSnapshot = attentionSnapshot + self.hideDetails = hideDetails + } } struct ADEStatusTimelineProvider: TimelineProvider { @@ -33,13 +47,28 @@ struct ADEStatusTimelineProvider: TimelineProvider { func getSnapshot(in context: Context, completion: @escaping (ADEStatusEntry) -> Void) { let snapshot = ADESharedContainer.readWorkspaceSnapshot() ?? .empty - completion(ADEStatusEntry(date: Date(), snapshot: snapshot)) + completion(ADEStatusEntry( + date: Date(), + snapshot: snapshot, + attentionSnapshot: ADESharedContainer.readAttentionSnapshot(), + hideDetails: ADESharedContainer.hideAttentionDetails + )) } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { let now = Date() let snapshot = ADESharedContainer.readWorkspaceSnapshot() ?? .empty - completion(Timeline(entries: [ADEStatusEntry(date: now, snapshot: snapshot)], policy: .after(now.addingTimeInterval(60)))) + completion(Timeline( + entries: [ + ADEStatusEntry( + date: now, + snapshot: snapshot, + attentionSnapshot: ADESharedContainer.readAttentionSnapshot(), + hideDetails: ADESharedContainer.hideAttentionDetails + ) + ], + policy: .after(now.addingTimeInterval(60)) + )) } } @@ -48,7 +77,19 @@ struct LockScreenWidgetEntryView: View { @Environment(\.widgetFamily) private var family var body: some View { - let status = LockScreenPriorityStatus(snapshot: entry.snapshot) + let status: LockScreenPriorityStatus + if let account = entry.attentionSnapshot, + Date().timeIntervalSince(account.generatedAt) <= 86_400 { + status = LockScreenPriorityStatus( + attentionSnapshot: account, + hideDetails: entry.hideDetails + ) + } else { + status = LockScreenPriorityStatus( + snapshot: entry.snapshot, + hideDetails: entry.hideDetails + ) + } return Group { switch family { @@ -63,6 +104,7 @@ struct LockScreenWidgetEntryView: View { } } .widgetURL(status.destinationURL) + .privacySensitive() } } @@ -99,7 +141,90 @@ private struct LockScreenPriorityStatus { let symbol: String } - init(snapshot: WorkspaceSnapshot) { + init(attentionSnapshot: AccountAttentionSnapshot, hideDetails: Bool = false) { + let now = Date() + let visible = attentionSnapshot.items.filter { item in + item.dismissedAt == nil + && (item.expiresAt == nil || item.expiresAt! > now) + } + let ordered = visible.sorted { lhs, rhs in + let priority = Self.priority(lhs.phase) - Self.priority(rhs.phase) + if priority != 0 { return priority < 0 } + return lhs.updatedAt > rhs.updatedAt + } + let inbox = visible.filter(\.needsInbox) + let live = visible.filter(\.isLive) + let machines = Set(visible.map(\.machine.machineKey)) + let onlineMachines = Set(visible.filter(\.machine.online).map(\.machine.machineKey)) + let metrics = [ + inbox.isEmpty ? nil : Metric(id: "needs", label: "\(inbox.count) need", symbol: "bell.fill"), + live.isEmpty ? nil : Metric(id: "live", label: "\(live.count) live", symbol: "waveform.path.ecg"), + machines.isEmpty ? nil : Metric(id: "machines", label: "\(machines.count) Mac", symbol: "desktopcomputer"), + ].compactMap { $0 } + + guard let focus = ordered.first else { + self = .init( + kind: .idle, + title: "ADE idle", + detail: "No work needs attention", + inlineText: "ADE · idle", + count: 0, + symbol: "moon.zzz.fill", + shortLabel: "IDLE", + tint: ADESharedTheme.statusIdle, + destinationURL: Self.workspaceURL, + metrics: [] + ) + return + } + + if onlineMachines.isEmpty, !machines.isEmpty { + self = .init( + kind: .offline, + title: hideDetails + ? "Mac offline" + : (machines.count == 1 ? "\(focus.machine.name) offline" : "\(machines.count) Macs offline"), + detail: hideDetails ? "Open ADE for details" : "Last known work · \(focus.project.name)", + inlineText: "ADE · offline", + count: 0, + symbol: "wifi.slash", + shortLabel: "OFF", + tint: ADESharedTheme.statusIdle, + destinationURL: focus.deepLinkURL ?? Self.workspaceURL, + metrics: metrics + ) + return + } + + let presentation = Self.presentation(for: focus.phase) + let scope = "\(focus.machine.name) · \(focus.project.name)" + let attentionCount = inbox.count + let ambientCount = visible.count + let privateTitle = focus.privacyPreview + .trimmingCharacters(in: .whitespacesAndNewlines) + self = .init( + kind: presentation.kind, + title: hideDetails + ? (privateTitle.isEmpty ? "Attention update" : privateTitle) + : focus.title, + detail: hideDetails ? "Across your signed-in machines" : scope, + inlineText: attentionCount > 0 + ? "ADE · \(attentionCount) need you" + : live.isEmpty + ? "ADE · \(ambientCount) recent" + : "ADE · \(live.count) live", + count: attentionCount > 0 + ? attentionCount + : (live.isEmpty ? ambientCount : live.count), + symbol: presentation.symbol, + shortLabel: presentation.label, + tint: presentation.tint, + destinationURL: focus.deepLinkURL ?? Self.workspaceURL, + metrics: metrics + ) + } + + init(snapshot: WorkspaceSnapshot, hideDetails: Bool = false) { let running = snapshot.runningAgents.sorted { $0.lastActivityAt > $1.lastActivityAt } let awaiting = snapshot.agents.filter { agent in agent.awaitingInput || agent.status.lowercased() == "awaiting_input" @@ -118,6 +243,8 @@ private struct LockScreenPriorityStatus { } let waitingCount = max(snapshot.awaitingInputCount, awaiting.count) let idleCount = snapshot.idleCount + let snapshotAge = Date().timeIntervalSince(snapshot.generatedAt) + let isStale = snapshot.generatedAt.timeIntervalSince1970 > 0 && snapshotAge > 90 let metrics = Self.metrics( runningCount: running.count, @@ -126,12 +253,31 @@ private struct LockScreenPriorityStatus { idleCount: idleCount ) - if waitingCount > 0 { + if isStale { + let machine = snapshot.machineName?.trimmingCharacters(in: .whitespacesAndNewlines) + let minutes = max(1, Int(snapshotAge / 60)) + self = .init( + kind: .offline, + title: hideDetails + ? "Mac offline" + : "\((machine?.isEmpty == false ? machine : nil) ?? "Mac") offline", + detail: minutes == 1 ? "Last update 1 minute ago" : "Last update \(minutes) minutes ago", + inlineText: "ADE · offline \(minutes)m", + count: 0, + symbol: "wifi.slash", + shortLabel: "OFF", + tint: ADESharedTheme.statusIdle, + destinationURL: Self.workspaceURL, + metrics: metrics + ) + } else if waitingCount > 0 { let first = awaiting.first self = .init( kind: .awaitingInput, title: waitingCount == 1 ? "1 chat waiting" : "\(waitingCount) chats waiting", - detail: first.map { Self.agentTitle($0) } ?? "Reply or approve in ADE", + detail: hideDetails + ? "Open ADE to reply or approve" + : (first.map { Self.agentTitle($0) } ?? "Reply or approve in ADE"), inlineText: "ADE · \(waitingCount) waiting", count: waitingCount, symbol: "bell.badge.fill", @@ -144,7 +290,7 @@ private struct LockScreenPriorityStatus { self = .init( kind: .failed, title: failed.count == 1 ? "Agent failed" : "\(failed.count) agents failed", - detail: Self.agentTitle(first), + detail: hideDetails ? "Open ADE for details" : Self.agentTitle(first), inlineText: "ADE · \(failed.count) failed", count: failed.count, symbol: "xmark.octagon.fill", @@ -157,7 +303,7 @@ private struct LockScreenPriorityStatus { self = .init( kind: .ciFailing, title: ciFailing.count == 1 ? "CI failing" : "\(ciFailing.count) PRs failing", - detail: Self.prTitle(first), + detail: hideDetails ? "Open ADE for details" : Self.prTitle(first), inlineText: "ADE · \(ciFailing.count) CI failing", count: ciFailing.count, symbol: "exclamationmark.triangle.fill", @@ -174,12 +320,12 @@ private struct LockScreenPriorityStatus { self = .init( kind: .reviewRequested, title: title, - detail: Self.prTitle(first), + detail: hideDetails ? "Open ADE for details" : Self.prTitle(first), inlineText: "ADE · \(reviewRequested.count) review", count: reviewRequested.count, symbol: "eye.fill", shortLabel: "REV", - tint: ADESharedTheme.warningAmber, + tint: changes > 0 ? ADESharedTheme.statusFailed : ADESharedTheme.statusReview, destinationURL: Self.prURL(first), metrics: metrics ) @@ -187,7 +333,7 @@ private struct LockScreenPriorityStatus { self = .init( kind: .mergeReady, title: mergeReady.count == 1 ? "Ready to merge" : "\(mergeReady.count) PRs ready", - detail: Self.prTitle(first), + detail: hideDetails ? "Open ADE for details" : Self.prTitle(first), inlineText: "ADE · \(mergeReady.count) ready", count: mergeReady.count, symbol: "checkmark.seal.fill", @@ -200,12 +346,12 @@ private struct LockScreenPriorityStatus { self = .init( kind: .running, title: running.count == 1 ? "1 agent running" : "\(running.count) agents running", - detail: Self.runningDetail(first), + detail: hideDetails ? "Agent work is in progress" : Self.runningDetail(first), inlineText: "ADE · \(running.count) running", count: running.count, symbol: "circle.dotted", shortLabel: "RUN", - tint: ADESharedTheme.statusSuccess, + tint: ADESharedTheme.statusRunning, destinationURL: Self.sessionURL(first.sessionId), metrics: metrics ) @@ -213,12 +359,12 @@ private struct LockScreenPriorityStatus { self = .init( kind: .openPullRequests, title: openPrs.count == 1 ? "1 open PR" : "\(openPrs.count) open PRs", - detail: Self.prTitle(first), + detail: hideDetails ? "Open ADE for details" : Self.prTitle(first), inlineText: "ADE · \(openPrs.count) PRs", count: openPrs.count, symbol: "arrow.triangle.pull", shortLabel: "PR", - tint: ADESharedTheme.brandCursor, + tint: ADESharedTheme.statusRunning, destinationURL: Self.prURL(first), metrics: metrics ) @@ -349,6 +495,47 @@ private struct LockScreenPriorityStatus { } return Array(result.prefix(3)) } + + private static func priority(_ phase: AccountAttentionPhase) -> Int { + switch phase { + case .needsYou: return 0 + case .failed, .checksFailing, .changesRequested: return 1 + case .reviewRequested, .mergeReady, .blocked: return 2 + case .starting, .running: return 3 + case .open, .stale: return 4 + case .completed, .merged: return 5 + case .closed: return 6 + } + } + + private static func presentation( + for phase: AccountAttentionPhase + ) -> (kind: Kind, symbol: String, label: String, tint: Color) { + switch phase { + case .needsYou, .blocked: + return (.awaitingInput, "bell.badge.fill", "YOU", ADESharedTheme.warningAmber) + case .failed: + return (.failed, "xmark.octagon.fill", "FAIL", ADESharedTheme.statusFailed) + case .checksFailing, .changesRequested: + return (.ciFailing, "exclamationmark.triangle.fill", "CHECK", ADESharedTheme.statusFailed) + case .reviewRequested: + return (.reviewRequested, "eye.fill", "REV", ADESharedTheme.statusReview) + case .mergeReady: + return (.mergeReady, "checkmark.seal.fill", "READY", ADESharedTheme.statusSuccess) + case .starting, .running: + return (.running, "waveform.path.ecg", "LIVE", ADESharedTheme.statusRunning) + case .open: + return (.idle, "arrow.triangle.pull", "OPEN", ADESharedTheme.statusRunning) + case .stale: + return (.offline, "wifi.slash", "OFF", ADESharedTheme.statusIdle) + case .completed: + return (.idle, "checkmark.circle.fill", "DONE", ADESharedTheme.statusSuccess) + case .merged: + return (.idle, "arrow.triangle.merge", "MERGED", ADESharedTheme.statusSuccess) + case .closed: + return (.idle, "xmark.circle.fill", "CLOSED", ADESharedTheme.statusIdle) + } + } } // MARK: - Rectangular @@ -360,39 +547,53 @@ private struct LockScreenRectangularView: View { var body: some View { ZStack { AccessoryWidgetBackground() - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 6) { + HStack(spacing: 8) { + ZStack { + Circle() + .fill(status.tint.opacity(0.16)) Image(systemName: status.symbol) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(status.tint) - .widgetAccentable() - .frame(width: 14, alignment: .center) - Text(status.title) .font(.system(size: 13, weight: .semibold)) - .lineLimit(1) - .minimumScaleFactor(0.82) - .frame(maxWidth: .infinity, alignment: .leading) + .foregroundStyle(status.tint) + .contentTransition(.symbolEffect(.replace)) } - Text(status.detail) - .font(.system(size: 10.5, weight: .regular)) - .foregroundStyle(.secondary) - .lineLimit(1) - .minimumScaleFactor(0.82) - .frame(maxWidth: .infinity, alignment: .leading) - if !status.metrics.isEmpty { + .frame(width: 30, height: 30) + .widgetAccentable() + + VStack(alignment: .leading, spacing: 2) { HStack(spacing: 5) { - ForEach(status.metrics) { metric in - Label(metric.label, systemImage: metric.symbol) - .font(.system(size: 9, weight: .semibold)) - .labelStyle(.titleAndIcon) - .lineLimit(1) - .minimumScaleFactor(0.8) - .foregroundStyle(.secondary) + Text(status.title) + .font(.footnote.weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.78) + .frame(maxWidth: .infinity, alignment: .leading) + Text(status.shortLabel) + .font(.system(size: status.shortLabel.count > 4 ? 7 : 8, weight: .bold, design: .rounded)) + .foregroundStyle(status.tint) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(status.tint.opacity(0.12), in: Capsule()) + .widgetAccentable() + } + Text(status.detail) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.78) + if !status.metrics.isEmpty { + HStack(spacing: 6) { + ForEach(status.metrics.prefix(2)) { metric in + Label(metric.label, systemImage: metric.symbol) + .font(.caption2.weight(.semibold)) + .labelStyle(.titleAndIcon) + .lineLimit(1) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) } - Spacer(minLength: 0) } } } + .padding(.horizontal, 1) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .opacity(isLuminanceReduced ? 0.85 : 1) } @@ -409,31 +610,28 @@ private struct LockScreenCircularView: View { @Environment(\.isLuminanceReduced) private var isLuminanceReduced var body: some View { - Group { - if status.count > 0 { - Gauge(value: 1, in: 0...1) { - EmptyView() - } currentValueLabel: { - VStack(spacing: -1) { + ZStack { + AccessoryWidgetBackground() + Gauge(value: status.count > 0 ? 0.84 : 0.18, in: 0...1) { + EmptyView() + } currentValueLabel: { + VStack(spacing: -1) { + if status.count > 0 { Text("\(min(status.count, 99))") - .font(.system(size: status.count >= 10 ? 16 : 20, weight: .black)) - .minimumScaleFactor(0.7) - .lineLimit(1) - Text(status.shortLabel) - .font(.system(size: status.shortLabel.count > 4 ? 7 : 8, weight: .semibold)) - .lineLimit(1) - .minimumScaleFactor(0.65) + .font(.system(size: status.count >= 10 ? 15 : 18, weight: .black, design: .rounded)) + } else { + Image(systemName: status.symbol) + .font(.system(size: 15, weight: .semibold)) + .contentTransition(.symbolEffect(.replace)) } - } - .gaugeStyle(.accessoryCircular) - } else { - ZStack { - AccessoryWidgetBackground() - Image(systemName: status.symbol) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(status.tint) + Text(status.shortLabel) + .font(.system(size: status.shortLabel.count > 4 ? 6.5 : 7.5, weight: .bold, design: .rounded)) + .lineLimit(1) + .minimumScaleFactor(0.6) } } + .gaugeStyle(.accessoryCircular) + .tint(status.tint) } .widgetAccentable() .opacity(isLuminanceReduced ? 0.85 : 1) @@ -449,7 +647,8 @@ private struct LockScreenInlineView: View { let status: LockScreenPriorityStatus var body: some View { - Text(status.inlineText) + Label(status.inlineText, systemImage: status.symbol) + .labelStyle(.titleAndIcon) .lineLimit(1) .minimumScaleFactor(0.82) .accessibilityLabel("ADE") diff --git a/apps/push-relay/README.md b/apps/push-relay/README.md index 949a18518..ec2013de7 100644 --- a/apps/push-relay/README.md +++ b/apps/push-relay/README.md @@ -1,10 +1,9 @@ # ADE Push Relay -Cloudflare Worker that fans ADE agent-state transitions out to iPhones as APNs -alert pushes and Live Activity updates. The ADE machine runtime (brain) is the -only publisher; phones never talk to this worker directly — they hand their -APNs tokens to the brain over the paired sync WebSocket, and the brain -registers them here. +Cloudflare Worker that consolidates ADE attention state across a signed-in +account, then fans it out to desktop Attention Center, iPhone, APNs, and Live +Activities. ADE machine runtimes publish sanitized state; signed-in clients +read and acknowledge the account stream directly. This is a **separate worker** from `apps/webhook-relay` (the GitHub webhook relay): different trust model, different lifecycle, and free-plan compatible on @@ -18,9 +17,15 @@ its own (single D1 database, no Durable Objects, no queues). - Every other call is HMAC-signed with that secret: - `x-ade-push-timestamp`: unix seconds (±5 min skew allowed) - `x-ade-push-signature`: `sha256=HMAC(secret, "...")` -- The secret is scoped to push publishing only. It never grants access to any - project data, and the worker stores no chat/PR content — only device tokens - and notification payloads in flight. +- `POST /machines/:key/attention` additionally requires the signed-in user's + Clerk bearer token. That binds the machine stream to an account without + making the machine secret an account credential. +- Account routes require a Clerk bearer token whose issuer and + audience/authorized-party match this deployment. +- The machine secret is scoped to push publishing only. Account Attention + stores bounded, sanitized presentation metadata (titles, previews, + destinations, progress, and acknowledgements), never transcripts, prompts, + diffs, or artifact contents. Entries expire and tombstones are pruned. ## Endpoints @@ -33,6 +38,29 @@ its own (single D1 database, no Durable Objects, no queues). | GET | `/machines/:key/devices` | List registrations (diagnostics) | | POST | `/machines/:key/live-activity-tokens` | Upsert/remove a per-activity update token (`deviceId`, `activityId`, `token`; empty token removes) | | POST | `/machines/:key/publish` | Publish `notifications` (alert pushes) and/or `liveActivity` events | +| POST | `/machines/:key/attention` | Publish the machine's complete account Attention snapshot (HMAC + Clerk bearer) | +| GET | `/attention/account/snapshot?since=` | Read account Attention changes | +| POST | `/attention/account/ack` | Mark items seen or dismissed across devices | +| POST | `/attention/account/presence` | Report foreground/ambient-surface presence for desktop-first escalation | +| GET, PUT | `/attention/account/preferences` | Read or replace account notification preferences | +| PUT, DELETE | `/attention/account/devices/:deviceId` | Register or remove an account APNs destination. JSON must include a positive monotonic `ownershipEpoch`; stale account requests receive `409` with the latest `ownershipEpoch`. DELETE retains that ownership epoch so delayed requests cannot reclaim the install. | +| PUT, DELETE | `/attention/account/devices/:deviceId/activities/:activityId` | Register or remove an account Live Activity update token | + +### Account Attention semantics + +- Each brain publishes one bounded full snapshot for its machine. The worker + merges every linked machine into a revisioned account stream, including + tombstones so desktop and iOS converge after removals. +- Seen and dismissed state belongs to the account, so acknowledging an item on + iPhone clears it on desktop and vice versa. +- Routine running/progress state remains ambient. Needs-input, failure, + checks-failing, review-requested, changes-requested, and merge-ready + transitions may notify according to account/device preferences. +- A foreground Mac suppresses the immediate phone alert. If the item remains + unseen after the escalation window, a later machine heartbeat can send it. +- The relay owns one account-wide Live Activity per iPhone. It focuses the + highest-priority work across machines and avoids competing per-machine + activities when the account publisher is healthy. ### Publish semantics @@ -113,6 +141,31 @@ TestFlight/App Store builds register `production`), and uses the registration's Until `APNS_KEY`/`APNS_KEY_ID`/`APNS_TEAM_ID` are set, registration endpoints work but `publish` returns 503 (`/health` reports `apnsConfigured: false`). +### Clerk verification (required for account Attention) + +Configure the same Clerk instance and ADE OAuth client used by desktop and iOS: + +```bash +npx wrangler secret put CLERK_JWKS_URL +npx wrangler secret put CLERK_ISSUER +npx wrangler secret put CLERK_OAUTH_CLIENT_ID +``` + +If these are absent, legacy machine-scoped push routes continue to work, while +account Attention routes fail closed with `401`. + +The iOS Debug configuration uses ADE's development Clerk instance. A shared +relay deployment can accept it without mixing identity domains by configuring: + +```bash +npx wrangler secret put CLERK_SECONDARY_JWKS_URL +npx wrangler secret put CLERK_SECONDARY_ISSUER +npx wrangler secret put CLERK_SECONDARY_OAUTH_CLIENT_ID +``` + +Verified account keys are namespaced by issuer before persistence, so identical +opaque user ids from development and production cannot collide. + ## Local dev ```bash diff --git a/apps/push-relay/migrations/0003_account_attention.sql b/apps/push-relay/migrations/0003_account_attention.sql new file mode 100644 index 000000000..27eb30895 --- /dev/null +++ b/apps/push-relay/migrations/0003_account_attention.sql @@ -0,0 +1,190 @@ +alter table machines add column account_user_id text; + +create table if not exists attention_revisions ( + user_id text primary key, + revision integer not null default 0, + updated_at text not null +); + +create table if not exists attention_machine_links ( + machine_key text primary key, + user_id text not null, + machine_name text, + last_seen_at text not null, + linked_at text not null, + legacy_devices_imported_at text +); + +create index if not exists idx_attention_machine_links_user + on attention_machine_links(user_id, last_seen_at desc); + +create table if not exists attention_items ( + user_id text not null, + item_id text not null, + machine_key text not null, + source_revision integer not null, + account_revision integer not null, + fingerprint text not null, + event_kind text not null, + phase text not null, + payload_json text not null, + seen_at text, + dismissed_at text, + expires_at text, + updated_at text not null, + primary key(user_id, item_id) +); + +create index if not exists idx_attention_items_user_revision + on attention_items(user_id, account_revision); + +create index if not exists idx_attention_items_expiry + on attention_items(expires_at); + +create table if not exists attention_tombstones ( + user_id text not null, + item_id text not null, + source_revision integer not null, + account_revision integer not null, + deleted_at text not null, + primary key(user_id, item_id) +); + +create index if not exists idx_attention_tombstones_user_revision + on attention_tombstones(user_id, account_revision); + +create table if not exists attention_devices ( + user_id text not null, + device_id text not null, + source_machine_key text, + apns_token text, + push_to_start_token text, + bundle_id text not null, + aps_environment text not null check (aps_environment in ('sandbox', 'production')), + platform text, + device_name text, + preferences_json text not null, + registered_at text not null, + updated_at text not null, + lease_expires_at text not null, + primary key(user_id, device_id) +); + +create index if not exists idx_attention_devices_user + on attention_devices(user_id, updated_at desc); + +create index if not exists idx_attention_devices_lease + on attention_devices(lease_expires_at); + +create index if not exists idx_attention_devices_apns_token + on attention_devices(apns_token); + +create unique index if not exists idx_attention_devices_unique_device + on attention_devices(device_id); + +create unique index if not exists idx_attention_devices_unique_apns_token + on attention_devices(apns_token) + where apns_token is not null; + +create trigger if not exists attention_devices_enforce_user_limit +before insert on attention_devices +when not exists ( + select 1 + from attention_devices + where user_id = new.user_id and device_id = new.device_id +) +and ( + select count(*) + from attention_devices + where user_id = new.user_id +) >= 32 +begin + select raise(abort, 'attention account device limit reached'); +end; + +-- Durable ownership survives attention_devices deletion so delayed requests +-- from a previous account cannot reclaim or remove a switched installation. +create table if not exists attention_device_ownership ( + device_id text primary key, + user_id text not null, + ownership_epoch integer not null check (ownership_epoch > 0), + apns_token text, + active integer not null check (active in (0, 1)), + updated_at text not null +); + +create unique index if not exists idx_attention_device_ownership_apns_token + on attention_device_ownership(apns_token) + where apns_token is not null; + +create trigger if not exists attention_device_ownership_reject_stale +before insert on attention_device_ownership +when exists ( + select 1 + from attention_device_ownership as current + where ( + current.device_id = new.device_id + or ( + new.apns_token is not null + and current.apns_token = new.apns_token + ) + ) + and ( + current.ownership_epoch > new.ownership_epoch + or ( + current.ownership_epoch = new.ownership_epoch + and current.user_id <> new.user_id + ) + ) +) +begin + select raise(abort, 'stale attention device ownership'); +end; + +create table if not exists attention_activity_tokens ( + user_id text not null, + device_id text not null, + activity_id text not null, + token text not null, + updated_at text not null, + primary key(user_id, device_id, activity_id) +); + +create table if not exists attention_activity_state ( + user_id text not null, + device_id text not null, + activity_id text not null, + started integer not null default 0, + fingerprint text, + updated_at text not null, + primary key(user_id, device_id, activity_id) +); + +create table if not exists attention_presence ( + user_id text not null, + device_id text not null, + payload_json text not null, + observed_at text not null, + primary key(user_id, device_id) +); + +create index if not exists idx_attention_presence_user + on attention_presence(user_id, observed_at desc); + +create table if not exists attention_preferences ( + user_id text primary key, + payload_json text not null, + updated_at text not null +); + +create table if not exists attention_delivery_receipts ( + user_id text not null, + item_id text not null, + device_id text not null, + state text not null, + delivered_at text not null, + primary key(user_id, item_id, device_id, state) +); + +create index if not exists idx_attention_delivery_receipts_user_item + on attention_delivery_receipts(user_id, item_id); diff --git a/apps/push-relay/package-lock.json b/apps/push-relay/package-lock.json index 54657593d..43ba65a16 100644 --- a/apps/push-relay/package-lock.json +++ b/apps/push-relay/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "ade-push-relay", "version": "0.0.0", + "dependencies": { + "jose": "^6.2.4" + }, "devDependencies": { "@cloudflare/workers-types": "^4.20260620.0", "@types/node": "^20.11.30", @@ -1974,6 +1977,15 @@ "node": "*" } }, + "node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", diff --git a/apps/push-relay/package.json b/apps/push-relay/package.json index 949661154..26844e506 100644 --- a/apps/push-relay/package.json +++ b/apps/push-relay/package.json @@ -17,5 +17,8 @@ "typescript": "^5.7.3", "vitest": "^0.34.6", "wrangler": "^4.53.0" + }, + "dependencies": { + "jose": "^6.2.4" } } diff --git a/apps/push-relay/src/attention.ts b/apps/push-relay/src/attention.ts new file mode 100644 index 000000000..4a0a03b9f --- /dev/null +++ b/apps/push-relay/src/attention.ts @@ -0,0 +1,2443 @@ +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; +import { + sendApnsPush, + type ApnsEnvironment, + type ApnsKeyConfig, +} from "./apns"; + +export type AttentionRelayEnv = { + DB: D1Database; + CLERK_JWKS_URL?: string; + CLERK_ISSUER?: string; + CLERK_OAUTH_CLIENT_ID?: string; + CLERK_SECONDARY_JWKS_URL?: string; + CLERK_SECONDARY_ISSUER?: string; + CLERK_SECONDARY_OAUTH_CLIENT_ID?: string; + APNS_KEY?: string; + APNS_KEY_ID?: string; + APNS_TEAM_ID?: string; + APNS_DEFAULT_TOPIC?: string; +}; + +type AttentionItemRow = { + payload_json: string; + seen_at: string | null; + dismissed_at: string | null; + account_revision: number; +}; + +type AttentionTombstoneRow = { + item_id: string; + source_revision: number; + account_revision: number; + deleted_at: string; +}; + +type AttentionDeviceRow = { + device_id: string; + apns_token: string | null; + push_to_start_token: string | null; + bundle_id: string; + aps_environment: string; + preferences_json: string; +}; + +type AttentionDeviceOwnershipRow = { + device_id: string; + user_id: string; + ownership_epoch: number; + apns_token: string | null; +}; + +type ParsedAttentionItem = Record & { + contractVersion: 1; + id: string; + revision: number; + fingerprint: string; + kind: "agent" | "pull_request"; + eventKind: string; + phase: string; + title: string; + preview: string; + privacyPreview: string; + updatedAt: string; + expiresAt: string | null; + machine: Record & { machineKey: string; name: string }; + project: { + projectId: string; + name: string; + rootPath: string | null; + }; + destination: Record; + actions: Array>; +}; + +const MAX_BODY_BYTES = 256 * 1024; +const MAX_ATTENTION_ITEMS = 64; +const MAX_ATTENTION_TOMBSTONES = 64; +const MAX_ATTENTION_DEVICES = 32; +const ATTENTION_DEVICE_LEASE_MS = 30 * 24 * 60 * 60 * 1_000; +const MAX_NOTIFICATION_ATTEMPTS_PER_PUBLISH = 64; +const MAX_ID_LENGTH = 256; +const MAX_TITLE_LENGTH = 180; +const MAX_PREVIEW_LENGTH = 320; +const MAX_DETAIL_LENGTH = 1_000; +const APNS_TOKEN_PATTERN = /^[a-f0-9]{32,512}$/i; +const ACCOUNT_MACHINE_ONLINE_WINDOW_MS = 90_000; +const DEFAULT_DESKTOP_ESCALATION_DELAY_SECONDS = 30; +const DESKTOP_PRESENCE_WINDOW_MS = 45_000; +const TOMBSTONE_RETENTION_MS = 24 * 60 * 60 * 1_000; +const remoteJwksByUrl = new Map>(); + +const EVENT_KINDS = new Set([ + "agent_running", + "agent_needs_you", + "agent_failed", + "agent_completed", + "pr_checks_failing", + "pr_review_requested", + "pr_changes_requested", + "pr_merge_ready", + "pr_merged", + "pr_opened", + "pr_closed", +]); + +const PHASES = new Set([ + "starting", + "running", + "needs_you", + "blocked", + "failed", + "completed", + "stale", + "checks_failing", + "review_requested", + "changes_requested", + "merge_ready", + "open", + "merged", + "closed", +]); + +const ACTION_KINDS = new Set([ + "approve", + "deny", + "answer", + "restart", + "rerun_checks", + "mark_seen", + "dismiss", + "open", +]); + +const PR_TABS = new Set(["overview", "activity", "checks", "files"]); + +const DEFAULT_NOTIFY_EVENTS = new Set([ + "agent_needs_you", + "agent_failed", + "pr_checks_failing", + "pr_review_requested", + "pr_changes_requested", + "pr_merge_ready", +]); + +function json(value: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(value), { + ...init, + headers: { + "content-type": "application/json", + "cache-control": "no-store", + ...(init.headers ?? {}), + }, + }); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function requiredString(value: unknown, maxLength = MAX_ID_LENGTH): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim(); + if (!normalized || normalized.length > maxLength) return null; + return normalized; +} + +function optionalIsoDate(value: unknown): string | null | undefined { + if (value === null || value === undefined) return null; + const normalized = requiredString(value, 64); + if (!normalized || Number.isNaN(Date.parse(normalized))) return undefined; + return new Date(normalized).toISOString(); +} + +function boundedText(value: unknown, maxLength: number): string | null { + const text = requiredString(value, maxLength * 4); + if (!text) return null; + const sanitized = text + .replace(/\b(?:sk|pk|ghp|github_pat|xox[baprs])_[A-Za-z0-9_-]{12,}\b/gi, "[redacted]") + .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}\b/gi, "Bearer [redacted]") + .replace(/\s+/g, " ") + .trim(); + return sanitized.slice(0, maxLength); +} + +function apnsConfig(env: AttentionRelayEnv): ApnsKeyConfig | null { + const keyPem = env.APNS_KEY?.trim() ?? ""; + const keyId = env.APNS_KEY_ID?.trim() ?? ""; + const teamId = env.APNS_TEAM_ID?.trim() ?? ""; + return keyPem && keyId && teamId ? { keyPem, keyId, teamId } : null; +} + +function deepLinkForItem(item: ParsedAttentionItem): string | null { + const destination = item.destination; + if (!isRecord(destination)) return null; + const accountMachineKey = requiredString(item.machine.accountMachineKey, 128); + if (destination.kind === "session") { + const sessionId = requiredString(destination.sessionId); + if (!sessionId) return null; + const query = new URLSearchParams(); + const itemId = requiredString(destination.itemId); + const eventId = requiredString(destination.eventId); + if (itemId) query.set("item", itemId); + if (eventId) query.set("event", eventId); + if (accountMachineKey) query.set("accountMachineKey", accountMachineKey); + return `ade://session/${encodeURIComponent(sessionId)}${query.size ? `?${query}` : ""}`; + } + if (destination.kind === "pull_request") { + const number = Number(destination.number); + if (!Number.isSafeInteger(number) || number <= 0) return null; + const owner = requiredString(destination.repoOwner); + const repo = requiredString(destination.repoName); + const tab = requiredString(destination.tab, 32); + const query = new URLSearchParams(); + if (tab && tab !== "overview") query.set("tab", tab); + if (accountMachineKey) query.set("accountMachineKey", accountMachineKey); + const suffix = query.size ? `?${query}` : ""; + return owner && repo + ? `ade://pr/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}${suffix}` + : `ade://pr/${number}${suffix}`; + } + return null; +} + +function attentionAlertRoutingPayload( + item: ParsedAttentionItem, + deepLink: string | null, +): Record { + const accountMachineKey = requiredString(item.machine.accountMachineKey, 128); + return { + attentionItemId: item.id, + eventKind: item.eventKind, + ...(accountMachineKey ? { accountMachineKey } : {}), + ...(deepLink ? { deepLink } : {}), + ...(isRecord(item.destination) && typeof item.destination.sessionId === "string" + ? { sessionId: item.destination.sessionId } + : {}), + ...(isRecord(item.destination) && typeof item.destination.itemId === "string" + ? { itemId: item.destination.itemId } + : {}), + }; +} + +function readPreferences(value: string | null | undefined): Record { + if (!value) return {}; + try { + const parsed = JSON.parse(value); + return isRecord(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function preferenceBoolean( + device: Record, + account: Record, + key: string, + fallback: boolean, +): boolean { + if (typeof device[key] === "boolean") return device[key]; + if (typeof account[key] === "boolean") return account[key]; + return fallback; +} + +function preferenceNumber( + device: Record, + account: Record, + key: string, + fallback: number, +): number { + if (typeof device[key] === "number" && Number.isFinite(device[key])) return device[key]; + if (typeof account[key] === "number" && Number.isFinite(account[key])) return account[key]; + return fallback; +} + +function desktopEscalationDelayMs(accountPreferences: Record): number { + return Math.max( + 0, + Math.min( + 300, + Math.round(preferenceNumber( + {}, + accountPreferences, + "desktopFirstDelaySeconds", + DEFAULT_DESKTOP_ESCALATION_DELAY_SECONDS, + )), + ), + ) * 1_000; +} + +function normalizedSnapshotCursor( + requestedSince: number, + headRevision: number, + requestedStreamId?: string | null, + currentStreamId?: string | null, +): number { + const requested = Math.max(0, Math.trunc(requestedSince) || 0); + const head = Math.max(0, Math.trunc(headRevision) || 0); + if ( + requestedStreamId + && currentStreamId + && requestedStreamId.trim() !== currentStreamId.trim() + ) { + return 0; + } + return requested > head ? 0 : requested; +} + +function attentionFullSnapshotUnchanged( + existing: Array<{ item_id: string; source_revision: number; fingerprint: string }>, + incoming: Array<{ id: string; revision: number; fingerprint: string }>, + tombstoneCount: number, +): boolean { + if (tombstoneCount > 0 || existing.length !== incoming.length) return false; + const existingById = new Map(existing.map((item) => [item.item_id, item])); + return incoming.every((item) => { + const current = existingById.get(item.id); + return ( + current != null + && Number(current.source_revision) === item.revision + && current.fingerprint === item.fingerprint + ); + }); +} + +function mergedDevicePreferences( + device: AttentionDeviceRow, + devicePreferences: Record, +): Record { + const registered = readPreferences(device.preferences_json); + const accountOverride = isRecord(devicePreferences[device.device_id]) + ? devicePreferences[device.device_id] as Record + : {}; + return { ...registered, ...accountOverride }; +} + +function quietHoursActive( + device: Record, + account: Record, + nowMs: number, +): boolean { + const raw = isRecord(device.quietHours) + ? device.quietHours + : isRecord(account.quietHours) + ? account.quietHours + : null; + if (!raw) return false; + if (raw.enabled === false || device.quietHoursEnabled === false) return false; + + let startMinute = Number(raw.startMinute); + let endMinute = Number(raw.endMinute); + if (!Number.isInteger(startMinute) || !Number.isInteger(endMinute)) { + const parseClock = (value: unknown): number | null => { + if (typeof value !== "string") return null; + const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim()); + if (!match) return null; + const hour = Number(match[1]); + const minute = Number(match[2]); + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null; + return hour * 60 + minute; + }; + const parsedStart = parseClock(raw.start); + const parsedEnd = parseClock(raw.end); + if (parsedStart === null || parsedEnd === null) return false; + startMinute = parsedStart; + endMinute = parsedEnd; + } + if ( + startMinute < 0 + || startMinute >= 24 * 60 + || endMinute < 0 + || endMinute >= 24 * 60 + || startMinute === endMinute + ) { + return false; + } + const timeZone = requiredString(raw.timeZone ?? raw.timezone, 120) ?? "UTC"; + try { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).formatToParts(new Date(nowMs)); + const hour = Number(parts.find((part) => part.type === "hour")?.value ?? 0) % 24; + const minute = Number(parts.find((part) => part.type === "minute")?.value ?? 0); + const current = hour * 60 + minute; + return startMinute < endMinute + ? current >= startMinute && current < endMinute + : current >= startMinute || current < endMinute; + } catch { + return false; + } +} + +function itemSessionId(item: ParsedAttentionItem): string | null { + return item.destination.kind === "session" + ? requiredString(item.destination.sessionId) + : null; +} + +function stringListIncludes(value: unknown, target: string | null): boolean { + if (!target || !Array.isArray(value)) return false; + return value.some((entry) => typeof entry === "string" && entry === target); +} + +async function hasRecentForegroundDesktop( + env: AttentionRelayEnv, + userId: string, + nowMs: number, +): Promise { + const cutoff = new Date(nowMs - DESKTOP_PRESENCE_WINDOW_MS).toISOString(); + const rows = await env.DB.prepare(` + select payload_json + from attention_presence + where user_id = ? and observed_at >= ? + `).bind(userId, cutoff).all<{ payload_json: string }>(); + return rows.results.some((row) => { + const presence = readPreferences(row.payload_json); + return presence.platform === "macOS" && presence.appForeground === true; + }); +} + +async function deliverAttentionNotifications( + env: AttentionRelayEnv, + userId: string, + items: ParsedAttentionItem[], +): Promise { + const config = apnsConfig(env); + if (!config || items.length === 0) return; + const [devicesResult, preferencesRow] = await Promise.all([ + env.DB.prepare(` + select device_id, apns_token, bundle_id, aps_environment, preferences_json + from attention_devices + where user_id = ? and apns_token is not null and lease_expires_at > ? + `).bind(userId, new Date().toISOString()).all(), + env.DB + .prepare("select payload_json from attention_preferences where user_id = ? limit 1") + .bind(userId) + .first<{ payload_json: string }>(), + ]); + if (devicesResult.results.length === 0) return; + + const preferences = readPreferences(preferencesRow?.payload_json); + const accountPreferences = isRecord(preferences.account) ? preferences.account : {}; + const eventPolicies = isRecord(accountPreferences.eventPolicies) + ? accountPreferences.eventPolicies + : {}; + const devicePreferences = isRecord(preferences.devices) ? preferences.devices : {}; + const nowMs = Date.now(); + const desktopForeground = await hasRecentForegroundDesktop(env, userId, nowMs); + const desktopFirstEnabled = preferenceBoolean( + {}, + accountPreferences, + "desktopFirstEnabled", + true, + ); + const desktopFirstDelayMs = desktopEscalationDelayMs(accountPreferences); + let notificationAttempts = 0; + + for (const item of items) { + const policy = typeof eventPolicies[item.eventKind] === "string" + ? eventPolicies[item.eventKind] + : DEFAULT_NOTIFY_EVENTS.has(item.eventKind) + ? "notify" + : "ambient"; + if (policy !== "notify") continue; + const current = await env.DB + .prepare("select seen_at, dismissed_at from attention_items where user_id = ? and item_id = ? limit 1") + .bind(userId, item.id) + .first<{ seen_at: string | null; dismissed_at: string | null }>(); + if (current?.seen_at || current?.dismissed_at) continue; + // Give an active desktop/notch the first chance to surface the item. The + // machine heartbeat republishes the full snapshot every 30s; if the item + // remains unseen, the next pass escalates it to the phone. + if ( + desktopFirstEnabled + && desktopForeground + && nowMs - Date.parse(item.updatedAt) < desktopFirstDelayMs + ) { + continue; + } + + const deepLink = deepLinkForItem(item); + for (const device of devicesResult.results) { + if (notificationAttempts >= MAX_NOTIFICATION_ATTEMPTS_PER_PUBLISH) return; + if (!device.apns_token) continue; + const override = mergedDevicePreferences(device, devicePreferences); + const notificationsEnabled = typeof override.enabled === "boolean" + ? override.enabled + : preferenceBoolean(override, accountPreferences, "notificationsEnabled", true); + if ( + !notificationsEnabled + || quietHoursActive(override, accountPreferences, nowMs) + || stringListIncludes( + override.mutedSessionIds + ?? preferences.mutedSessionIds, + itemSessionId(item), + ) + ) { + continue; + } + const receiptState = `alert:${item.fingerprint.slice(0, 48)}`; + const existing = await env.DB.prepare(` + select 1 as found + from attention_delivery_receipts + where user_id = ? and item_id = ? and device_id = ? and state = ? + limit 1 + `).bind(userId, item.id, device.device_id, receiptState).first<{ found: number }>(); + if (existing?.found) continue; + + const hideDetails = preferenceBoolean(override, accountPreferences, "hideDetails", false); + const soundsEnabled = preferenceBoolean(override, accountPreferences, "soundsEnabled", false); + const body = hideDetails + ? boundedText(item.privacyPreview, MAX_PREVIEW_LENGTH) + : boundedText(item.preview, MAX_PREVIEW_LENGTH); + const result = await sendApnsPush(config, { + environment: device.aps_environment as ApnsEnvironment, + deviceToken: device.apns_token, + topic: device.bundle_id || env.APNS_DEFAULT_TOPIC?.trim() || "", + pushType: "alert", + priority: 10, + expiration: Math.floor(nowMs / 1_000) + 24 * 60 * 60, + collapseId: item.id, + payload: { + aps: { + alert: { + title: notificationTitle(item, hideDetails), + ...(body ? { body } : {}), + }, + ...(soundsEnabled ? { sound: "default" } : {}), + "thread-id": item.id, + "interruption-level": item.eventKind === "agent_needs_you" + ? "time-sensitive" + : "active", + }, + ...attentionAlertRoutingPayload(item, deepLink), + }, + }); + notificationAttempts += 1; + if (result.ok) { + await env.DB.prepare(` + insert into attention_delivery_receipts(user_id, item_id, device_id, state, delivered_at) + values (?, ?, ?, ?, ?) + on conflict(user_id, item_id, device_id, state) do nothing + `).bind( + userId, + item.id, + device.device_id, + receiptState, + new Date(nowMs).toISOString(), + ).run(); + } else if (result.tokenInvalid) { + await env.DB.prepare(` + update attention_devices + set apns_token = null, updated_at = ? + where user_id = ? and device_id = ? and apns_token = ? + `).bind( + new Date(nowMs).toISOString(), + userId, + device.device_id, + device.apns_token, + ).run(); + } + } + } +} + +function activityPriority(item: ParsedAttentionItem): number { + switch (item.phase) { + case "needs_you": return 0; + case "failed": + case "checks_failing": + case "changes_requested": return 1; + case "review_requested": + case "merge_ready": + case "blocked": return 2; + case "starting": + case "running": return 3; + case "stale": + case "open": return 4; + case "completed": + case "merged": return 5; + default: return 6; + } +} + +function activityRun(item: ParsedAttentionItem): Record | null { + if (item.kind !== "agent" || !isRecord(item.destination)) return null; + const sessionId = requiredString(item.destination.sessionId); + if (!sessionId) return null; + const actions = Array.isArray(item.actions) ? item.actions.filter(isRecord) : []; + const approval = actions.some((action) => action.kind === "approve"); + const phase = item.phase === "needs_you" || item.phase === "blocked" + ? approval ? "waiting_for_approval" : "waiting_for_input" + : item.phase; + return { + id: sessionId, + accountMachineKey: requiredString(item.machine.accountMachineKey, 128), + title: boundedText(item.title, MAX_TITLE_LENGTH) ?? "Agent run", + phase, + model: boundedText(item.model, 120), + lane: boundedText(item.laneName, 160), + detail: boundedText(item.preview, MAX_PREVIEW_LENGTH), + }; +} + +function activityPullRequest(item: ParsedAttentionItem): Record | null { + if (item.kind !== "pull_request" || !isRecord(item.destination)) return null; + const number = Number(item.destination.number); + if (!Number.isSafeInteger(number) || number <= 0) return null; + const phase = item.phase === "open" ? "opened" : item.phase; + return { + id: item.id, + accountMachineKey: requiredString(item.machine.accountMachineKey, 128), + prNumber: number, + title: boundedText(item.title, MAX_TITLE_LENGTH) ?? `Pull request #${number}`, + phase, + lane: boundedText(item.laneName, 160), + repoOwner: requiredString(item.destination.repoOwner), + repoName: requiredString(item.destination.repoName), + updatedAt: Date.parse(item.updatedAt) / 1_000, + }; +} + +async function accountActivityContentState( + env: AttentionRelayEnv, + userId: string, +): Promise<{ + contentState: Record; + fingerprint: string; + count: number; + focusTitle: string | null; +}> { + const rows = await env.DB.prepare(` + select payload_json, seen_at, dismissed_at + from attention_items + where user_id = ? + and dismissed_at is null + and (expires_at is null or expires_at > ?) + limit 512 + `).bind(userId, new Date().toISOString()).all<{ + payload_json: string; + seen_at: string | null; + dismissed_at: string | null; + }>(); + const items = rows.results.flatMap((row) => { + try { + const item = JSON.parse(row.payload_json) as ParsedAttentionItem; + if (item.phase === "closed" || item.phase === "open") return []; + if ((item.phase === "completed" || item.phase === "merged") && row.seen_at) return []; + return [item]; + } catch { + return []; + } + }).sort((left, right) => { + const priority = activityPriority(left) - activityPriority(right); + if (priority !== 0) return priority; + return Date.parse(right.updatedAt) - Date.parse(left.updatedAt); + }); + const runs = items.flatMap((item) => { + const run = activityRun(item); + return run ? [run] : []; + }).slice(0, 3); + const prs = items.flatMap((item) => { + const pr = activityPullRequest(item); + return pr ? [pr] : []; + }).slice(0, 2); + const newestUpdate = items.reduce( + (latest, item) => Math.max(latest, Date.parse(item.updatedAt)), + 0, + ); + const activeCount = items.filter((item) => + item.kind === "agent" + && ( + item.phase === "starting" + || item.phase === "running" + || item.phase === "needs_you" + || item.phase === "blocked" + )).length; + const contentState = { + updatedAt: Math.floor((newestUpdate || Date.now()) / 1_000), + activeCount, + runs, + prs, + }; + const fingerprintBuffer = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(JSON.stringify(contentState)), + ); + const fingerprint = Array.from(new Uint8Array(fingerprintBuffer), (byte) => + byte.toString(16).padStart(2, "0")).join(""); + return { + contentState, + fingerprint, + count: runs.length + prs.length, + focusTitle: boundedText(items[0]?.title, MAX_TITLE_LENGTH), + }; +} + +function privacyPreservingActivityContentState( + contentState: Record, +): Record { + const runs = Array.isArray(contentState.runs) + ? contentState.runs.filter(isRecord).map((run) => ({ + ...run, + title: "Agent activity", + model: null, + lane: null, + detail: null, + })) + : []; + const prs = Array.isArray(contentState.prs) + ? contentState.prs.filter(isRecord).map((pr) => ({ + ...pr, + title: Number.isSafeInteger(pr.prNumber) + ? `Pull request #${String(pr.prNumber)}` + : "Pull request", + lane: null, + })) + : []; + return { + ...contentState, + runs, + prs, + }; +} + +function notificationTitle(item: ParsedAttentionItem, hideDetails: boolean): string { + if (!hideDetails) return boundedText(item.title, MAX_TITLE_LENGTH) ?? "ADE needs you"; + return item.kind === "pull_request" ? "ADE pull request update" : "ADE agent update"; +} + +async function deliverAccountLiveActivity( + env: AttentionRelayEnv, + userId: string, +): Promise { + const config = apnsConfig(env); + if (!config) return; + const activityId = "agent-runs"; + const [{ contentState, fingerprint, count, focusTitle }, devicesResult, preferencesRow] = + await Promise.all([ + accountActivityContentState(env, userId), + env.DB.prepare(` + select device_id, apns_token, push_to_start_token, bundle_id, + aps_environment, preferences_json + from attention_devices + where user_id = ? and lease_expires_at > ? + `).bind(userId, new Date().toISOString()).all(), + env.DB + .prepare("select payload_json from attention_preferences where user_id = ? limit 1") + .bind(userId) + .first<{ payload_json: string }>(), + ]); + const preferences = readPreferences(preferencesRow?.payload_json); + const accountPreferences = isRecord(preferences.account) ? preferences.account : {}; + const devicePreferences = isRecord(preferences.devices) ? preferences.devices : {}; + const nowSeconds = Math.floor(Date.now() / 1_000); + + for (const device of devicesResult.results) { + const override = mergedDevicePreferences(device, devicePreferences); + const state = await env.DB.prepare(` + select started, fingerprint + from attention_activity_state + where user_id = ? and device_id = ? and activity_id = ? + limit 1 + `).bind(userId, device.device_id, activityId).first<{ + started: number; + fingerprint: string | null; + }>(); + const started = state?.started === 1; + const liveActivitiesEnabled = preferenceBoolean( + override, + accountPreferences, + "liveActivitiesEnabled", + true, + ); + const hideDetails = preferenceBoolean( + override, + accountPreferences, + "hideDetails", + false, + ); + const deviceCount = liveActivitiesEnabled ? count : 0; + const deviceContentState = liveActivitiesEnabled + ? hideDetails + ? privacyPreservingActivityContentState(contentState) + : contentState + : { + updatedAt: nowSeconds, + activeCount: 0, + runs: [], + prs: [], + }; + const deviceFingerprint = hideDetails ? `${fingerprint}:private` : fingerprint; + if (deviceCount === 0 && !started) continue; + if (deviceCount > 0 && started && state?.fingerprint === deviceFingerprint) continue; + + let event: "start" | "update" | "end"; + let deviceToken: string | null; + if (deviceCount === 0) { + event = "end"; + const token = await env.DB + .prepare("select token from attention_activity_tokens where user_id = ? and device_id = ? and activity_id = ? limit 1") + .bind(userId, device.device_id, activityId) + .first<{ token: string }>(); + deviceToken = token?.token ?? null; + } else if (!started) { + event = "start"; + deviceToken = device.push_to_start_token; + } else { + event = "update"; + const token = await env.DB + .prepare("select token from attention_activity_tokens where user_id = ? and device_id = ? and activity_id = ? limit 1") + .bind(userId, device.device_id, activityId) + .first<{ token: string }>(); + deviceToken = token?.token ?? null; + } + if (!deviceToken) { + if (event === "end") { + await env.DB + .prepare("delete from attention_activity_state where user_id = ? and device_id = ? and activity_id = ?") + .bind(userId, device.device_id, activityId) + .run(); + } + continue; + } + + const aps: Record = { + timestamp: nowSeconds, + event, + "content-state": deviceContentState, + "relevance-score": deviceCount > 0 ? 0.9 : 0, + }; + if (event !== "end") aps["stale-date"] = nowSeconds + 10 * 60; + if (event === "start") { + aps["input-push-token"] = 1; + aps["attributes-type"] = "ADEAgentRunsAttributes"; + aps.attributes = { machineName: "All machines", accountWide: true }; + aps.alert = { + title: hideDetails + ? "ADE activity started" + : deviceCount === 1 + ? focusTitle ?? "ADE activity started" + : `${deviceCount} ADE items active`, + body: "Across your signed-in machines", + }; + } + if (event === "end") aps["dismissal-date"] = nowSeconds + 60; + const result = await sendApnsPush(config, { + environment: device.aps_environment as ApnsEnvironment, + deviceToken, + topic: `${device.bundle_id}.push-type.liveactivity`, + pushType: "liveactivity", + priority: 10, + expiration: nowSeconds + 24 * 60 * 60, + collapseId: `attention:${activityId}`, + payload: { aps }, + }); + if (result.ok) { + if (event === "end") { + await Promise.all([ + env.DB + .prepare("delete from attention_activity_state where user_id = ? and device_id = ? and activity_id = ?") + .bind(userId, device.device_id, activityId) + .run(), + env.DB + .prepare("delete from attention_activity_tokens where user_id = ? and device_id = ? and activity_id = ?") + .bind(userId, device.device_id, activityId) + .run(), + ]); + } else { + await env.DB.prepare(` + insert into attention_activity_state( + user_id, device_id, activity_id, started, fingerprint, updated_at + ) values (?, ?, ?, 1, ?, ?) + on conflict(user_id, device_id, activity_id) do update set + started = 1, + fingerprint = excluded.fingerprint, + updated_at = excluded.updated_at + `).bind( + userId, + device.device_id, + activityId, + deviceFingerprint, + new Date().toISOString(), + ).run(); + } + } else if (result.tokenInvalid) { + if (event === "start") { + await env.DB + .prepare("update attention_devices set push_to_start_token = null where user_id = ? and device_id = ?") + .bind(userId, device.device_id) + .run(); + } else { + await env.DB + .prepare("delete from attention_activity_tokens where user_id = ? and device_id = ? and activity_id = ?") + .bind(userId, device.device_id, activityId) + .run(); + } + } + } +} + +function audienceIncludes(audience: JWTPayload["aud"], expected: string): boolean { + return typeof audience === "string" + ? audience === expected + : Array.isArray(audience) && audience.includes(expected); +} + +async function verifyBearerToken(request: Request, env: AttentionRelayEnv): Promise { + const match = (request.headers.get("authorization") ?? "").match(/^Bearer\s+(\S+)\s*$/i); + const token = match?.[1]; + if (!token) return null; + const configurations = [ + { + jwksUrl: env.CLERK_JWKS_URL?.trim() ?? "", + issuer: env.CLERK_ISSUER?.trim() ?? "", + oauthClientId: env.CLERK_OAUTH_CLIENT_ID?.trim() ?? "", + }, + { + jwksUrl: env.CLERK_SECONDARY_JWKS_URL?.trim() ?? "", + issuer: env.CLERK_SECONDARY_ISSUER?.trim() ?? "", + oauthClientId: env.CLERK_SECONDARY_OAUTH_CLIENT_ID?.trim() ?? "", + }, + ].filter((config) => config.jwksUrl && config.issuer && config.oauthClientId); + + for (const config of configurations) { + let jwks = remoteJwksByUrl.get(config.jwksUrl); + if (!jwks) { + jwks = createRemoteJWKSet(new URL(config.jwksUrl)); + remoteJwksByUrl.set(config.jwksUrl, jwks); + } + try { + const { payload } = await jwtVerify(token, jwks, { + issuer: config.issuer, + algorithms: ["RS256"], + clockTolerance: 5, + }); + const subject = requiredString(payload.sub); + if (!subject) continue; + // Clerk's native session tokens have no audience; their `azp` may be + // origin-based. OAuth access tokens are client-bound through `aud`/`azp`. + // Keep this byte-for-byte in policy with apps/account-directory. + if ( + payload.aud !== undefined + && !audienceIncludes(payload.aud, config.oauthClientId) + && payload.azp !== config.oauthClientId + ) { + continue; + } + // Development and production Clerk instances can emit the same opaque + // `sub` shape. Namespace the database key by verified issuer so those + // identity domains can never see or overwrite one another. + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(`${config.issuer}\0${subject}`), + ); + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0")).join(""); + } catch { + // Try the next configured issuer. + } + } + return null; +} + +async function authorizedUser( + request: Request, + env: AttentionRelayEnv, +): Promise<{ userId: string } | { response: Response }> { + const userId = await verifyBearerToken(request, env); + return userId + ? { userId } + : { response: json({ ok: false, error: "unauthorized" }, { status: 401 }) }; +} + +function parseAttentionItem(value: unknown, machineKey: string): ParsedAttentionItem | null { + if (!isRecord(value) || value.contractVersion !== 1) return null; + const id = requiredString(value.id); + const revision = Number(value.revision); + const fingerprint = requiredString(value.fingerprint); + const kind = value.kind; + const eventKind = requiredString(value.eventKind, 64); + const phase = requiredString(value.phase, 64); + const title = boundedText(value.title, MAX_TITLE_LENGTH); + const preview = boundedText(value.preview, MAX_PREVIEW_LENGTH); + const privacyPreview = boundedText(value.privacyPreview, MAX_PREVIEW_LENGTH); + const updatedAt = optionalIsoDate(value.updatedAt); + const occurredAt = optionalIsoDate(value.occurredAt); + const expiresAt = optionalIsoDate(value.expiresAt); + if ( + !id + || !Number.isSafeInteger(revision) + || revision < 0 + || !fingerprint + || (kind !== "agent" && kind !== "pull_request") + || !eventKind + || !EVENT_KINDS.has(eventKind) + || (kind === "agent" && !eventKind.startsWith("agent_")) + || (kind === "pull_request" && !eventKind.startsWith("pr_")) + || !phase + || !PHASES.has(phase) + || !title + || !preview + || !privacyPreview + || !updatedAt + || !occurredAt + || expiresAt === undefined + || !isRecord(value.machine) + || requiredString(value.machine.machineKey) !== machineKey + ) { + return null; + } + const expectedIdPrefix = kind === "agent" + ? `agent:${machineKey}:` + : `pull-request:${machineKey}:`; + if (!id.startsWith(expectedIdPrefix)) return null; + const machineName = boundedText(value.machine.name, 120); + const accountMachineKey = value.machine.accountMachineKey == null + ? null + : requiredString(value.machine.accountMachineKey, 128); + const deviceId = value.machine.deviceId == null + ? null + : requiredString(value.machine.deviceId, 128); + if ( + (accountMachineKey !== null && !/^[a-f0-9]{32,64}$/i.test(accountMachineKey)) + || (value.machine.deviceId != null && !deviceId) + ) { + return null; + } + if (!machineName || !isRecord(value.project) || !isRecord(value.destination)) return null; + + const projectId = requiredString(value.project.projectId); + const projectName = boundedText(value.project.name, 160); + const rootPath = value.project.rootPath == null + ? null + : boundedText(value.project.rootPath, 1_000); + if (!projectId || !projectName || (value.project.rootPath != null && !rootPath)) return null; + + let destination: Record; + if (kind === "agent") { + const sessionId = requiredString(value.destination.sessionId); + const itemId = value.destination.itemId == null + ? null + : requiredString(value.destination.itemId); + const eventId = value.destination.eventId == null + ? null + : requiredString(value.destination.eventId); + if ( + value.destination.kind !== "session" + || !sessionId + || (value.destination.itemId != null && !itemId) + || (value.destination.eventId != null && !eventId) + ) { + return null; + } + destination = { kind: "session", sessionId, itemId, eventId }; + } else { + const number = Number(value.destination.number); + const prId = value.destination.prId == null + ? null + : requiredString(value.destination.prId); + const repoOwner = value.destination.repoOwner == null + ? null + : requiredString(value.destination.repoOwner); + const repoName = value.destination.repoName == null + ? null + : requiredString(value.destination.repoName); + const tab = requiredString(value.destination.tab, 32); + const eventId = value.destination.eventId == null + ? null + : requiredString(value.destination.eventId); + if ( + value.destination.kind !== "pull_request" + || !Number.isSafeInteger(number) + || number <= 0 + || !tab + || !PR_TABS.has(tab) + || (value.destination.prId != null && !prId) + || (value.destination.repoOwner != null && !repoOwner) + || (value.destination.repoName != null && !repoName) + || (value.destination.eventId != null && !eventId) + ) { + return null; + } + destination = { + kind: "pull_request", + prId, + repoOwner, + repoName, + number, + tab, + eventId, + }; + } + + if (!Array.isArray(value.actions) || value.actions.length > 8) return null; + const actions = value.actions.map((rawAction) => { + if (!isRecord(rawAction)) return null; + const actionId = requiredString(rawAction.id, 64); + const actionKind = requiredString(rawAction.kind, 64); + const label = boundedText(rawAction.label, 80); + if (!actionId || !actionKind || !ACTION_KINDS.has(actionKind) || !label) return null; + let payload: Record | undefined; + if (rawAction.payload !== undefined) { + if (!isRecord(rawAction.payload) || Object.keys(rawAction.payload).length > 16) return null; + payload = {}; + for (const [rawKey, rawValue] of Object.entries(rawAction.payload)) { + const key = requiredString(rawKey, 64); + if (!key) return null; + if (typeof rawValue === "string") { + const text = boundedText(rawValue, MAX_PREVIEW_LENGTH); + if (!text) return null; + payload[key] = text; + } else if ( + rawValue === null + || typeof rawValue === "boolean" + || (typeof rawValue === "number" && Number.isFinite(rawValue)) + ) { + payload[key] = rawValue; + } else { + return null; + } + } + } + return { + id: actionId, + kind: actionKind, + label, + ...(rawAction.destructive === true ? { destructive: true } : {}), + ...(payload ? { payload } : {}), + }; + }); + if (actions.some((action) => action === null)) return null; + + const recentActivity = Array.isArray(value.recentActivity) + ? value.recentActivity + .map((entry) => boundedText(entry, MAX_PREVIEW_LENGTH)) + .filter((entry): entry is string => Boolean(entry)) + .slice(0, 5) + : undefined; + const detail = value.detail == null ? null : boundedText(value.detail, MAX_DETAIL_LENGTH); + if (value.detail != null && !detail) return null; + const planProgress = value.planProgress == null + ? null + : isRecord(value.planProgress) + ? { + completed: Number(value.planProgress.completed), + total: Number(value.planProgress.total), + current: value.planProgress.current == null + ? null + : boundedText(value.planProgress.current, MAX_PREVIEW_LENGTH), + } + : undefined; + if ( + planProgress === undefined + || ( + planProgress !== null + && ( + !Number.isSafeInteger(planProgress.completed) + || !Number.isSafeInteger(planProgress.total) + || planProgress.completed < 0 + || planProgress.total < 0 + || planProgress.total > 10_000 + || planProgress.completed > planProgress.total + || (value.planProgress != null + && isRecord(value.planProgress) + && value.planProgress.current != null + && !planProgress.current) + ) + ) + ) { + return null; + } + const laneId = value.laneId == null ? null : requiredString(value.laneId); + const laneName = value.laneName == null ? null : boundedText(value.laneName, 160); + const provider = value.provider == null ? null : boundedText(value.provider, 120); + const model = value.model == null ? null : boundedText(value.model, 160); + if ( + (value.laneId != null && !laneId) + || (value.laneName != null && !laneName) + || (value.provider != null && !provider) + || (value.model != null && !model) + ) { + return null; + } + + return { + contractVersion: 1, + id, + revision, + fingerprint, + kind, + eventKind, + phase, + title, + preview, + privacyPreview, + detail, + recentActivity, + planProgress, + laneId, + laneName, + provider, + model, + destination, + actions: actions as Array>, + updatedAt, + occurredAt, + expiresAt, + seenAt: null, + dismissedAt: null, + machine: { + machineKey, + accountMachineKey, + deviceId, + name: machineName, + online: true, + lastSeenAt: null, + }, + project: { + projectId, + name: projectName, + rootPath, + }, + } as ParsedAttentionItem; +} + +async function bumpRevision(env: AttentionRelayEnv, userId: string): Promise { + const now = new Date().toISOString(); + const row = await env.DB.prepare(` + insert into attention_revisions(user_id, revision, updated_at) + values (?, 1, ?) + on conflict(user_id) do update set + revision = attention_revisions.revision + 1, + updated_at = excluded.updated_at + returning revision + `).bind(userId, now).first<{ revision: number }>(); + return Number(row?.revision ?? 1); +} + +function attentionDeviceOwnershipDeleteStatements( + env: AttentionRelayEnv, + userId: string, + deviceId: string, +): D1PreparedStatement[] { + return [ + env.DB + .prepare("delete from attention_activity_tokens where user_id = ? and device_id = ?") + .bind(userId, deviceId), + env.DB + .prepare("delete from attention_activity_state where user_id = ? and device_id = ?") + .bind(userId, deviceId), + env.DB + .prepare("delete from attention_delivery_receipts where user_id = ? and device_id = ?") + .bind(userId, deviceId), + env.DB + .prepare("delete from attention_presence where user_id = ? and device_id = ?") + .bind(userId, deviceId), + env.DB + .prepare(` + update attention_device_ownership + set active = 0, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + where user_id = ? and device_id = ? + `) + .bind(userId, deviceId), + env.DB + .prepare("delete from attention_devices where user_id = ? and device_id = ?") + .bind(userId, deviceId), + ]; +} + +function isAttentionDeviceLimitError(error: unknown): boolean { + return error instanceof Error + && error.message.includes("attention account device limit reached"); +} + +function isStaleAttentionDeviceOwnershipError(error: unknown): boolean { + return error instanceof Error + && error.message.includes("stale attention device ownership"); +} + +function parsedOwnershipEpoch(value: unknown): number | null { + return typeof value === "number" + && Number.isSafeInteger(value) + && value > 0 + ? value + : null; +} + +async function attentionDeviceOwnershipRows( + env: AttentionRelayEnv, + deviceId: string, + apnsToken: string | null, +): Promise { + const result = apnsToken + ? await env.DB.prepare(` + select device_id, user_id, ownership_epoch, apns_token + from attention_device_ownership + where device_id = ? or apns_token = ? + `).bind(deviceId, apnsToken).all() + : await env.DB.prepare(` + select device_id, user_id, ownership_epoch, apns_token + from attention_device_ownership + where device_id = ? + `).bind(deviceId).all(); + return result.results; +} + +function staleAttentionDeviceOwnershipEpoch( + rows: AttentionDeviceOwnershipRow[], + userId: string, + ownershipEpoch: number, +): number | null { + const conflict = rows.some((row) => { + const currentEpoch = Number(row.ownership_epoch); + return currentEpoch > ownershipEpoch + || (currentEpoch === ownershipEpoch && row.user_id !== userId); + }); + if (!conflict) return null; + return rows.reduce( + (latest, row) => Math.max(latest, Number(row.ownership_epoch) || 0), + ownershipEpoch, + ); +} + +function attentionDeviceOwnershipConflict(ownershipEpoch: number): Response { + return json({ + ok: false, + error: "stale device ownership", + ownershipEpoch, + }, { status: 409 }); +} + +function attentionDeviceOwnershipUpsertStatement( + env: AttentionRelayEnv, + args: { + deviceId: string; + userId: string; + ownershipEpoch: number; + apnsToken: string | null; + active: boolean; + updatedAt: string; + }, +): D1PreparedStatement { + return env.DB.prepare(` + insert into attention_device_ownership( + device_id, user_id, ownership_epoch, apns_token, active, updated_at + ) values (?, ?, ?, ?, ?, ?) + on conflict(device_id) do update set + user_id = excluded.user_id, + ownership_epoch = excluded.ownership_epoch, + apns_token = coalesce(excluded.apns_token, attention_device_ownership.apns_token), + active = excluded.active, + updated_at = excluded.updated_at + where excluded.ownership_epoch > attention_device_ownership.ownership_epoch + or ( + excluded.ownership_epoch = attention_device_ownership.ownership_epoch + and excluded.user_id = attention_device_ownership.user_id + ) + `).bind( + args.deviceId, + args.userId, + args.ownershipEpoch, + args.apnsToken, + args.active ? 1 : 0, + args.updatedAt, + ); +} + +function releasePriorApnsOwnershipStatement( + env: AttentionRelayEnv, + args: { + deviceId: string; + userId: string; + ownershipEpoch: number; + apnsToken: string; + updatedAt: string; + }, +): D1PreparedStatement { + return env.DB.prepare(` + update attention_device_ownership + set apns_token = null, active = 0, updated_at = ? + where apns_token = ? + and device_id <> ? + and ( + ownership_epoch < ? + or (ownership_epoch = ? and user_id = ?) + ) + `).bind( + args.updatedAt, + args.apnsToken, + args.deviceId, + args.ownershipEpoch, + args.ownershipEpoch, + args.userId, + ); +} + +async function latestAttentionDeviceOwnershipEpoch( + env: AttentionRelayEnv, + deviceId: string, + apnsToken: string | null, + fallback: number, +): Promise { + const rows = await attentionDeviceOwnershipRows(env, deviceId, apnsToken); + return rows.reduce( + (latest, row) => Math.max(latest, Number(row.ownership_epoch) || 0), + fallback, + ); +} + +async function linkMachineToAccount( + env: AttentionRelayEnv, + userId: string, + machineKey: string, + machineName: string, +): Promise { + const now = new Date().toISOString(); + const previous = await env.DB + .prepare(` + select user_id, legacy_devices_imported_at + from attention_machine_links + where machine_key = ? + limit 1 + `) + .bind(machineKey) + .first<{ user_id: string; legacy_devices_imported_at: string | null }>(); + const freshOwnership = !previous || previous.user_id !== userId; + if (previous?.user_id && previous.user_id !== userId) { + const previousItems = await env.DB + .prepare(` + select item_id, source_revision + from attention_items + where machine_key = ? and user_id = ? + `) + .bind(machineKey, previous.user_id) + .all<{ item_id: string; source_revision: number }>(); + const previousRevision = previousItems.results.length > 0 + ? await bumpRevision(env, previous.user_id) + : 0; + await env.DB + .prepare("delete from attention_items where machine_key = ? and user_id = ?") + .bind(machineKey, previous.user_id) + .run(); + for (const item of previousItems.results) { + await env.DB.prepare(` + insert into attention_tombstones( + user_id, item_id, source_revision, account_revision, deleted_at + ) + values (?, ?, ?, ?, ?) + on conflict(user_id, item_id) do update set + source_revision = excluded.source_revision, + account_revision = excluded.account_revision, + deleted_at = excluded.deleted_at + `).bind( + previous.user_id, + item.item_id, + Number(item.source_revision), + previousRevision, + now, + ).run(); + } + const previousDevices = await env.DB + .prepare("select device_id from attention_devices where user_id = ? and source_machine_key = ?") + .bind(previous.user_id, machineKey) + .all<{ device_id: string }>(); + for (const device of previousDevices.results) { + await deleteAttentionDeviceOwnership(env, previous.user_id, device.device_id); + } + } + if (freshOwnership) { + // A machine returning to an account starts a fresh source stream. Remove + // only tombstones in that machine's namespace so the first full snapshot + // can revive an item even when its local revision counter restarted. + await env.DB.prepare(` + delete from attention_tombstones + where user_id = ? + and ( + item_id like ? + or item_id like ? + ) + `).bind( + userId, + `agent:${machineKey}:%`, + `pull-request:${machineKey}:%`, + ).run(); + } + await env.DB.prepare(` + insert into attention_machine_links( + machine_key, user_id, machine_name, last_seen_at, linked_at, + legacy_devices_imported_at + ) + values (?, ?, ?, ?, ?, null) + on conflict(machine_key) do update set + user_id = excluded.user_id, + machine_name = excluded.machine_name, + last_seen_at = excluded.last_seen_at + `).bind(machineKey, userId, machineName, now, now).run(); + await env.DB + .prepare("update machines set account_user_id = ? where machine_key = ?") + .bind(userId, machineKey) + .run(); + if (previous?.legacy_devices_imported_at) return; + + // Legacy machine registrations are migration input, not ongoing authority. + // Seed only currently unowned installs/tokens, at most once per machine. + const leaseExpiresAt = new Date(Date.now() + ATTENTION_DEVICE_LEASE_MS).toISOString(); + const existingDeviceCount = await env.DB + .prepare("select count(*) as count from attention_devices where user_id = ?") + .bind(userId) + .first<{ count: number }>(); + const availableSlots = Math.max( + 0, + MAX_ATTENTION_DEVICES - Number(existingDeviceCount?.count ?? 0), + ); + await env.DB.batch([ + env.DB.prepare(` + insert or ignore into attention_devices( + user_id, device_id, source_machine_key, apns_token, push_to_start_token, + bundle_id, aps_environment, platform, device_name, preferences_json, + registered_at, updated_at, lease_expires_at + ) + select ?, legacy.device_id, ?, legacy.apns_token, + legacy.push_to_start_token, legacy.bundle_id, aps_environment, platform, + device_name, '{}', registered_at, updated_at, ? + from device_registrations as legacy + where legacy.machine_key = ? + and not exists ( + select 1 + from attention_devices as owned + where owned.device_id = legacy.device_id + or ( + legacy.apns_token is not null + and owned.apns_token = legacy.apns_token + ) + ) + and not exists ( + select 1 + from attention_device_ownership as ownership + where ownership.device_id = legacy.device_id + or ( + legacy.apns_token is not null + and ownership.apns_token = legacy.apns_token + ) + ) + limit ? + `).bind(userId, machineKey, leaseExpiresAt, machineKey, availableSlots), + env.DB.prepare(` + insert or ignore into attention_device_ownership( + device_id, user_id, ownership_epoch, apns_token, active, updated_at + ) + select device.device_id, ?, 1, device.apns_token, 1, ? + from attention_devices as device + where device.user_id = ? and device.source_machine_key = ? + `).bind(userId, now, userId, machineKey), + env.DB.prepare(` + insert or ignore into attention_activity_tokens( + user_id, device_id, activity_id, token, updated_at + ) + select ?, token.device_id, token.activity_id, token.token, token.updated_at + from live_activity_tokens as token + join attention_devices as device + on device.user_id = ? + and device.device_id = token.device_id + and device.source_machine_key = ? + where token.machine_key = ? + `).bind(userId, userId, machineKey, machineKey), + env.DB.prepare(` + insert or ignore into attention_activity_state( + user_id, device_id, activity_id, started, fingerprint, updated_at + ) + select ?, token.device_id, token.activity_id, 1, null, token.updated_at + from live_activity_tokens as token + join attention_devices as device + on device.user_id = ? + and device.device_id = token.device_id + and device.source_machine_key = ? + where token.machine_key = ? + `).bind(userId, userId, machineKey, machineKey), + env.DB.prepare(` + update attention_machine_links + set legacy_devices_imported_at = ? + where machine_key = ? and user_id = ? + `).bind(now, machineKey, userId), + ]); +} + +export async function handleAttentionMachinePublish( + request: Request, + env: AttentionRelayEnv, + machineKey: string, + body: ArrayBuffer, +): Promise { + const account = await authorizedUser(request, env); + if ("response" in account) return account.response; + if (body.byteLength > MAX_BODY_BYTES) { + return json({ ok: false, error: "payload too large" }, { status: 413 }); + } + let payload: unknown; + try { + payload = JSON.parse(new TextDecoder().decode(body)); + } catch { + return json({ ok: false, error: "invalid json" }, { status: 400 }); + } + if (!isRecord(payload)) return json({ ok: false, error: "invalid payload" }, { status: 400 }); + const machineName = boundedText(payload.machineName, 120) ?? "ADE machine"; + const fullSnapshot = payload.fullSnapshot === true; + const rawItems = Array.isArray(payload.items) ? payload.items : []; + const rawTombstones = Array.isArray(payload.tombstones) ? payload.tombstones : []; + if (rawItems.length > MAX_ATTENTION_ITEMS || rawTombstones.length > MAX_ATTENTION_TOMBSTONES) { + return json({ ok: false, error: "too many changes" }, { status: 400 }); + } + const items = rawItems.map((entry) => parseAttentionItem(entry, machineKey)); + if (items.some((entry) => entry === null)) { + return json({ ok: false, error: "invalid attention item" }, { status: 400 }); + } + const parsedTombstones = rawTombstones.map((entry) => { + if (!isRecord(entry)) return null; + const id = requiredString(entry.id); + const revision = Number(entry.revision); + return id && Number.isSafeInteger(revision) && revision >= 0 ? { id, revision } : null; + }); + if (parsedTombstones.some((entry) => entry === null)) { + return json({ ok: false, error: "invalid attention tombstone" }, { status: 400 }); + } + const ownsItemId = (id: string) => + id.startsWith(`agent:${machineKey}:`) + || id.startsWith(`pull-request:${machineKey}:`); + if ((parsedTombstones as Array<{ id: string }>).some((entry) => !ownsItemId(entry.id))) { + return json({ ok: false, error: "foreign attention tombstone" }, { status: 403 }); + } + const tombstonesById = new Map( + (parsedTombstones as Array<{ id: string; revision: number }>).map((entry) => [entry.id, entry]), + ); + const publishedIds = new Set( + (items as ParsedAttentionItem[]).map((item) => item.id), + ); + if ([...tombstonesById.keys()].some((id) => publishedIds.has(id))) { + return json({ ok: false, error: "an item cannot also be removed" }, { status: 400 }); + } + let existingMachineItems: Array<{ + item_id: string; + source_revision: number; + fingerprint: string; + }> = []; + if (fullSnapshot) { + const existing = await env.DB.prepare(` + select item_id, source_revision, fingerprint + from attention_items + where user_id = ? and machine_key = ? + `).bind(account.userId, machineKey).all<{ + item_id: string; + source_revision: number; + fingerprint: string; + }>(); + existingMachineItems = existing.results; + for (const row of existing.results) { + if (!publishedIds.has(row.item_id)) { + tombstonesById.set(row.item_id, { + id: row.item_id, + revision: Math.max(Date.now(), Number(row.source_revision) || 0), + }); + } + } + } + const tombstones = [...tombstonesById.values()]; + const firstItem = items.find((entry): entry is ParsedAttentionItem => entry !== null); + await linkMachineToAccount( + env, + account.userId, + machineKey, + firstItem?.machine.name ?? machineName, + ); + if ( + fullSnapshot + && attentionFullSnapshotUnchanged( + existingMachineItems, + items as ParsedAttentionItem[], + tombstones.length, + ) + ) { + const current = await env.DB + .prepare("select revision from attention_revisions where user_id = ? limit 1") + .bind(account.userId) + .first<{ revision: number }>(); + return json({ + ok: true, + revision: Number(current?.revision ?? 0), + upserted: 0, + removed: 0, + unchanged: true, + }); + } + const accountRevision = await bumpRevision(env, account.userId); + const now = new Date().toISOString(); + + for (const item of items as ParsedAttentionItem[]) { + const tombstone = await env.DB.prepare(` + select source_revision + from attention_tombstones + where user_id = ? and item_id = ? + limit 1 + `).bind(account.userId, item.id).first<{ source_revision: number }>(); + if (tombstone && Number(tombstone.source_revision) >= item.revision) { + continue; + } + await env.DB.prepare(` + insert into attention_items( + user_id, item_id, machine_key, source_revision, account_revision, + fingerprint, event_kind, phase, payload_json, seen_at, dismissed_at, + expires_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, null, null, ?, ?) + on conflict(user_id, item_id) do update set + machine_key = excluded.machine_key, + source_revision = excluded.source_revision, + account_revision = excluded.account_revision, + fingerprint = excluded.fingerprint, + event_kind = excluded.event_kind, + phase = excluded.phase, + payload_json = excluded.payload_json, + seen_at = case + when attention_items.fingerprint = excluded.fingerprint then attention_items.seen_at + else null + end, + dismissed_at = case + when attention_items.fingerprint = excluded.fingerprint then attention_items.dismissed_at + else null + end, + expires_at = excluded.expires_at, + updated_at = excluded.updated_at + where excluded.source_revision >= attention_items.source_revision + `).bind( + account.userId, + item.id, + machineKey, + item.revision, + accountRevision, + item.fingerprint, + item.eventKind, + item.phase, + JSON.stringify(item), + item.expiresAt, + item.updatedAt, + ).run(); + await env.DB + .prepare("delete from attention_tombstones where user_id = ? and item_id = ?") + .bind(account.userId, item.id) + .run(); + } + + for (const tombstone of tombstones as Array<{ id: string; revision: number }>) { + const existing = await env.DB + .prepare("select source_revision from attention_items where user_id = ? and machine_key = ? and item_id = ? limit 1") + .bind(account.userId, machineKey, tombstone.id) + .first<{ source_revision: number }>(); + if (existing && Number(existing.source_revision) > tombstone.revision) { + continue; + } + await env.DB + .prepare("delete from attention_items where user_id = ? and machine_key = ? and item_id = ? and source_revision <= ?") + .bind(account.userId, machineKey, tombstone.id, tombstone.revision) + .run(); + await env.DB.prepare(` + insert into attention_tombstones( + user_id, item_id, source_revision, account_revision, deleted_at + ) + values (?, ?, ?, ?, ?) + on conflict(user_id, item_id) do update set + source_revision = excluded.source_revision, + account_revision = excluded.account_revision, + deleted_at = excluded.deleted_at + where excluded.source_revision >= attention_tombstones.source_revision + `).bind( + account.userId, + tombstone.id, + tombstone.revision, + accountRevision, + now, + ).run(); + } + await deliverAttentionNotifications( + env, + account.userId, + items as ParsedAttentionItem[], + ); + await deliverAccountLiveActivity(env, account.userId); + + return json({ + ok: true, + revision: accountRevision, + upserted: items.length, + removed: tombstones.length, + }); +} + +async function handleSnapshot( + env: AttentionRelayEnv, + userId: string, + url: URL, +): Promise { + const pageSize = 512; + const requestedSince = Math.max(0, Number(url.searchParams.get("since") ?? 0) || 0); + const requestedStreamId = url.searchParams.get("streamId")?.trim() || null; + const revisionRow = await env.DB + .prepare("select revision from attention_revisions where user_id = ? limit 1") + .bind(userId) + .first<{ revision: number }>(); + const headRevision = Number(revisionRow?.revision ?? 0); + // Account cursors are scoped to one verified identity. A desktop that signs + // out and into another account can legitimately present a cursor larger than + // the new account's head; treat that as a new stream and return a full page. + const since = normalizedSnapshotCursor( + requestedSince, + headRevision, + requestedStreamId, + userId, + ); + const now = Date.now(); + const itemRows = await env.DB.prepare(` + select payload_json, seen_at, dismissed_at, account_revision + from attention_items + where user_id = ? + and account_revision > ? + and (expires_at is null or expires_at > ?) + order by account_revision asc + limit ? + `).bind(userId, since, new Date(now).toISOString(), pageSize).all(); + const tombstoneRows = await env.DB.prepare(` + select item_id, source_revision, account_revision, deleted_at + from attention_tombstones + where user_id = ? and account_revision > ? + order by account_revision asc + limit ? + `).bind(userId, since, pageSize).all(); + const itemBoundary = itemRows.results.length === pageSize + ? Math.max(since, Number(itemRows.results.at(-1)?.account_revision ?? since) - 1) + : Number.POSITIVE_INFINITY; + const tombstoneBoundary = tombstoneRows.results.length === pageSize + ? Math.max(since, Number(tombstoneRows.results.at(-1)?.account_revision ?? since) - 1) + : Number.POSITIVE_INFINITY; + const boundary = Math.min(itemBoundary, tombstoneBoundary); + const responseRevision = Number.isFinite(boundary) + ? Math.min(headRevision, boundary) + : headRevision; + const responseItemRows = itemRows.results.filter( + (row) => row.account_revision <= responseRevision, + ); + const responseTombstoneRows = tombstoneRows.results.filter( + (row) => row.account_revision <= responseRevision, + ); + const links = await env.DB.prepare(` + select machine_key, machine_name, last_seen_at + from attention_machine_links + where user_id = ? + `).bind(userId).all<{ + machine_key: string; + machine_name: string | null; + last_seen_at: string; + }>(); + const presenceByMachine = new Map( + links.results.map((row) => [ + row.machine_key, + { + online: now - Date.parse(row.last_seen_at) <= ACCOUNT_MACHINE_ONLINE_WINDOW_MS, + lastSeenAt: row.last_seen_at, + }, + ]), + ); + + const items = responseItemRows.flatMap((row) => { + try { + const payload = JSON.parse(row.payload_json) as ParsedAttentionItem; + return [{ + ...payload, + machine: { + ...payload.machine, + online: presenceByMachine.get(payload.machine.machineKey)?.online ?? false, + lastSeenAt: presenceByMachine.get(payload.machine.machineKey)?.lastSeenAt ?? null, + }, + seenAt: row.seen_at, + dismissedAt: row.dismissed_at, + }]; + } catch { + return []; + } + }); + return json({ + ok: true, + contractVersion: 1, + streamId: userId, + revision: responseRevision, + generatedAt: new Date(now).toISOString(), + machines: links.results.map((row) => ({ + machineKey: row.machine_key, + name: row.machine_name?.trim() || "ADE machine", + online: now - Date.parse(row.last_seen_at) <= ACCOUNT_MACHINE_ONLINE_WINDOW_MS, + lastSeenAt: row.last_seen_at, + })), + items, + tombstones: responseTombstoneRows.map((row) => ({ + id: row.item_id, + revision: row.source_revision, + deletedAt: row.deleted_at, + })), + }); +} + +async function handleAcknowledgment( + request: Request, + env: AttentionRelayEnv, + userId: string, +): Promise { + let payload: unknown; + try { + payload = await request.json(); + } catch { + return json({ ok: false, error: "invalid json" }, { status: 400 }); + } + if (!isRecord(payload) || !Array.isArray(payload.itemIds) || payload.itemIds.length > 64) { + return json({ ok: false, error: "invalid acknowledgment" }, { status: 400 }); + } + const itemIds = payload.itemIds.map((value) => requiredString(value)); + if (itemIds.some((value) => value === null)) { + return json({ ok: false, error: "invalid item id" }, { status: 400 }); + } + const seenAt = optionalIsoDate(payload.seenAt ?? new Date().toISOString()); + const dismissedAt = optionalIsoDate(payload.dismissedAt); + if (!seenAt || dismissedAt === undefined) { + return json({ ok: false, error: "invalid timestamp" }, { status: 400 }); + } + const revision = await bumpRevision(env, userId); + for (const itemId of itemIds as string[]) { + await env.DB.prepare(` + update attention_items + set seen_at = ?, dismissed_at = coalesce(?, dismissed_at), account_revision = ? + where user_id = ? and item_id = ? + `).bind(seenAt, dismissedAt, revision, userId, itemId).run(); + } + await deliverAccountLiveActivity(env, userId); + return json({ ok: true, revision, itemIds }); +} + +async function handlePresence( + request: Request, + env: AttentionRelayEnv, + userId: string, +): Promise { + let payload: unknown; + try { + payload = await request.json(); + } catch { + return json({ ok: false, error: "invalid json" }, { status: 400 }); + } + if (!isRecord(payload)) return json({ ok: false, error: "invalid presence" }, { status: 400 }); + const deviceId = requiredString(payload.deviceId, 128); + const observedAt = optionalIsoDate(payload.observedAt ?? new Date().toISOString()); + const visibleItemIds = Array.isArray(payload.visibleItemIds) + ? payload.visibleItemIds.map((value) => requiredString(value)).filter(Boolean).slice(0, 64) + : []; + if (!deviceId || !observedAt) { + return json({ ok: false, error: "invalid presence" }, { status: 400 }); + } + const stored = { + ...payload, + deviceId, + observedAt, + visibleItemIds, + }; + await env.DB.prepare(` + insert into attention_presence(user_id, device_id, payload_json, observed_at) + values (?, ?, ?, ?) + on conflict(user_id, device_id) do update set + payload_json = excluded.payload_json, + observed_at = excluded.observed_at + `).bind(userId, deviceId, JSON.stringify(stored), observedAt).run(); + return json({ ok: true, observedAt }); +} + +async function handlePreferences( + request: Request, + env: AttentionRelayEnv, + userId: string, +): Promise { + if (request.method === "GET") { + const row = await env.DB + .prepare("select payload_json, updated_at from attention_preferences where user_id = ? limit 1") + .bind(userId) + .first<{ payload_json: string; updated_at: string }>(); + let preferences: Record | null = null; + if (row) { + try { + const parsed = JSON.parse(row.payload_json); + preferences = isRecord(parsed) ? parsed : null; + } catch { + preferences = null; + } + } + return json({ + ok: true, + preferences, + updatedAt: row?.updated_at ?? null, + }); + } + let payload: unknown; + try { + payload = await request.json(); + } catch { + return json({ ok: false, error: "invalid json" }, { status: 400 }); + } + if (!isRecord(payload)) return json({ ok: false, error: "invalid preferences" }, { status: 400 }); + const serialized = JSON.stringify(payload); + if (serialized.length > 32_000) { + return json({ ok: false, error: "preferences too large" }, { status: 413 }); + } + const updatedAt = new Date().toISOString(); + await env.DB.prepare(` + insert into attention_preferences(user_id, payload_json, updated_at) + values (?, ?, ?) + on conflict(user_id) do update set + payload_json = excluded.payload_json, + updated_at = excluded.updated_at + `).bind(userId, serialized, updatedAt).run(); + await deliverAccountLiveActivity(env, userId); + return json({ ok: true, preferences: payload, updatedAt }); +} + +async function deleteAttentionDeviceOwnership( + env: AttentionRelayEnv, + userId: string, + deviceId: string, +): Promise { + await env.DB.batch(attentionDeviceOwnershipDeleteStatements(env, userId, deviceId)); +} + +async function handleDeviceRegistration( + request: Request, + env: AttentionRelayEnv, + userId: string, + deviceId: string, +): Promise { + if (!requiredString(deviceId, 128)) { + return json({ ok: false, error: "invalid device id" }, { status: 400 }); + } + let payload: unknown; + try { + payload = await request.json(); + } catch { + return json({ ok: false, error: "invalid json" }, { status: 400 }); + } + if (!isRecord(payload)) return json({ ok: false, error: "invalid device" }, { status: 400 }); + const ownershipEpoch = parsedOwnershipEpoch(payload.ownershipEpoch); + if (ownershipEpoch === null) { + return json({ ok: false, error: "invalid ownership epoch" }, { status: 400 }); + } + const rawApnsToken = payload.apnsToken == null + ? null + : requiredString(payload.apnsToken, 512); + if ( + rawApnsToken !== null + && (!rawApnsToken || !APNS_TOKEN_PATTERN.test(rawApnsToken)) + ) { + return json({ ok: false, error: "invalid device routing" }, { status: 400 }); + } + if (request.method === "DELETE") { + const deviceOwnership = await attentionDeviceOwnershipRows(env, deviceId, null); + const registeredDevice = await env.DB.prepare(` + select apns_token + from attention_devices + where device_id = ? + limit 1 + `).bind(deviceId).first<{ apns_token: string | null }>(); + const apnsToken = deviceOwnership.find((row) => row.device_id === deviceId)?.apns_token + ?? rawApnsToken + ?? registeredDevice?.apns_token + ?? null; + const ownershipRows = apnsToken + ? await attentionDeviceOwnershipRows(env, deviceId, apnsToken) + : deviceOwnership; + const staleEpoch = staleAttentionDeviceOwnershipEpoch( + ownershipRows, + userId, + ownershipEpoch, + ); + if (staleEpoch !== null) { + return attentionDeviceOwnershipConflict(staleEpoch); + } + const conflicts = apnsToken + ? await env.DB.prepare(` + select user_id, device_id + from attention_devices + where device_id = ? or apns_token = ? + `).bind(deviceId, apnsToken).all<{ user_id: string; device_id: string }>() + : await env.DB.prepare(` + select user_id, device_id + from attention_devices + where device_id = ? + `).bind(deviceId).all<{ user_id: string; device_id: string }>(); + const now = new Date().toISOString(); + const statements: D1PreparedStatement[] = []; + if (apnsToken) { + statements.push(releasePriorApnsOwnershipStatement(env, { + deviceId, + userId, + ownershipEpoch, + apnsToken, + updatedAt: now, + })); + } + statements.push(attentionDeviceOwnershipUpsertStatement(env, { + deviceId, + userId, + ownershipEpoch, + apnsToken, + active: false, + updatedAt: now, + })); + statements.push(...conflicts.results.flatMap((conflict) => + attentionDeviceOwnershipDeleteStatements(env, conflict.user_id, conflict.device_id) + )); + try { + await env.DB.batch(statements); + } catch (error) { + if (isStaleAttentionDeviceOwnershipError(error)) { + return attentionDeviceOwnershipConflict( + await latestAttentionDeviceOwnershipEpoch( + env, + deviceId, + apnsToken, + ownershipEpoch, + ), + ); + } + throw error; + } + return json({ ok: true, deviceId, ownershipEpoch }); + } + const bundleId = requiredString(payload.bundleId, 200); + const apsEnvironment = payload.apsEnvironment; + const apnsToken = rawApnsToken; + const pushToStartToken = payload.pushToStartToken == null + ? null + : requiredString(payload.pushToStartToken, 512); + if ( + !bundleId + || (apsEnvironment !== "sandbox" && apsEnvironment !== "production") + || (apnsToken !== null && (!apnsToken || !APNS_TOKEN_PATTERN.test(apnsToken))) + || ( + pushToStartToken !== null + && (!pushToStartToken || !APNS_TOKEN_PATTERN.test(pushToStartToken)) + ) + ) { + return json({ ok: false, error: "invalid device routing" }, { status: 400 }); + } + const ownershipRows = await attentionDeviceOwnershipRows(env, deviceId, apnsToken); + const staleEpoch = staleAttentionDeviceOwnershipEpoch( + ownershipRows, + userId, + ownershipEpoch, + ); + if (staleEpoch !== null) { + return attentionDeviceOwnershipConflict(staleEpoch); + } + // A physical installation/APNs token belongs to exactly one authenticated + // account stream at a time. + const conflicts = apnsToken + ? await env.DB.prepare(` + select user_id, device_id + from attention_devices + where not (user_id = ? and device_id = ?) + and (device_id = ? or apns_token = ?) + `).bind(userId, deviceId, deviceId, apnsToken).all<{ user_id: string; device_id: string }>() + : await env.DB.prepare(` + select user_id, device_id + from attention_devices + where not (user_id = ? and device_id = ?) + and device_id = ? + `).bind(userId, deviceId, deviceId).all<{ user_id: string; device_id: string }>(); + const existingDevice = await env.DB + .prepare("select 1 as found from attention_devices where user_id = ? and device_id = ? limit 1") + .bind(userId, deviceId) + .first<{ found: number }>(); + const deviceCount = await env.DB + .prepare("select count(*) as count from attention_devices where user_id = ?") + .bind(userId) + .first<{ count: number }>(); + const destinationConflicts = new Set( + conflicts.results + .filter((conflict) => conflict.user_id === userId) + .map((conflict) => conflict.device_id), + ).size; + const projectedDeviceCount = Number(deviceCount?.count ?? 0) + - destinationConflicts + + (existingDevice?.found ? 0 : 1); + if (projectedDeviceCount > MAX_ATTENTION_DEVICES) { + return json({ ok: false, error: "account device limit reached" }, { status: 409 }); + } + const now = new Date().toISOString(); + const leaseExpiresAt = new Date(Date.now() + ATTENTION_DEVICE_LEASE_MS).toISOString(); + const preferences = isRecord(payload.preferences) ? payload.preferences : {}; + const transferStatements: D1PreparedStatement[] = []; + if (apnsToken) { + transferStatements.push(releasePriorApnsOwnershipStatement(env, { + deviceId, + userId, + ownershipEpoch, + apnsToken, + updatedAt: now, + })); + } + transferStatements.push(attentionDeviceOwnershipUpsertStatement(env, { + deviceId, + userId, + ownershipEpoch, + apnsToken, + active: true, + updatedAt: now, + })); + transferStatements.push(...conflicts.results.flatMap((conflict) => + attentionDeviceOwnershipDeleteStatements(env, conflict.user_id, conflict.device_id) + )); + transferStatements.push( + env.DB.prepare(` + insert into attention_devices( + user_id, device_id, source_machine_key, apns_token, push_to_start_token, + bundle_id, aps_environment, platform, device_name, preferences_json, + registered_at, updated_at, lease_expires_at + ) values (?, ?, null, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + on conflict(user_id, device_id) do update set + apns_token = coalesce(excluded.apns_token, attention_devices.apns_token), + push_to_start_token = coalesce(excluded.push_to_start_token, attention_devices.push_to_start_token), + bundle_id = excluded.bundle_id, + aps_environment = excluded.aps_environment, + platform = excluded.platform, + device_name = excluded.device_name, + preferences_json = excluded.preferences_json, + updated_at = excluded.updated_at, + lease_expires_at = excluded.lease_expires_at + `).bind( + userId, + deviceId, + apnsToken, + pushToStartToken, + bundleId, + apsEnvironment, + requiredString(payload.platform, 32), + boundedText(payload.deviceName, 120), + JSON.stringify(preferences), + now, + now, + leaseExpiresAt, + ), + ); + // D1 batches are transactional: a failed destination insert rolls back all + // ownership deletions, so a transfer cannot orphan the previous account. + try { + await env.DB.batch(transferStatements); + } catch (error) { + if (isAttentionDeviceLimitError(error)) { + return json({ ok: false, error: "account device limit reached" }, { status: 409 }); + } + if (isStaleAttentionDeviceOwnershipError(error)) { + return attentionDeviceOwnershipConflict( + await latestAttentionDeviceOwnershipEpoch( + env, + deviceId, + apnsToken, + ownershipEpoch, + ), + ); + } + throw error; + } + await deliverAccountLiveActivity(env, userId); + return json({ ok: true, deviceId, ownershipEpoch, updatedAt: now }); +} + +async function handleActivityTokenRegistration( + request: Request, + env: AttentionRelayEnv, + userId: string, + deviceId: string, + activityId: string, +): Promise { + if (!requiredString(deviceId, 128) || !requiredString(activityId, 128)) { + return json({ ok: false, error: "invalid activity target" }, { status: 400 }); + } + const device = await env.DB + .prepare("select 1 as found from attention_devices where user_id = ? and device_id = ? and lease_expires_at > ? limit 1") + .bind(userId, deviceId, new Date().toISOString()) + .first<{ found: number }>(); + if (!device?.found) { + return json({ ok: false, error: "attention device is not registered" }, { status: 404 }); + } + if (request.method === "DELETE") { + await Promise.all([ + env.DB + .prepare("delete from attention_activity_tokens where user_id = ? and device_id = ? and activity_id = ?") + .bind(userId, deviceId, activityId) + .run(), + env.DB + .prepare("delete from attention_activity_state where user_id = ? and device_id = ? and activity_id = ?") + .bind(userId, deviceId, activityId) + .run(), + ]); + return json({ ok: true, removed: true }); + } + let payload: unknown; + try { + payload = await request.json(); + } catch { + return json({ ok: false, error: "invalid json" }, { status: 400 }); + } + const token = isRecord(payload) ? requiredString(payload.token, 512) : null; + if (!token || !APNS_TOKEN_PATTERN.test(token)) { + return json({ ok: false, error: "invalid activity token" }, { status: 400 }); + } + await env.DB.prepare(` + insert into attention_activity_tokens(user_id, device_id, activity_id, token, updated_at) + values (?, ?, ?, ?, ?) + on conflict(user_id, device_id, activity_id) do update set + token = excluded.token, + updated_at = excluded.updated_at + `).bind(userId, deviceId, activityId, token, new Date().toISOString()).run(); + await env.DB.prepare(` + insert into attention_activity_state( + user_id, device_id, activity_id, started, fingerprint, updated_at + ) values (?, ?, ?, 1, null, ?) + on conflict(user_id, device_id, activity_id) do update set + started = 1, + updated_at = excluded.updated_at + `).bind(userId, deviceId, activityId, new Date().toISOString()).run(); + await deliverAccountLiveActivity(env, userId); + return json({ ok: true, removed: false }); +} + +async function handleAuthorizedAttentionAccountRequest( + request: Request, + env: AttentionRelayEnv, + url: URL, + userId: string, +): Promise { + const parts = url.pathname.split("/").filter(Boolean); + if (parts[0] !== "attention" || parts[1] !== "account") return null; + const route = parts.slice(2); + if (route.length === 1 && route[0] === "snapshot" && request.method === "GET") { + return await handleSnapshot(env, userId, url); + } + if (route.length === 1 && route[0] === "ack" && request.method === "POST") { + return await handleAcknowledgment(request, env, userId); + } + if (route.length === 1 && route[0] === "presence" && request.method === "POST") { + return await handlePresence(request, env, userId); + } + if (route.length === 1 && route[0] === "preferences" && (request.method === "GET" || request.method === "PUT")) { + return await handlePreferences(request, env, userId); + } + if ( + route.length === 2 + && route[0] === "devices" + && (request.method === "PUT" || request.method === "DELETE") + ) { + return await handleDeviceRegistration( + request, + env, + userId, + decodeURIComponent(route[1] ?? ""), + ); + } + if ( + route.length === 4 + && route[0] === "devices" + && route[2] === "activities" + && (request.method === "PUT" || request.method === "DELETE") + ) { + return await handleActivityTokenRegistration( + request, + env, + userId, + decodeURIComponent(route[1] ?? ""), + decodeURIComponent(route[3] ?? ""), + ); + } + return json({ ok: false, error: "not found" }, { status: 404 }); +} + +export async function handleAttentionAccountRequest( + request: Request, + env: AttentionRelayEnv, + url: URL, +): Promise { + const parts = url.pathname.split("/").filter(Boolean); + if (parts[0] !== "attention" || parts[1] !== "account") return null; + const account = await authorizedUser(request, env); + if ("response" in account) return account.response; + return await handleAuthorizedAttentionAccountRequest( + request, + env, + url, + account.userId, + ); +} + +export async function pruneAttentionState(env: AttentionRelayEnv): Promise { + const now = new Date(); + const tombstoneCutoff = new Date(now.getTime() - TOMBSTONE_RETENTION_MS).toISOString(); + const presenceCutoff = new Date(now.getTime() - 10 * 60 * 1_000).toISOString(); + const expiredDevices = await env.DB.prepare(` + select user_id, device_id + from attention_devices + where lease_expires_at <= ? + `).bind(now.toISOString()).all<{ user_id: string; device_id: string }>(); + for (const device of expiredDevices.results) { + await deleteAttentionDeviceOwnership(env, device.user_id, device.device_id); + } + await Promise.all([ + env.DB.prepare("delete from attention_items where expires_at is not null and expires_at <= ?") + .bind(now.toISOString()) + .run(), + env.DB.prepare("delete from attention_tombstones where deleted_at <= ?") + .bind(tombstoneCutoff) + .run(), + env.DB.prepare("delete from attention_presence where observed_at <= ?") + .bind(presenceCutoff) + .run(), + ]); +} + +/** Pure contract helpers exposed only so relay tests can cover trust boundaries. */ +export const attentionTestInternals = Object.freeze({ + activityPullRequest, + activityRun, + attentionAlertRoutingPayload, + attentionFullSnapshotUnchanged, + deepLinkForItem, + desktopEscalationDelayMs, + handleAuthorizedAttentionAccountRequest, + linkMachineToAccount, + notificationTitle, + normalizedSnapshotCursor, + parseAttentionItem, + privacyPreservingActivityContentState, +}); diff --git a/apps/push-relay/src/relay.ts b/apps/push-relay/src/relay.ts index 9695faff5..2d6229e54 100644 --- a/apps/push-relay/src/relay.ts +++ b/apps/push-relay/src/relay.ts @@ -5,6 +5,11 @@ import { type ApnsPushType, type ApnsSendResult, } from "./apns"; +import { + handleAttentionAccountRequest, + handleAttentionMachinePublish, + pruneAttentionState, +} from "./attention"; export type PushRelayEnv = { DB: D1Database; @@ -29,6 +34,16 @@ export type PushRelayEnv = { IP_RATE_LIMIT_PER_MIN?: string; /** `/claim` requests allowed per IP per 60s (unauthenticated-write gate). */ CLAIM_RATE_LIMIT_PER_MIN?: string; + /** Clerk JWKS endpoint used to verify account-scoped Attention bearer tokens. */ + CLERK_JWKS_URL?: string; + /** Expected Clerk issuer for account-scoped Attention bearer tokens. */ + CLERK_ISSUER?: string; + /** OAuth client id accepted as the Attention token audience/authorized party. */ + CLERK_OAUTH_CLIENT_ID?: string; + /** Optional second Clerk instance (typically development iOS builds). */ + CLERK_SECONDARY_JWKS_URL?: string; + CLERK_SECONDARY_ISSUER?: string; + CLERK_SECONDARY_OAUTH_CLIENT_ID?: string; }; type MachineRow = { @@ -633,6 +648,7 @@ export async function pruneRelayState(env: PushRelayEnv): Promise { .prepare("delete from rate_counters where bucket like 'budget:%' and updated_at < ?") .bind(budgetCutoff) .run(); + await pruneAttentionState(env); } function phaseExpiration(phase: PushPhase, nowSeconds = Math.floor(Date.now() / 1000)): number { @@ -1167,6 +1183,9 @@ export async function handleRequest(request: Request, env: PushRelayEnv): Promis return rateLimitedResponse(); } + const attentionAccountResponse = await handleAttentionAccountRequest(request, env, url); + if (attentionAccountResponse) return attentionAccountResponse; + const route = routeMachine(url.pathname); if (!route || !MACHINE_KEY_PATTERN.test(route.machineKey)) return text("not found", 404); const { machineKey, rest } = route; @@ -1201,6 +1220,15 @@ export async function handleRequest(request: Request, env: PushRelayEnv): Promis if (rest.length === 1 && rest[0] === "publish" && request.method === "POST") { return await handlePublish(request, env, machineKey); } + if (rest.length === 1 && rest[0] === "attention" && request.method === "POST") { + const body = await request.arrayBuffer(); + if (body.byteLength > MAX_BODY_BYTES) { + return json({ ok: false, error: "payload too large" }, { status: 413 }); + } + const auth = await assertMachineAuthorized(request, env, machineKey, body); + if ("response" in auth) return auth.response; + return await handleAttentionMachinePublish(request, env, machineKey, body); + } return text("not found", 404); } diff --git a/apps/push-relay/test/attention.test.ts b/apps/push-relay/test/attention.test.ts new file mode 100644 index 000000000..b273827bb --- /dev/null +++ b/apps/push-relay/test/attention.test.ts @@ -0,0 +1,1078 @@ +import { createRequire } from "node:module"; +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import { + attentionTestInternals, + pruneAttentionState, + type AttentionRelayEnv, +} from "../src/attention"; + +type NativeStatement = { + all: (...values: unknown[]) => Array>; + get: (...values: unknown[]) => Record | undefined; + run: (...values: unknown[]) => unknown; +}; + +type NativeDatabase = { + close: () => void; + exec: (sql: string) => void; + prepare: (sql: string) => NativeStatement; +}; + +const require = createRequire(import.meta.url); +const { DatabaseSync } = require("node:sqlite") as { + DatabaseSync: new (path: string) => NativeDatabase; +}; + +class SqliteD1Statement { + private values: unknown[] = []; + + constructor( + private readonly database: NativeDatabase, + private readonly sql: string, + ) {} + + bind(...values: unknown[]): this { + this.values = values; + return this; + } + + async first(): Promise { + return (this.database.prepare(this.sql).get(...this.values) ?? null) as T | null; + } + + async all(): Promise<{ results: T[] }> { + return { + results: this.database.prepare(this.sql).all(...this.values) as T[], + }; + } + + async run(): Promise<{ success: boolean }> { + this.runSync(); + return { success: true }; + } + + runSync(): void { + this.database.prepare(this.sql).run(...this.values); + } +} + +class SqliteD1Database { + readonly native = new DatabaseSync(":memory:"); + + constructor() { + for (const migration of [ + "../migrations/0001_push_registrations.sql", + "../migrations/0002_rate_and_budget.sql", + "../migrations/0003_account_attention.sql", + ]) { + this.native.exec(readFileSync(new URL(migration, import.meta.url), "utf8")); + } + } + + prepare(sql: string): SqliteD1Statement { + return new SqliteD1Statement(this.native, sql); + } + + async batch(statements: SqliteD1Statement[]): Promise> { + this.native.exec("begin immediate"); + try { + for (const statement of statements) statement.runSync(); + this.native.exec("commit"); + return statements.map(() => ({ success: true })); + } catch (error) { + this.native.exec("rollback"); + throw error; + } + } + + close(): void { + this.native.close(); + } +} + +function makeAttentionEnv(database: SqliteD1Database): AttentionRelayEnv { + return { DB: database as unknown as D1Database }; +} + +function row>( + database: SqliteD1Database, + sql: string, + ...values: unknown[] +): T | undefined { + return database.native.prepare(sql).get(...values) as T | undefined; +} + +function rows>( + database: SqliteD1Database, + sql: string, + ...values: unknown[] +): T[] { + return database.native.prepare(sql).all(...values) as T[]; +} + +async function accountRoute( + database: SqliteD1Database, + userId: string, + method: string, + path: string, + body?: unknown, +): Promise { + const request = new Request(`https://push.example${path}`, { + method, + headers: body === undefined ? undefined : { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const response = await attentionTestInternals.handleAuthorizedAttentionAccountRequest( + request, + makeAttentionEnv(database), + new URL(request.url), + userId, + ); + if (!response) throw new Error(`Attention route did not handle ${method} ${path}`); + return response; +} + +function insertAttentionDevice( + database: SqliteD1Database, + args: { + userId: string; + deviceId: string; + apnsToken?: string | null; + sourceMachineKey?: string | null; + leaseExpiresAt?: string; + ownershipEpoch?: number; + }, +): void { + const now = "2026-07-28T08:00:00.000Z"; + database.native.prepare(` + insert into attention_devices( + user_id, device_id, source_machine_key, apns_token, push_to_start_token, + bundle_id, aps_environment, platform, device_name, preferences_json, + registered_at, updated_at, lease_expires_at + ) values (?, ?, ?, ?, null, 'com.ade.ios', 'sandbox', 'iOS', null, '{}', ?, ?, ?) + `).run( + args.userId, + args.deviceId, + args.sourceMachineKey ?? null, + args.apnsToken ?? null, + now, + now, + args.leaseExpiresAt ?? "2026-09-01T08:00:00.000Z", + ); + database.native.prepare(` + insert into attention_device_ownership( + device_id, user_id, ownership_epoch, apns_token, active, updated_at + ) values (?, ?, ?, ?, 1, ?) + `).run( + args.deviceId, + args.userId, + args.ownershipEpoch ?? 1, + args.apnsToken ?? null, + now, + ); +} + +const MACHINE_KEY = "a".repeat(32); + +function validAgentItem(): Record { + return { + contractVersion: 1, + id: `agent:${MACHINE_KEY}:session-1`, + revision: 7, + fingerprint: "fingerprint-7", + kind: "agent", + eventKind: "agent_needs_you", + phase: "needs_you", + title: "Approve the migration", + preview: "The database migration is ready for review.", + privacyPreview: "An ADE agent needs your attention.", + detail: "Review the generated SQL before continuing.", + recentActivity: ["Inspected the schema", "Prepared the migration"], + planProgress: { + completed: 2, + total: 3, + current: "Waiting for approval", + }, + laneId: "lane-1", + laneName: "attention-system", + provider: "codex", + model: "gpt-5", + machine: { + machineKey: MACHINE_KEY, + accountMachineKey: "c".repeat(32), + name: "Studio", + }, + project: { + projectId: "project-1", + name: "ADE", + rootPath: "/projects/ade", + }, + destination: { + kind: "session", + sessionId: "session-1", + itemId: "approval-1", + eventId: "event-1", + }, + actions: [ + { + id: "approve", + kind: "approve", + label: "Approve", + payload: { decision: "accept" }, + }, + { + id: "open", + kind: "open", + label: "Open", + }, + ], + occurredAt: "2026-07-28T08:00:00.000Z", + updatedAt: "2026-07-28T08:00:05.000Z", + expiresAt: "2026-07-29T08:00:05.000Z", + }; +} + +describe("account Attention contract", () => { + it("accepts and normalizes a bounded machine-owned agent item", () => { + const parsed = attentionTestInternals.parseAttentionItem(validAgentItem(), MACHINE_KEY); + + expect(parsed).toMatchObject({ + id: `agent:${MACHINE_KEY}:session-1`, + kind: "agent", + eventKind: "agent_needs_you", + phase: "needs_you", + seenAt: null, + dismissedAt: null, + machine: { + machineKey: MACHINE_KEY, + name: "Studio", + online: true, + }, + destination: { + kind: "session", + sessionId: "session-1", + itemId: "approval-1", + }, + planProgress: { + completed: 2, + total: 3, + }, + }); + }); + + it("rejects cross-kind events, foreign machine ids, and invalid progress", () => { + const wrongKind = validAgentItem(); + wrongKind.eventKind = "pr_merge_ready"; + expect(attentionTestInternals.parseAttentionItem(wrongKind, MACHINE_KEY)).toBeNull(); + + const foreignId = validAgentItem(); + foreignId.id = `agent:${"b".repeat(32)}:session-1`; + expect(attentionTestInternals.parseAttentionItem(foreignId, MACHINE_KEY)).toBeNull(); + + const invalidProgress = validAgentItem(); + invalidProgress.planProgress = { completed: 4, total: 3, current: "Impossible" }; + expect(attentionTestInternals.parseAttentionItem(invalidProgress, MACHINE_KEY)).toBeNull(); + }); + + it("clamps desktop-first escalation preferences to a safe relay range", () => { + expect(attentionTestInternals.desktopEscalationDelayMs({})).toBe(30_000); + expect(attentionTestInternals.desktopEscalationDelayMs({ + desktopFirstDelaySeconds: -90, + })).toBe(0); + expect(attentionTestInternals.desktopEscalationDelayMs({ + desktopFirstDelaySeconds: 119.6, + })).toBe(120_000); + expect(attentionTestInternals.desktopEscalationDelayMs({ + desktopFirstDelaySeconds: 3_600, + })).toBe(300_000); + }); + + it("resets a cursor that belongs to a different account revision stream", () => { + expect(attentionTestInternals.normalizedSnapshotCursor(12, 40)).toBe(12); + expect(attentionTestInternals.normalizedSnapshotCursor(40, 40)).toBe(40); + expect(attentionTestInternals.normalizedSnapshotCursor(400, 2)).toBe(0); + expect( + attentionTestInternals.normalizedSnapshotCursor(12, 40, "account-a", "account-a"), + ).toBe(12); + expect( + attentionTestInternals.normalizedSnapshotCursor(12, 40, "account-a", "account-b"), + ).toBe(0); + }); + + it("does not rewrite an identical full-snapshot heartbeat", () => { + const current = [{ + item_id: `agent:${MACHINE_KEY}:session-1`, + source_revision: 7, + fingerprint: "fingerprint-7", + }]; + const incoming = [{ + id: `agent:${MACHINE_KEY}:session-1`, + revision: 7, + fingerprint: "fingerprint-7", + }]; + expect( + attentionTestInternals.attentionFullSnapshotUnchanged(current, incoming, 0), + ).toBe(true); + expect( + attentionTestInternals.attentionFullSnapshotUnchanged(current, [ + { ...incoming[0], fingerprint: "fingerprint-8" }, + ], 0), + ).toBe(false); + expect( + attentionTestInternals.attentionFullSnapshotUnchanged(current, incoming, 1), + ).toBe(false); + }); + + it("redacts lock-screen content without breaking exact PR routing metadata", () => { + const privateState = attentionTestInternals.privacyPreservingActivityContentState({ + updatedAt: 1_752_000_000, + activeCount: 2, + runs: [{ + id: "session-1", + title: "Secret customer migration", + phase: "needs_you", + model: "gpt-5", + lane: "secret-client", + detail: "Approve production access", + }], + prs: [{ + id: "pr-42", + prNumber: 42, + title: "Fix private authentication bug", + phase: "review_requested", + lane: "security", + repoOwner: "private-owner", + repoName: "private-repo", + }], + }); + + expect(privateState).toMatchObject({ + runs: [{ + id: "session-1", + title: "Agent activity", + phase: "needs_you", + model: null, + lane: null, + detail: null, + }], + prs: [{ + id: "pr-42", + prNumber: 42, + title: "Pull request #42", + phase: "review_requested", + lane: null, + repoOwner: "private-owner", + repoName: "private-repo", + }], + }); + }); + + it("redacts notification titles as well as bodies when previews are hidden", () => { + const parsed = attentionTestInternals.parseAttentionItem(validAgentItem(), MACHINE_KEY); + expect(parsed).not.toBeNull(); + if (!parsed) return; + + expect(attentionTestInternals.notificationTitle(parsed, false)).toBe( + "Approve the migration", + ); + expect(attentionTestInternals.notificationTitle(parsed, true)).toBe( + "ADE agent update", + ); + + const pullRequest = { + ...parsed, + kind: "pull_request", + } as typeof parsed; + expect(attentionTestInternals.notificationTitle(pullRequest, true)).toBe( + "ADE pull request update", + ); + }); + + it("keeps exact account-machine routing in cross-machine alerts and Live Activity rows", () => { + const firstRaw = validAgentItem(); + const first = attentionTestInternals.parseAttentionItem(firstRaw, MACHINE_KEY); + expect(first).not.toBeNull(); + if (!first) return; + + const otherMachineKey = "b".repeat(32); + const otherAccountMachineKey = "d".repeat(32); + const secondRaw = { + ...validAgentItem(), + id: `agent:${otherMachineKey}:session-2`, + machine: { + machineKey: otherMachineKey, + accountMachineKey: otherAccountMachineKey, + name: "MacBook", + }, + destination: { + kind: "session", + sessionId: "session-2", + }, + }; + const second = attentionTestInternals.parseAttentionItem( + secondRaw, + otherMachineKey, + ); + expect(second).not.toBeNull(); + if (!second) return; + + const firstDeepLink = attentionTestInternals.deepLinkForItem(first); + const secondDeepLink = attentionTestInternals.deepLinkForItem(second); + expect(firstDeepLink).toContain(`accountMachineKey=${"c".repeat(32)}`); + expect(secondDeepLink).toContain(`accountMachineKey=${otherAccountMachineKey}`); + expect( + attentionTestInternals.attentionAlertRoutingPayload(first, firstDeepLink), + ).toMatchObject({ + accountMachineKey: "c".repeat(32), + sessionId: "session-1", + deepLink: firstDeepLink, + }); + expect( + attentionTestInternals.attentionAlertRoutingPayload(second, secondDeepLink), + ).toMatchObject({ + accountMachineKey: otherAccountMachineKey, + sessionId: "session-2", + deepLink: secondDeepLink, + }); + expect(attentionTestInternals.activityRun(first)).toMatchObject({ + id: "session-1", + accountMachineKey: "c".repeat(32), + }); + + const pullRequestRaw = { + ...secondRaw, + id: `pull-request:${otherMachineKey}:private-owner:private-repo:42`, + kind: "pull_request", + eventKind: "pr_checks_failing", + phase: "checks_failing", + destination: { + kind: "pull_request", + repoOwner: "private-owner", + repoName: "private-repo", + number: 42, + tab: "checks", + }, + }; + const pullRequest = attentionTestInternals.parseAttentionItem( + pullRequestRaw, + otherMachineKey, + ); + expect(pullRequest).not.toBeNull(); + if (!pullRequest) return; + expect(attentionTestInternals.activityPullRequest(pullRequest)).toMatchObject({ + prNumber: 42, + accountMachineKey: otherAccountMachineKey, + }); + const pullRequestDeepLink = attentionTestInternals.deepLinkForItem(pullRequest); + expect(pullRequestDeepLink).toContain("tab=checks"); + expect(pullRequestDeepLink).toContain( + `accountMachineKey=${otherAccountMachineKey}`, + ); + }); + + it("requires ownershipEpoch to be a positive safe JSON integer", async () => { + const database = new SqliteD1Database(); + try { + for (const ownershipEpoch of [0, -1, 1.5, "1", Number.MAX_SAFE_INTEGER + 1]) { + const response = await accountRoute( + database, + "account-a", + "PUT", + "/attention/account/devices/phone-1", + { + ownershipEpoch, + apnsToken: "90".repeat(32), + bundleId: "com.ade.ios", + apsEnvironment: "sandbox", + }, + ); + expect(response.status).toBe(400); + } + } finally { + database.close(); + } + }); + + it("imports legacy routes once without reclaiming a phone that changed accounts", async () => { + const database = new SqliteD1Database(); + const env = makeAttentionEnv(database); + const legacyToken = "ab".repeat(32); + try { + database.native.prepare(` + insert into machines(machine_key, secret, created_at, last_seen_at) + values (?, 'relay-secret', '2026-07-28T08:00:00.000Z', '2026-07-28T08:00:00.000Z') + `).run(MACHINE_KEY); + database.native.prepare(` + insert into device_registrations( + machine_key, device_id, apns_token, push_to_start_token, bundle_id, + aps_environment, platform, device_name, registered_at, updated_at + ) values (?, 'phone-1', ?, null, 'com.ade.ios', 'sandbox', 'iOS', + 'Phone', '2026-07-28T08:00:00.000Z', '2026-07-28T08:00:00.000Z') + `).run(MACHINE_KEY, legacyToken); + database.native.prepare(` + insert into live_activity_tokens( + machine_key, device_id, activity_id, token, updated_at + ) values (?, 'phone-1', 'account-attention', ?, '2026-07-28T08:00:00.000Z') + `).run(MACHINE_KEY, "cd".repeat(32)); + + await attentionTestInternals.linkMachineToAccount( + env, + "account-a", + MACHINE_KEY, + "Studio", + ); + expect(row(database, ` + select source_machine_key + from attention_devices + where user_id = 'account-a' and device_id = 'phone-1' + `)?.source_machine_key).toBe(MACHINE_KEY); + expect(row(database, ` + select legacy_devices_imported_at + from attention_machine_links + where machine_key = ? + `, MACHINE_KEY)?.legacy_devices_imported_at).toEqual(expect.any(String)); + expect(rows(database, ` + select activity_id + from attention_activity_tokens + where user_id = 'account-a' and device_id = 'phone-1' + `)).toHaveLength(1); + + const transfer = await accountRoute( + database, + "account-b", + "PUT", + "/attention/account/devices/phone-1", + { + ownershipEpoch: 2, + apnsToken: legacyToken, + bundleId: "com.ade.ios", + apsEnvironment: "sandbox", + platform: "iOS", + }, + ); + expect(transfer.status).toBe(200); + + // A later machine heartbeat cannot use stale machine pairing data to + // transfer the phone or its Live Activity route back to account A. + await attentionTestInternals.linkMachineToAccount( + env, + "account-a", + MACHINE_KEY, + "Studio", + ); + expect(row(database, ` + select user_id + from attention_devices + where device_id = 'phone-1' + `)?.user_id).toBe("account-b"); + expect(rows(database, ` + select * + from attention_activity_tokens + where user_id = 'account-a' and device_id = 'phone-1' + `)).toHaveLength(0); + } finally { + database.close(); + } + }); + + it("preserves source revisions on relink tombstones and permits a fresh-owner revival", async () => { + const database = new SqliteD1Database(); + const env = makeAttentionEnv(database); + const itemId = `agent:${MACHINE_KEY}:session-1`; + try { + database.native.prepare(` + insert into machines(machine_key, secret, created_at, last_seen_at) + values (?, 'relay-secret', '2026-07-28T08:00:00.000Z', '2026-07-28T08:00:00.000Z') + `).run(MACHINE_KEY); + await attentionTestInternals.linkMachineToAccount( + env, + "account-a", + MACHINE_KEY, + "Studio", + ); + database.native.prepare(` + insert into attention_revisions(user_id, revision, updated_at) + values ('account-a', 4, '2026-07-28T08:00:00.000Z') + `).run(); + database.native.prepare(` + insert into attention_items( + user_id, item_id, machine_key, source_revision, account_revision, + fingerprint, event_kind, phase, payload_json, seen_at, dismissed_at, + expires_at, updated_at + ) values ( + 'account-a', ?, ?, 7, 4, 'fingerprint-7', 'agent_running', + 'running', '{}', null, null, null, '2026-07-28T08:00:00.000Z' + ) + `).run(itemId, MACHINE_KEY); + + await attentionTestInternals.linkMachineToAccount( + env, + "account-b", + MACHINE_KEY, + "Studio", + ); + expect(row(database, ` + select source_revision + from attention_tombstones + where user_id = 'account-a' and item_id = ? + `, itemId)?.source_revision).toBe(7); + + await attentionTestInternals.linkMachineToAccount( + env, + "account-a", + MACHINE_KEY, + "Studio", + ); + expect(row(database, ` + select source_revision + from attention_tombstones + where user_id = 'account-a' and item_id = ? + `, itemId)).toBeUndefined(); + } finally { + database.close(); + } + }); + + it("checks destination quota before an atomic ownership transfer and renews the lease", async () => { + const database = new SqliteD1Database(); + const transferToken = "ef".repeat(32); + try { + for (let index = 0; index < 32; index += 1) { + insertAttentionDevice(database, { + userId: "account-b", + deviceId: `existing-${index}`, + }); + } + insertAttentionDevice(database, { + userId: "account-a", + deviceId: "phone-transfer", + apnsToken: transferToken, + }); + database.native.prepare(` + insert into attention_presence(user_id, device_id, payload_json, observed_at) + values ('account-a', 'phone-transfer', '{}', '2026-07-28T08:00:00.000Z') + `).run(); + + const rejected = await accountRoute( + database, + "account-b", + "PUT", + "/attention/account/devices/phone-transfer", + { + ownershipEpoch: 2, + apnsToken: transferToken, + bundleId: "com.ade.ios", + apsEnvironment: "sandbox", + }, + ); + expect(rejected.status).toBe(409); + expect(row(database, ` + select user_id + from attention_devices + where device_id = 'phone-transfer' + `)?.user_id).toBe("account-a"); + expect(rows(database, ` + select device_id from attention_devices where user_id = 'account-b' + `)).toHaveLength(32); + expect(rows(database, ` + select device_id + from attention_presence + where user_id = 'account-a' and device_id = 'phone-transfer' + `)).toHaveLength(1); + + database.native.prepare(` + delete from attention_devices + where user_id = 'account-b' and device_id = 'existing-31' + `).run(); + const transferred = await accountRoute( + database, + "account-b", + "PUT", + "/attention/account/devices/phone-transfer", + { + ownershipEpoch: 2, + apnsToken: transferToken, + bundleId: "com.ade.ios", + apsEnvironment: "sandbox", + }, + ); + expect(transferred.status).toBe(200); + const transferredRow = row<{ + user_id: string; + lease_expires_at: string; + }>(database, ` + select user_id, lease_expires_at + from attention_devices + where device_id = 'phone-transfer' + `); + expect(transferredRow?.user_id).toBe("account-b"); + expect(Date.parse(transferredRow?.lease_expires_at ?? "")).toBeGreaterThan( + Date.now() + 29 * 24 * 60 * 60 * 1_000, + ); + expect(rows(database, ` + select device_id + from attention_presence + where user_id = 'account-a' and device_id = 'phone-transfer' + `)).toHaveLength(0); + } finally { + database.close(); + } + }); + + it("rolls back the previous owner when the destination insert fails", async () => { + const database = new SqliteD1Database(); + const transferToken = "12".repeat(32); + try { + insertAttentionDevice(database, { + userId: "account-a", + deviceId: "phone-transfer", + apnsToken: transferToken, + }); + database.native.prepare(` + insert into attention_activity_tokens( + user_id, device_id, activity_id, token, updated_at + ) values ( + 'account-a', 'phone-transfer', 'account-attention', ?, + '2026-07-28T08:00:00.000Z' + ) + `).run("34".repeat(32)); + database.native.exec(` + create trigger reject_account_b_device + before insert on attention_devices + when new.user_id = 'account-b' + begin + select raise(abort, 'simulated destination failure'); + end + `); + + await expect(accountRoute( + database, + "account-b", + "PUT", + "/attention/account/devices/phone-transfer", + { + ownershipEpoch: 2, + apnsToken: transferToken, + bundleId: "com.ade.ios", + apsEnvironment: "sandbox", + }, + )).rejects.toThrow("simulated destination failure"); + expect(row(database, ` + select user_id + from attention_devices + where device_id = 'phone-transfer' + `)?.user_id).toBe("account-a"); + expect(rows(database, ` + select activity_id + from attention_activity_tokens + where user_id = 'account-a' and device_id = 'phone-transfer' + `)).toHaveLength(1); + } finally { + database.close(); + } + }); + + it("rolls back a transfer if the destination fills after the quota precheck", async () => { + const database = new SqliteD1Database(); + const transferToken = "56".repeat(32); + try { + for (let index = 0; index < 31; index += 1) { + insertAttentionDevice(database, { + userId: "account-b", + deviceId: `existing-${index}`, + }); + } + insertAttentionDevice(database, { + userId: "account-a", + deviceId: "phone-transfer", + apnsToken: transferToken, + }); + const originalBatch = database.batch.bind(database); + let injectedConcurrentRegistration = false; + database.batch = async (statements) => { + if (!injectedConcurrentRegistration) { + injectedConcurrentRegistration = true; + insertAttentionDevice(database, { + userId: "account-b", + deviceId: "concurrent-phone", + }); + } + return await originalBatch(statements); + }; + + const response = await accountRoute( + database, + "account-b", + "PUT", + "/attention/account/devices/phone-transfer", + { + ownershipEpoch: 2, + apnsToken: transferToken, + bundleId: "com.ade.ios", + apsEnvironment: "sandbox", + }, + ); + + expect(response.status).toBe(409); + expect(row(database, ` + select user_id + from attention_devices + where device_id = 'phone-transfer' + `)?.user_id).toBe("account-a"); + expect(rows(database, ` + select device_id from attention_devices where user_id = 'account-b' + `)).toHaveLength(32); + expect(row(database, ` + select user_id, ownership_epoch + from attention_device_ownership + where device_id = 'phone-transfer' + `)).toMatchObject({ + user_id: "account-a", + ownership_epoch: 1, + }); + } finally { + database.close(); + } + }); + + it("atomically rejects an ownership switch superseded during its precheck", async () => { + const database = new SqliteD1Database(); + const apnsToken = "67".repeat(32); + const registration = { + apnsToken, + bundleId: "com.ade.ios", + apsEnvironment: "sandbox", + }; + try { + const firstOwner = await accountRoute( + database, + "account-a", + "PUT", + "/attention/account/devices/phone-1", + { ...registration, ownershipEpoch: 1 }, + ); + expect(firstOwner.status).toBe(200); + + const originalBatch = database.batch.bind(database); + let injectedNewerOwner = false; + database.batch = async (statements) => { + if (!injectedNewerOwner) { + injectedNewerOwner = true; + database.batch = originalBatch; + const newer = await accountRoute( + database, + "account-c", + "PUT", + "/attention/account/devices/phone-1", + { ...registration, ownershipEpoch: 3 }, + ); + expect(newer.status).toBe(200); + } + return await originalBatch(statements); + }; + + const superseded = await accountRoute( + database, + "account-b", + "PUT", + "/attention/account/devices/phone-1", + { ...registration, ownershipEpoch: 2 }, + ); + expect(superseded.status).toBe(409); + expect(await superseded.json()).toEqual({ + ok: false, + error: "stale device ownership", + ownershipEpoch: 3, + }); + expect(row(database, ` + select user_id + from attention_devices + where device_id = 'phone-1' + `)?.user_id).toBe("account-c"); + expect(row(database, ` + select user_id, ownership_epoch, active + from attention_device_ownership + where device_id = 'phone-1' + `)).toMatchObject({ + user_id: "account-c", + ownership_epoch: 3, + active: 1, + }); + } finally { + database.close(); + } + }); + + it("rejects delayed old-account PUT and DELETE after switch, deletion, and revival", async () => { + const database = new SqliteD1Database(); + const apnsToken = "78".repeat(32); + const registration = { + apnsToken, + bundleId: "com.ade.ios", + apsEnvironment: "sandbox", + }; + try { + const firstOwner = await accountRoute( + database, + "account-a", + "PUT", + "/attention/account/devices/phone-1", + { ...registration, ownershipEpoch: 1 }, + ); + expect(firstOwner.status).toBe(200); + + const equalEpochOtherOwner = await accountRoute( + database, + "account-b", + "PUT", + "/attention/account/devices/phone-1", + { ...registration, ownershipEpoch: 1 }, + ); + expect(equalEpochOtherOwner.status).toBe(409); + expect(await equalEpochOtherOwner.json()).toEqual({ + ok: false, + error: "stale device ownership", + ownershipEpoch: 1, + }); + + const switched = await accountRoute( + database, + "account-b", + "PUT", + "/attention/account/devices/phone-1", + { ...registration, ownershipEpoch: 2 }, + ); + expect(switched.status).toBe(200); + + const removed = await accountRoute( + database, + "account-b", + "DELETE", + "/attention/account/devices/phone-1", + { ownershipEpoch: 2, apnsToken }, + ); + expect(removed.status).toBe(200); + expect(row(database, ` + select user_id, ownership_epoch, active + from attention_device_ownership + where device_id = 'phone-1' + `)).toMatchObject({ + user_id: "account-b", + ownership_epoch: 2, + active: 0, + }); + expect(rows(database, ` + select device_id from attention_devices where device_id = 'phone-1' + `)).toHaveLength(0); + + const revived = await accountRoute( + database, + "account-b", + "PUT", + "/attention/account/devices/phone-1", + { ...registration, ownershipEpoch: 2 }, + ); + expect(revived.status).toBe(200); + + const delayedPut = await accountRoute( + database, + "account-a", + "PUT", + "/attention/account/devices/phone-1", + { ...registration, ownershipEpoch: 1 }, + ); + expect(delayedPut.status).toBe(409); + expect(await delayedPut.json()).toEqual({ + ok: false, + error: "stale device ownership", + ownershipEpoch: 2, + }); + + // A changed local install id still cannot reclaim a newer APNs route. + const delayedRoutePut = await accountRoute( + database, + "account-a", + "PUT", + "/attention/account/devices/reinstalled-phone", + { ...registration, ownershipEpoch: 1 }, + ); + expect(delayedRoutePut.status).toBe(409); + expect((await delayedRoutePut.json()) as Record).toMatchObject({ + ownershipEpoch: 2, + }); + + const staleDelete = await accountRoute( + database, + "account-a", + "DELETE", + "/attention/account/devices/phone-1", + { ownershipEpoch: 1, apnsToken }, + ); + expect(staleDelete.status).toBe(409); + expect((await staleDelete.json()) as Record).toMatchObject({ + ownershipEpoch: 2, + }); + expect(row(database, ` + select user_id + from attention_devices + where device_id = 'phone-1' + `)?.user_id).toBe("account-b"); + } finally { + database.close(); + } + }); + + it("prunes an expired lease and all device-owned account state", async () => { + const database = new SqliteD1Database(); + try { + insertAttentionDevice(database, { + userId: "account-a", + deviceId: "expired-phone", + leaseExpiresAt: "2026-01-01T00:00:00.000Z", + }); + database.native.prepare(` + insert into attention_presence(user_id, device_id, payload_json, observed_at) + values ('account-a', 'expired-phone', '{}', '2026-07-28T08:00:00.000Z') + `).run(); + database.native.prepare(` + insert into attention_activity_state( + user_id, device_id, activity_id, started, fingerprint, updated_at + ) values ( + 'account-a', 'expired-phone', 'account-attention', 1, null, + '2026-07-28T08:00:00.000Z' + ) + `).run(); + + await pruneAttentionState(makeAttentionEnv(database)); + + expect(rows(database, ` + select device_id + from attention_devices + where user_id = 'account-a' and device_id = 'expired-phone' + `)).toHaveLength(0); + expect(rows(database, ` + select device_id + from attention_presence + where user_id = 'account-a' and device_id = 'expired-phone' + `)).toHaveLength(0); + expect(rows(database, ` + select device_id + from attention_activity_state + where user_id = 'account-a' and device_id = 'expired-phone' + `)).toHaveLength(0); + expect(row(database, ` + select ownership_epoch, active + from attention_device_ownership + where device_id = 'expired-phone' + `)).toMatchObject({ + ownership_epoch: 1, + active: 0, + }); + } finally { + database.close(); + } + }); +}); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0f5ca1610..35702ec2d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -255,8 +255,8 @@ Native SwiftUI app acting as a controller. It pairs with an ADE machine over Web - CRDT: pure-SQL CRR emulation layer (trigger-based change tracking) since iOS blocks `sqlite3_load_extension()`/`sqlite3_auto_extension()`. Changesets are wire-compatible with desktop cr-sqlite. - Core services: `Database.swift`, `SyncService.swift`, `KeychainService.swift`, `DpopKeyService.swift` (Secure Enclave P-256 pairing proof), `PushNotificationService.swift`, `LiveActivityService.swift`, and `ProductAnalytics.swift` (affirmative-consent, content-free native analytics with an independent identity and 20-event daily ceiling). - Shipped project tabs: Lanes, Files, Work, PRs, CTO, Settings (including a Push delivery panel). The projectless Chats surface is entered only from the Hub, outside the project tab bar. It uses runtime-scoped commands and the same chat event union/Work transcript renderer while suppressing lane/project actions. The Work chat decodes the same chat event union as desktop for live transcripts, including scheduled-work updates and transcript retractions; scheduled work appears in a native Chat Info popup/sheet while the phone remains a controller only. Durable active rows expose Cancel and the Schedule header exposes per-chat Pause/Resume when the host advertises those actions. Project chats use `chat.cancelScheduledWork` / `chat.setScheduledWorkPaused`; Hub personal chats map the same UI to runtime-scoped `personalChats.*` actions. The host also advertises non-queueable schedule creation, but iOS does not render a create control. Native clients gate every implemented control on its descriptor, so transport availability does not make an older brain accept unsupported mutations. -- Shipped widgets: a Lock Screen widget for prioritized agent/PR/sync/offline/idle status, plus an ActivityKit Live Activity + Dynamic Island for active agent runs (`ADEWidgets/ADEAgentActivityWidget.swift`). -- Push: APNs alert pushes (deep-linked) and Live Activity updates arrive via the Cloudflare push relay (§2.7); the phone hands tokens/prefs to the brain over the paired sync WebSocket. +- Shipped attention surfaces: a global account-wide Attention Center, project-scoped lenses over the same model, a Lock Screen widget, and one account-wide ActivityKit Live Activity + Dynamic Island prioritized across signed-in machines/projects (`ADEWidgets/ADEAgentActivityWidget.swift`). +- Push: signed-in clients exchange account Attention snapshots/ACKs/presence/preferences and register APNs/Live-Activity tokens directly with the Cloudflare push relay (§2.7). Account device PUT/DELETE mutations carry a persisted monotonic `ownershipEpoch`; direct account switches commit old → unowned → new epochs, and the relay retains deletion tombstones so delayed requests cannot reclaim an installation. Legacy paired-machine registration remains for older clients. Alert pushes and every widget/Live-Activity row carry exact destinations with the source `accountMachineKey`; remote account items select/adopt that machine before navigation and never execute current-host-only intents. - Connection: ADE account sign-in is the primary PIN-less path; direct pairing uses a user-set 6-digit PIN after scanning the v3 smart-URL QR or choosing a Nearby machine. Both paths produce device-bound DPoP trust and reconnect in LAN → Tailscale → Relay order. Sign-out disables account discovery and Relay but retains direct machine trust until the user explicitly forgets that machine. - Planned: Automations, Graph, History tabs; iPad layout; Spotlight. - Target: iOS 26+, iPhone + iPad. @@ -298,7 +298,7 @@ The `/open` route is the HTTPS half of the ADE deeplink scheme (`https://ade-app Four independent Cloudflare Workers, each its own npm package / lockfile / `wrangler.jsonc` with its own trust model. None is a runtime dependency of the desktop app; the brain talks to them over HTTPS/WebSocket. -- **`apps/push-relay/`** — fans ADE agent-state transitions out to iPhones as APNs alert pushes and Live Activity updates (Worker + a single D1 database; free-plan compatible, no Durable Objects). The brain is the only publisher: it claims an unguessable 32–64-hex `machineKey` with a relay secret (`POST /machines/:key/claim`, first-writer-wins) and HMAC-signs every later call (`x-ade-push-signature: sha256=HMAC(secret, "...")`). It stores only device tokens and in-flight notification payloads — no chat/PR content. APNs auth is an ES256 provider JWT from the `.p8` (wrangler secrets `APNS_KEY` / `APNS_KEY_ID` / `APNS_TEAM_ID`). Brain-side publisher lives at `apps/ade-cli/src/services/push/`. See [features/sync-and-multi-device/push-notifications.md](./features/sync-and-multi-device/push-notifications.md). +- **`apps/push-relay/`** — merges the bounded ADE Attention snapshots published by every signed-in brain, exposes an incremental account snapshot/ACK/presence/preferences/device API to desktop and iOS, and fans policy-selected events out as APNs alerts plus one prioritized account-wide Live Activity (Worker + one D1 database; free-plan compatible, no Durable Objects). A machine publish requires both its existing HMAC signature and a verified Clerk account token; account routes require a verified Clerk bearer token, and the D1 user key is namespaced by verified issuer so development and production identity domains cannot collide. The relay stores bounded attention previews/destinations/acknowledgments in addition to device tokens and delivery receipts; it does not store chat transcripts or diff contents. APNs auth is an ES256 provider JWT from the `.p8` (wrangler secrets `APNS_KEY` / `APNS_KEY_ID` / `APNS_TEAM_ID`). Brain-side publisher lives at `apps/ade-cli/src/services/push/`; desktop also launches a native AppKit/SwiftUI ADE Notch helper which consumes the renderer's account snapshot through typed IPC instead of polling the relay independently. See [features/sync-and-multi-device/push-notifications.md](./features/sync-and-multi-device/push-notifications.md). - **`apps/tunnel-relay/`** — pipes ADE **sync** WebSocket frames between a controller and a brain when there is no direct LAN/Tailscale path (Worker + Durable Object with SQLite storage, one instance per `machineKey`, WebSocket Hibernation API). The brain holds a persistent HMAC-signed outbound control socket while the machine has a valid ADE account session; a controller dials `/connect/:machineKey`; the DO pairs it with a dedicated brain-side pipe socket and passes bytes through 1:1 with no frame wrapping, so the normal ADE hello / pairing / DPoP handshake is unchanged. Native 30-second ping / 10-second pong transport liveness is the primary keepalive; because a hibernated or wedged DO can leave the edge answering those transport pings after the machine's control registration is dead, the brain adds a low-frequency application-level `{t:"ping"}`/`{t:"pong"}` keepalive (180 s interval, 30 s deadline) to catch such "zombie" controls, and verifies the path end-to-end with a self-probe (`syncRelaySelfProbe`) that dials `/connect/:machineKey?ready=2` like a real controller. The account directory advertises a `relay` endpoint only after that self-probe round-trips (honest relay publication); an at-capacity `4503` close is treated as liveness proof, not failure. Failed bridge opens are rejected explicitly; application close codes and bounded sanitized reasons survive the phone/pipe/local boundaries. Early controller frames are bounded by both 64 frames and 256 KiB, and idle-sweep alarms run only while a client or pipe exists. Brain-side client is `apps/ade-cli/src/services/sync/syncTunnelClientService.ts`, shared one-per-machine and handed the shared sync listener by `attachHostListener()` from whichever runtime actually owns that listener (which is often not the runtime that constructed the client). There is no user relay toggle: sign-in starts and advertises Relay, while sign-out closes it. It remains the lowest-priority `relay` address candidate after LAN and Tailscale. TLS terminates at the Worker, so this is a trusted-operator plaintext path rather than end-to-end encryption; relay payload E2E encryption is planned security work. - **`apps/account-directory/`** — Clerk-authenticated machine directory and OAuth device-authorization bridge (Worker + D1). The machine brain publishes a health-filtered registration through `accountMachinePublisherService.ts`: a 30-second heartbeat keeps the row inside the Worker's 90-second online window, while sign-in and publish-relevant relay-route changes trigger coalesced immediate writes and reset the heartbeat deadline. The Worker scopes rows by Clerk `sub`, selects at most the 500 most recently seen machines, then returns online-first order. Machine-list responses expose separate auth and D1 durations through `Server-Timing`, including auth failures. Authentication failures return only fixed classifications such as `token expired`, `invalid issuer`, and `invalid audience`; directory clients consume at most 512 response bytes before exposing the short reason in machine-list results and publisher health. Clients attach `X-ADE-Correlation-ID`; the Worker reflects and CORS-exposes it and logs it with route, method, status, and duration so a connection attempt can be followed without recording account tokens or full endpoint URLs. Desktop, ADE Code, hosted web, and iOS use the compiled HTTPS Worker origin by default. Headless login binds each short-lived device code to a daemon secret, uses Clerk OAuth + PKCE in any browser, and atomically burns the approved token pair on redemption. Each published row also carries the machine's long-lived Ed25519 identity as `pubkey`; a same-account desktop/iOS client verifies that key during the sealed `ade-adopt-v1` handshake to adopt a machine over a direct LAN/Tailscale route (LAN → Tailscale → Relay fallback) without exposing the account bearer in plaintext — see [features/sync-and-multi-device/README.md](./features/sync-and-multi-device/README.md). - **`apps/webhook-relay/`** — the pre-existing GitHub webhook relay (different trust model and lifecycle again). See its own docs. diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 711c34765..742bf9c98 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -180,12 +180,14 @@ apps/ios/ │ ├── Services/ │ │ ├── AccountService.swift # Clerk-backed optional account identity, │ │ │ # transferable social auth outcomes, -│ │ │ # durable sign-out boundary, and exact -│ │ │ # pairing generations +│ │ │ # durable sign-out/device-ownership epochs, +│ │ │ # serialized account push registration, +│ │ │ # and exact pairing generations │ │ ├── AccountEmailAuthFlow.swift # identifier-first email sign-in-or-up: │ │ │ # precise account-not-found fallback and │ │ │ # matching attempt verification -│ │ ├── AccountDirectory.swift # account machine directory client +│ │ ├── AccountDirectory.swift # account machine directory + Attention +│ │ │ # relay clients │ │ ├── Database.swift # SQLite + pure-SQL CRR + offline caches │ │ ├── KeychainService.swift # per-host pairing secrets, stable device │ │ │ # identity, and SSH credential storage @@ -203,7 +205,7 @@ apps/ios/ │ │ ├── PushNotificationService.swift # APNs registration, alert/deep-link │ │ │ # handling, prefs, push.getStatus │ │ ├── LiveActivityService.swift # ActivityKit start/update/end for the -│ │ │ # aggregate "agent runs" activity +│ │ │ # account-wide "agent runs" activity │ │ ├── MobileUsageQuotaStore.swift # host-scoped cached Claude/Codex │ │ │ # quota snapshot + refresh state │ │ ├── SyncRecoveryPolicy.swift # deterministic reconnect, path-change, @@ -235,8 +237,8 @@ apps/ios/ │ │ ├── ADESharedContainer.swift # App Group UserDefaults + WorkspaceSnapshot helpers │ │ ├── ADESharedModels.swift # AgentSnapshot, PrSnapshot — shared with widgets │ │ ├── ADESharedTheme.swift # Provider color/icon table mirrored from desktop -│ │ ├── ADEAgentActivityAttributes.swift # ActivityKit attributes + -│ │ │ # ContentState (shared app ↔ widget) +│ │ ├── ADEAgentActivityAttributes.swift # account-wide ActivityKit +│ │ │ # content-state + exact machine links │ │ └── AttentionActionIntents.swift # widget actions for approve/deny/restart/retry │ ├── Views/ │ │ ├── Account/ # account choice/sign-in plus the mobile @@ -1044,40 +1046,50 @@ pipeline (Cloudflare relay, brain publisher, APNs, content-state contract) is documented in [`push-notifications.md`](./push-notifications.md); the phone-side pieces: -- **Registration is post-pairing only.** The app requests notification - authorization and registers for remote notifications after a machine - is paired, never on first launch. `ADEAppDelegate` receives the APNs - device token and hands it to `PushNotificationService`, which sends - `push.registerDevice` / `push.setPrefs` / - `push.reportLiveActivityToken` over the paired sync WebSocket - (runtime-scoped commands the brain forwards to the relay). Every - foreground transition re-reports tokens and ends orphaned activities; - unpair/forget sends `push.unregisterDevice` and ends local activities. +- **Account registration does not require pairing.** After notification + authorization, a signed-in phone registers its APNs and push-to-start tokens + directly with the account Attention relay. `AccountService` serializes + account device PUTs, coalesces queued token/preferences refreshes, and sends a + persisted monotonic `ownershipEpoch` on every device PUT/DELETE. Sign-out + commits an unowned epoch before revocation; a direct account switch commits + `account A → unowned → account B`, so a delayed old-account request cannot + reclaim the install. Relay `409` means a newer boundary already won and is + not retried. The older `push.registerDevice` / `push.setPrefs` / + `push.reportLiveActivityToken` commands remain as a paired-machine + compatibility path. - **Alert payloads deep-link.** A default tap carries a top-level - `deepLink` (`ade://session/`, `ade://pr/`) routed through - `DeepLinkRouter.handleNotificationUserInfo`. -- **Approval alerts are actionable.** `ADEAppDelegate` registers the + `deepLink` routed through `DeepLinkRouter.handleNotificationUserInfo`. + Account alerts preserve `accountMachineKey` plus exact session + item/event or PR-tab anchors, and the app adopts/selects that machine before + opening the destination. +- **Approval alerts are actionable only against the owning host.** + `ADEAppDelegate` registers the `ADE_APPROVAL` notification category so approval pushes (stamped `aps.category = "ADE_APPROVAL"` plus top-level `sessionId` / `itemId` by the brain) show inline Approve / Deny. `didReceive response` maps the `ADE_APPROVE` / `ADE_DENY` action ids to `ADEIntentCommandRegistry` (`chat.approve`), so approvals resolve from the lock screen without opening the app. The `waiting_for_approval` Live Activity row carries the - same buttons via `ApproveSessionIntent` / `DenySessionIntent`, which are + same buttons only for a current-host activity via + `ApproveSessionIntent` / `DenySessionIntent`, which are `LiveActivityIntent`s (so they run in-app, not the widget extension); a tap while the app is dead queues in the registry and drains on the next - launch / foreground. -- **App-icon badge.** The brain stamps `aps.badge` = the machine-wide - count of runs awaiting attention on every alert (and a silent badge-only - item when the count changes with no alert). The phone clears the badge + launch / foreground. Remote account rows navigate to the exact machine and + pending item instead of executing a current-host intent. +- **App-icon badge.** Account Attention delivery stamps the account-wide + unresolved attention count on alerts and badge-only refreshes. The phone clears the badge on every foreground transition (`PushNotificationService.clearAppBadge`, called from `ADEApp`'s scene-phase handler) so a lingering count never reads as stale. - **Live Activity** mirrors up to three active agent runs plus up to two recent PR lifecycle/status rows on the Lock Screen and Dynamic Island. - `LiveActivityService` starts one aggregate activity per machine - (`activityId: "agent-runs"`), applies brain-pushed content-state updates, - and ends it when all runs are terminal and the short-lived PR rows expire. + `LiveActivityService` owns one account-wide `agent-runs` activity per phone, + applies relay-pushed content-state updates, and ends it when the account + activity settles, Live Activities are disabled, or the user signs out. Each + `Run` / `PullRequest` row carries an optional `accountMachineKey`; exact + element-level links preserve it so tapping a secondary row never opens the + primary item or the wrong machine. Legacy payloads without the additive key + still decode. PR rows are sourced from the same `pr-notification` fan-out as desktop toasts and cover opened, reopened, closed, merged, checks failing, changes requested, review requested, and merge-ready states. `NSSupportsLiveActivities` @@ -1110,37 +1122,41 @@ contract) is documented in Source: `apps/ios/ADE/Views/AttentionDrawer/`. -The attention drawer is a single global sheet (`AttentionDrawerSheet`) -opened from the navigation bar bell. `AttentionDrawerModel` rebuilds the -roster from the App Group `WorkspaceSnapshot` whenever -`SyncService.activeSessions` or `workspaceSnapshotRevision` changes, and -projects each row into an `AttentionItem` that carries the originating -session/PR ids plus an optional `itemId` lifted from -`AgentChatSessionSummary.pendingInputItemId` / `AgentSnapshot.pendingInputItemId`. - -Each row renders inline actions sourced from the same surface the -notification banners use: - -- **Awaiting input** — when an `itemId` is present, the row shows - Approve / Deny buttons backed by `ApproveSessionIntent` / - `DenySessionIntent`; otherwise the primary action is "Open session" - (which still routes through `Reply`-style behaviour via deep link). An - explicit `ade chat ask` uses its persisted question as the drawer subtitle; - older approvals without a message fall back to "Approval needed." -- **Failed** — "Open agent" plus a `RestartSessionIntent` chip. -- **CI failing** — "Open #N" plus `RetryCheckIntent` to rerun checks. -- **Review requested / merge ready** — "Review" / "Merge" / - "View" entries that deep-link into the PR detail surface. - -`AttentionDrawerModel.clearVisibleItems()` snapshots the current set of -ids into `dismissedItemIDs` (persisted under -`ade.attention.dismissedItemIDsKey` in App Group `UserDefaults`) and -prunes the in-memory list. The pruning step in -`pruneDismissedItems(activeIDs:)` runs on every rebuild, so a future -regression — a chat re-entering awaiting-input, a PR going red again — -re-surfaces the card automatically. The "Clear all" toolbar button calls -this method; the cards do not silently come back until the underlying -attention recurs. +The navigation-bar bell opens one global account-wide Attention Center +(`AttentionDrawerSheet`). The signed-in app reads the Clerk-authenticated +relay snapshot incrementally and persists it in the App Group container with +the same source-revision, account-cursor, tombstone, and expiry rules as +desktop. The existing per-project drawer is a project lens over that model; it +is not a separate inbox. + +The sheet has **Needs you**, **Live**, and **Recent** views, a project lens +picker, and machine/project context on every item. It remains useful when the +phone is account-signed-in but not directly paired to a machine. + +Each row uses the shared item destination and actions: + +- **Needs you** — exact session/question/approval navigation; locally owned + approvals may expose Approve/Deny. +- **Failed** — exact agent navigation and a locally safe restart affordance. +- **CI failing / review requested / merge ready** — exact PR tab navigation. +- **Completed / merged** — retained in Recent until seen or dismissed. + +Acknowledgments write through the account relay so desktop, ADE Notch, and +mobile settle together. A device never executes a current-host App Intent for +an item that originated on another machine; those items expose exact Open or +Reply navigation instead. + +Push settings work for account-only users and cover notifications, Live +Activities, desktop-first behavior, preview privacy, sound, celebration, and +quiet-hour policy. Sign-out best-effort deletes the account device +registration and ends account-wide local Live Activities. + +The Lock Screen widget and account-wide `agent-runs` Live Activity read the +same App Group snapshot/phase vocabulary. The relay prioritizes up to three +agent rows and two PR rows; ordinary open PRs do not keep the activity alive. +Every secondary row has its own `Link`, so it cannot accidentally open the +activity's primary item. Remote account activity rows never expose host-local +approval intents. ## Tab structure @@ -1834,9 +1850,9 @@ different machine's cached limits. | Settings tab (pairing / appearance / diagnostics) | Implemented | | Automations / Graph / History tabs | Planned | | Full Settings parity | Planned | -| Lock Screen widget | Implemented; single prioritized status across agents, PRs, sync, offline, and idle states | -| Push notifications (APNs alerts + deep links) | Implemented (on-device E2E needs a physical iPhone) | -| Live Activity + Dynamic Island (`ADEAgentActivityWidget`) | Implemented (push-to-start / background updates verifiable on-device only) | +| Lock Screen widget | Implemented; one prioritized account status across signed-in machines/projects, agents, PRs, sync, offline, and idle states | +| Push notifications (APNs alerts + exact cross-machine deep links) | Implemented (on-device E2E needs a physical iPhone) | +| Account-wide Live Activity + Dynamic Island (`ADEAgentActivityWidget`) | Implemented; one prioritized activity per phone (push-to-start / background updates verifiable on-device only) | | Push delivery settings panel (`SettingsPushDeliverySection`) | Implemented | | Home Screen / Control Center widgets | Not shipped | | iPad adaptive layout | Planned | @@ -2205,6 +2221,21 @@ different machine's cached limits. Approve / Deny / Reply buttons need to address a specific approval — the phone can decide an awaiting-input row at the source instead of forcing the user to open the session. +- **Account device ownership is epoch-ordered.** `AccountService` persists a + positive JavaScript-safe `ownershipEpoch` in the App Group and includes it on + every authenticated account device PUT/DELETE. A direct account switch is + two committed transitions (`old owner → nil → new owner`), registration PUTs + are serialized/latest-wins, and a Relay `409` is terminal because a newer + owner boundary already superseded the request. Do not replace this with task + cancellation: cancellation cannot recall an HTTP request that already + reached Relay. +- **Account routes must retain `accountMachineKey`.** Attention destinations, + APNs payloads, Live Activity `Run`/`PullRequest` rows, and their element-level + links carry the canonical account machine key. `DeepLinkRouter` threads it + into `WorkSessionNavigationRequest` / `PrNavigationRequest`, and navigation + selects or adopts that exact machine before opening the session, pending + item/event, PR, or PR tab. Missing keys remain valid only for legacy/local + payloads. - **The chat-summary cache merges, it never wholesale-replaces.** `cacheChatSummaries` folds each incoming summary into `chatSummaryCache` by session id rather than swapping the whole map. diff --git a/docs/features/sync-and-multi-device/push-notifications.md b/docs/features/sync-and-multi-device/push-notifications.md index 9c246f514..9901b6392 100644 --- a/docs/features/sync-and-multi-device/push-notifications.md +++ b/docs/features/sync-and-multi-device/push-notifications.md @@ -1,210 +1,297 @@ -# Mobile Push & Live Activities +# Attention, notifications, and Live Activities -ADE pushes agent-state transitions to paired iPhones through a small -Cloudflare Worker relay (`apps/push-relay/`), so the phone learns -"Claude needs input" / "turn failed" / "PR merge-ready" even when the -app is closed and no direct transport exists. A companion ActivityKit -Live Activity mirrors up to three active agent runs on the lock screen -and Dynamic Island. +ADE uses one account-wide Attention contract for agent work and pull requests +across every signed-in machine and project. Desktop Attention, ADE Notch, the +iOS Attention Center, APNs notifications, Lock Screen widgets, and Live +Activities all render the same items and route to the same destination. + +The product name for the shared system is **ADE Attention**. The compact native +macOS presentation is **ADE Notch**. + +## Product rules + +- Running work is ambient. It belongs in Attention, ADE Notch, widgets, and + Live Activities, not in a stream of toast or push interruptions. +- `needs_you`, failures, failing checks, changes requested, and review requests + can notify according to the user's policy. +- Completed and merged work remains visible until it is seen or dismissed. +- Every row owns an exact ADE destination. A PR can target Overview, Checks, or + Review; an agent item can target a session, question, approval, or event. +- Account views group work by machine and project. They never assume the + currently open project or the current machine is the whole account. +- Remote actions are conservative. Account items from another machine open the + correct context; they do not execute a current-host App Intent by accident. +- Notification previews, Live Activity content, and ADE Notch honor the same + `hideDetails` preference. ## Topology -``` +```text agentChatService ─┐ -ptyService.onExit ├─ pushPublisherService (brain, debounced + deduped) -prPollingService ─┘ │ HMAC-signed HTTPS - ▼ - ade-push-relay (Cloudflare Worker + D1) - │ APNs HTTP/2 (ES256 .p8 JWT) - ▼ - APNs sandbox/production - │ - ▼ - iPhone: alert pushes (deep links) + Live Activity updates +pty/session state ├─ pushPublisherService (brain; canonical item derivation) +prPollingService ─┘ │ + │ HMAC machine auth + signed-in account token + ▼ + ade-push-relay (Cloudflare Worker + D1) + │ │ + │ account snapshots │ APNs alert / Live Activity + ▼ ▼ + Desktop + iOS Attention iPhone system surfaces + │ + └─ desktop renderer snapshot + ▼ + native ADE Notch helper ``` -The phone never talks to the relay. It hands its APNs tokens and -notification preferences to the brain over the paired sync WebSocket -(`push.registerDevice`, `push.setPrefs`, `push.reportLiveActivityToken`, -`push.getStatus`, `push.unregisterDevice` — all runtime-scoped commands), -and the brain forwards registrations to the relay. - -## Relay (`apps/push-relay/`) - -- Deployed at `https://ade-push-relay.arulsharma1028.workers.dev` - (override with `ADE_PUSH_RELAY_URL`). -- Trust model: the brain claims an unguessable 32-hex `machineKey` with a - machine secret; every later call carries `x-ade-push-timestamp` + - `x-ade-push-signature: sha256=HMAC(secret, "...")`. -- D1 tables: `machines`, `device_registrations` (APNs token, - push-to-start token, bundleId, apsEnvironment), `live_activity_tokens` - (per-activity update tokens), `publish_suppression` (dedupe hashes), - `rate_counters` (per-IP rate-limit + daily-budget windows). -- Spend + abuse controls (Cloudflare has no native hard billing cap, so the - worker enforces its own): a per-IP limit on every route - (`IP_RATE_LIMIT_PER_MIN`, default 120), a tighter per-IP limit on the - unauthenticated `/claim` write (`CLAIM_RATE_LIMIT_PER_MIN`, default 10), - and a hard daily request budget (`DAILY_REQUEST_BUDGET`, default - 500,000/day → `429` until midnight UTC once blown; sized — counting the - guards' own ~2 D1 counter writes/request — to keep a full month ≈ $1.50 of - overage, safely under ~$10). All three are wrangler vars. Structured JSON - logs (`rate_limited`/`budget_exceeded`/`auth_failed`/`apns_error`/ - `claim_conflict`) via enabled observability — see `apps/push-relay/README.md`. -- APNs: ES256 provider JWT from the `.p8` (wrangler secrets `APNS_KEY`, - `APNS_KEY_ID`, `APNS_TEAM_ID`), cached ~45 min per isolate. Pushes route - to sandbox/production per registration and use the registration's - bundleId as `apns-topic` (`.push-type.liveactivity` for Live - Activities) so dev/TestFlight/App Store builds coexist. -- Phase-dependent TTLs: `running` → 2 h, `waiting`/`terminal` → 24 h. -- Dead tokens (410 / BadDeviceToken / Unregistered / DeviceTokenNotForTopic - / ExpiredToken) are cleared automatically; the phone re-registers. -- Live Activity `start` pushes set `"input-push-token": 1` so ActivityKit - mints a per-activity update token, and default `stale-date` to +10 min. - -## Brain publisher (`apps/ade-cli/src/services/push/`) - -Subscribes to the SAME signal sources the widget snapshot uses — it never -re-derives state: - -- `agentChatService.subscribeToEvents` — `approval_request` / - `structured_question` → waiting phases + alert; `pending_input_resolved` - clears; failed turn statuses → alert. -- `ptyService.onSessionRuntimeSignal` — tracked CLI sessions' OSC 133-derived - state (`running` / `waiting-input`) feeds Live Activity run rows **only**, - never alert pushes: a CLI agent returns to its prompt after every turn, so - alerting on waiting-input would ping once per turn. Chat-attached shells and - untitled infra rows are filtered out (the chat run already represents them). -- `ptyService.onExit` — CLI session ended: flips the run to - completed/failed in the aggregate; a non-zero exit also alerts. -- `prPollingService`'s `pr-notification` events — `merge_ready` / - `checks_failing` alerts (edge-transition gated by that service). - -Behavior: gated off until a device registers; trailing-edge debounce with -prompt delivery for waiting transitions; two dedupe lines (in-memory JSON -fingerprint, then the relay's `dedupeKey` content-hash suppression); -suppressed Live Activity updates never fall back to alert pushes. - -Approval alerts are **actionable**: the publisher stamps top-level -`sessionId` + `itemId` and `aps.category: "ADE_APPROVAL"` on the payload, -and iOS binds Approve/Deny notification actions to that category (routed -through the same intent command registry the widgets use — `chat.approve` -with `decision: accept|decline`), so approvals resolve from the lock -screen without opening the app. - -Every alert also carries `aps.badge` = the machine-wide count of runs in -`waiting_for_*` phases (`countAwaitingAttentionRuns`). When that count -changes, the publisher also emits a silent, title-less badge-only item -(`dedupeKey: "alert:badge"`, no sound) so the icon tracks *drops* too — an -approval answered on the Mac produces no alert but must still lower the -badge. That badge-only item targets every alert-enabled device that is -**not** already carrying an alert in the same flush (a muted session still -needs the fresh count even though its own alert push is skipped); the -relay's content-hash suppression absorbs unchanged resends, and the -relay accepts a title-less item only when it carries a `badge`. iOS -clears the badge on every foreground. - -On daemon shutdown the publisher makes a best-effort Live Activity `end` -(`dismissalDate` now + 60 s, still-active runs re-stamped `stale`), -bounded by a short timeout so exit never hangs — dead agents don't linger -on the lock screen until the stale-date dim. - -Per-device preferences are enforced brain-side before publishing: master -enable, per-session mutes, and quiet hours (evaluated in the device's -timezone; may span midnight). Live Activity updates ignore quiet hours -(silent) but honor `liveActivitiesEnabled`. - -## Live Activity contract - -One aggregate activity per machine (`activityId: "agent-runs"`, -`attributesType: "ADEAgentRunsAttributes"`, attributes -`{ machineName }`). Content state (JSON, mirrored by the Swift -`ActivityAttributes.ContentState`): - -```json -{ - "updatedAt": 1751712000, - "activeCount": 2, - "runs": [ - { "id": "", "title": "fix-login-flow", "phase": "waiting_for_input", - "model": "claude-fable-5", "lane": "auth-lane", "detail": "Approve the plan" } - ] -} +Each brain publishes a bounded full snapshot for its machine. The relay merges +machine snapshots into an account revision stream. Signed-in desktop and iOS +clients read that stream incrementally, acknowledge items, report presence, and +update account/device preferences. + +The legacy paired-machine push routes remain available for older clients. Once +an account Attention publish succeeds, the brain suppresses duplicate legacy +alerts and the legacy per-machine Live Activity. + +## Shared contract + +The TypeScript source of truth is +`apps/desktop/src/shared/types/attention.ts`. + +An `AttentionItem` includes: + +- stable `id`, source `revision`, `fingerprint`, occurrence/update/expiry time; +- kind, event, and phase; +- machine and project identity; +- optional lane, provider, model, plan progress, and recent activity; +- public preview plus a separate privacy-safe preview; +- exact session or PR destination; +- bounded actions such as open, approve, deny, restart, rerun checks, mark + seen, and dismiss; +- `seenAt` and `dismissedAt` acknowledgment state. + +Contract version 1 limits text, actions, progress counts, snapshots, and +tombstones before data is stored or delivered. Relay validation also enforces: + +- agent ids/events cannot masquerade as PR ids/events, and vice versa; +- the item id and embedded machine identity must match the authenticated + publishing machine; +- session and PR destinations use the expected shape and known PR tabs; +- action payloads contain only bounded scalar values; +- plan progress is finite, non-negative, and internally consistent. + +Source revisions are independent from account cursor revisions. Tombstones +carry the source revision that deleted the item, so delayed snapshots cannot +resurrect old work and delayed tombstones cannot remove a newer item. + +## Relay and trust model + +The Worker lives in `apps/push-relay/`. + +Machine publishing requires both: + +1. the existing HMAC-signed machine request; and +2. a verified Clerk bearer token for the account receiving the snapshot. + +Account clients use the verified bearer token for snapshot, acknowledgment, +presence, preferences, device registration, and activity-token routes. Clerk +production and optional secondary/development issuers are verified separately. +The relay hashes verified issuer plus subject into the D1 account key so equal +opaque subjects from different Clerk instances cannot share data. + +Every iOS installation also persists a positive, JavaScript-safe monotonic +`ownershipEpoch`. Account device PUT and DELETE bodies both carry that epoch. +Sign-out commits an unowned epoch before revocation; a direct account switch +commits `account A → unowned → account B`, so the old-account DELETE and the +new-account PUT never tie. Relay retains the latest epoch even after deletion +and returns `409` for a stale or equal-epoch foreign-owner mutation. The phone +treats that response as safely superseded rather than retrying an obsolete +request. Registration PUTs are serialized and queued refreshes coalesce to the +latest request, so network reordering cannot restore an earlier account owner. + +The account routes are: + +```text +GET /attention/account/snapshot?since= +POST /attention/account/ack +POST /attention/account/presence +GET /attention/account/preferences +PUT /attention/account/preferences +PUT /attention/account/devices/:deviceId +DELETE /attention/account/devices/:deviceId +PUT /attention/account/devices/:deviceId/activities/:activityId +DELETE /attention/account/devices/:deviceId/activities/:activityId +POST /machines/:machineKey/attention ``` -Additive optional field: a `waiting_for_approval` row carries `itemId` -(the pending approval item), which the widget uses to render Approve/Deny -`Button(intent:)` on the lock-screen presentation. Older widgets ignore -it; the Swift decode stays lenient. - -Phases: `starting | running | waiting_for_approval | waiting_for_input | -completed | failed | stale`. Runs are capped at 3 (most recent first), -`detail` at 160 chars, and a `failed` run's detail is redacted to a fixed -string before it reaches the lock screen. Stuck runs age out of the -aggregate (2 h running / 24 h waiting) so dead sessions cannot pin -`activeCount`. `start` fires on 0→N running, `end` (dismissal +5 min) -when all runs reach a terminal phase. - -## One status vocabulary - -`apps/desktop/src/shared/sessionCanonicalState.ts` is the canonical mapping -from session inputs to a phase + attention badge, consumed by the Work tab -(desktop and the iOS mirror). Its `needs_you` covers the Live Activity's -`waiting_for_approval`/`waiting_for_input` (wire names unchanged); -`failed`/`stale`/`running` correspond directly. Its 3-hour stale threshold -is the human-facing "running but silent" bar — distinct from the relay's APNs -delivery TTLs and the Live Activity's 10-minute lock-screen stale-date. - -## iOS - -- `aps-environment` entitlement + `remote-notification` background mode + - `NSSupportsLiveActivities(FrequentUpdates)`. -- Registration happens only after pairing (never on first launch); - re-registration on every foreground transition also re-reports Live - Activity tokens and ends orphaned activities. Unpair/forget sends - `push.unregisterDevice` and ends local activities. -- Alert payloads carry a top-level `deepLink` (`ade://session/`, - `ade://pr/`) routed through - `DeepLinkRouter.handleNotificationUserInfo`. -- Settings > Push delivery panel shows registration state, token suffix, - environment, last push received, relay reachability (via - `push.getStatus`), plus notification/Live-Activity toggles, per-session - mutes, and quiet hours. Runtime-scoped push commands are never queued: - when the paired machine cannot answer live `push.*` commands, the panel - keeps the last good relay status, disables manual refresh, and renders a - transient "connect to the machine" state instead of persisting the - transport miss as a failed registration. -- Per-session mute is also one tap away in the Work list: the session - row's context menu (and the open chat's header menu) offers - "Mute notifications" / "Unmute notifications", and muted rows show a - subtle `bell.slash` glyph. A muted session still counts toward the - badge and Live Activity — only its alert pushes are skipped. -- Approve/Deny actions appear on approval alerts (long-press or pull - down) and on `waiting_for_approval` Live Activity rows. On the alert, - `ADEAppDelegate` registers the `ADE_APPROVAL` `UNNotificationCategory` - (`ADE_APPROVE` / `ADE_DENY` actions) and its `didReceive response` - routes those action ids to `chat.approve`. On the Live Activity, the - buttons fire `ApproveSessionIntent` / `DenySessionIntent`, which conform - to **`LiveActivityIntent`** (not plain `AppIntent`) precisely so the - intent executes in the *app* process — where the command bridge is - registered — instead of the widget extension where the bridge is nil. - Both paths dispatch through `ADEIntentCommandRegistry`, which queues the - command when the bridge isn't live yet; `register()` drains the queue on - cold launch and every warm foreground also calls `drainPendingCommands()`, - so an approval tapped while the app was dead still lands on the next open. - -## What needs a physical device - -Simulators cannot receive real APNs pushes or mint push-to-start tokens. -Fully verifiable on-device only: end-to-end alert delivery, Live Activity -push-to-start, background `liveactivity` updates, TTL/stale behavior. -Everything else (registration flow, command routing, publisher logic, -relay auth/suppression) is covered by unit tests and simulator builds. - -## Operator setup - -1. Create an APNs auth key (Apple Developer → Keys) and upload it: - `wrangler secret put APNS_KEY / APNS_KEY_ID / APNS_TEAM_ID` in - `apps/push-relay` (see its README). Until then the relay's `/health` - reports `apnsConfigured: false` and publishes return 503. -2. Nothing else — the brain self-claims its machine key on first - registration and the phone registers itself after pairing. +D1 stores account revisions, machine links, items, tombstones, device +registrations, Live Activity state/tokens, presence, preferences, and delivery +receipts. Snapshots and fan-out are capped. Expired items, old tombstones, and +stale presence are pruned. + +APNs registrations and invalid-token cleanup retain the existing push relay +behavior. See `apps/push-relay/README.md` for deployment variables, Clerk +issuer configuration, APNs configuration, abuse limits, and migrations. + +## Brain publisher + +`apps/ade-cli/src/services/push/pushPublisherService.ts` owns machine item +derivation. It publishes the same state that desktop and mobile display rather +than rebuilding notification meaning in each client. + +The publisher: + +- observes chat approvals/questions/failures/completions, tracked CLI session + state, and PR notification transitions; +- republishes a full bounded machine snapshot on changes and a 30-second + heartbeat so presence and long-running state recover after disconnects; +- includes every active project known to that brain, not just the foreground + desktop project; +- keeps recent terminal outcomes long enough for acknowledgment; +- emits exact PR tabs and exact session pending-item/event anchors; +- skips duplicate legacy notifications and Live Activities after a successful + account publish. + +## Delivery policy and preferences + +Balanced defaults: + +| Event | Default | +| --- | --- | +| Running / progress | Ambient | +| Needs you | Notify | +| Failed / checks failing / changes requested | Notify | +| Review requested / merge ready | Notify | +| Completed / merged / opened / closed | Ambient | + +Preferences support account defaults plus device and project overrides: + +- event delivery policies; +- notifications; +- Live Activities; +- desktop-first delivery and its delay; +- sounds (off by default); +- celebrations; +- hidden preview details; +- quiet hours; +- muted sessions. + +When desktop-first delivery is enabled and a foreground Mac recently reported +presence, the relay waits for the configured bounded delay before notifying the +phone. The next machine heartbeat escalates an item that remains unseen. + +Notification delivery is receipt-deduped per item/device/fingerprint. Quiet +hours, muted sessions, preview privacy, sound, and exact deep links are applied +before APNs fan-out. `needs_you` can use time-sensitive interruption; other +notifying events use active interruption. + +## Desktop Attention + +The renderer keeps the account snapshot warm even when `/attention` is not +open, so the sidebar badge and ADE Notch stay truthful. + +Desktop reads and mutates Attention through a dedicated machine-scoped brain +method. It never follows the window's current local/remote project binding, so +the welcome screen and a window viewing another machine still show the signed-in +desktop user's own account stream. + +The Attention route provides: + +- Needs-you/inbox, live, and recent views; +- all-machine, machine, and project scopes; +- a machine → project → item roster; +- an exact detail view with plan progress, recent activity, safe actions, + seen/dismiss state, offline explanation, and retryable acknowledgment; +- account delivery/privacy controls. + +Presence reports include foreground state, whether an ambient Attention surface +is visible, and the currently visible item ids. Acknowledgments are optimistic +with rollback when the account write fails. + +## ADE Notch + +ADE launches one native SwiftUI/AppKit helper from the desktop lifecycle. The +Electron renderer supplies the already-synced Attention snapshot and settings; +the helper does not create a second account poller. + +The helper uses a borderless non-activating `NSPanel` above the status bar, +joins Spaces/full-screen, and keeps the outer window fixed while the inner +silhouette animates. + +On a MacBook with a physical notch: + +- geometry comes from `safeAreaInsets`, `auxiliaryTopLeftArea`, and + `auxiliaryTopRightArea`; +- compact content lives in the visible side ears, never under the camera + housing; +- the black silhouette remains visually connected to the hardware notch. + +On other displays it uses the same top-center virtual island behavior. + +Interaction rules: + +- compact state identifies focused work and phase with a real provider mark; +- hover opens immediately and close uses short cancellable hysteresis; +- needs-you can open automatically; ordinary running work remains calm; +- incoming updates do not replace a card currently under the pointer; +- completion remains until seen/dismissed; +- celebrations are bounded one-shot effects and respect Reduce Motion; +- hit testing covers only the drawn/interactive shape, leaving the menu bar + usable. + +The helper sends open and acknowledgment requests back through typed IPC. Exact +ADE destinations are validated before the desktop navigates. + +## iOS Attention Center + +The mobile app stores the account snapshot in the App Group container using the +same delta/tombstone/expiry rules as desktop. + +The global Attention Center shows all signed-in machines and projects. Project +drawers are lenses over that same account model, not separate notification +inboxes. Tapping an item follows its exact destination. Remote items expose only +actions that are safe without assuming the currently paired host owns them. + +Account-only signed-in users can register APNs and Live Activity tokens without +pairing a machine. Sign-out best-effort deletes the account device registration +and ends account-wide local Live Activities. + +## Live Activity and widgets + +There is one account-wide `agent-runs` Live Activity per iPhone. The relay +prioritizes and caps up to three agent rows and two PR rows. + +- Ordinary open PRs do not keep the activity alive. +- Running, starting, needs-you, and blocked agent work contributes to the active + count. +- Completed/merged outcomes remain until seen, then disappear. +- Disabling Live Activities actively ends an existing account activity. +- When `hideDetails` is enabled, per-device content is redacted before APNs + delivery while preserving internal ids needed for exact routing. + +The Lock Screen and Dynamic Island lead with one focused item and show a small +overflow count instead of presenting a miniature monitoring dashboard. Each +secondary row owns an element-level `Link`, so tapping a PR or agent opens that +row rather than one activity-wide fallback URL. + +Account Live Activity `Run` and `PullRequest` rows carry the source +`accountMachineKey` as an additive optional wire field. Their exact ADE links +preserve that key together with the session item/event or PR tab anchor. Older +payloads without the field remain decodable, but account-wide payloads include +it so the app can adopt/select the owning machine before opening the row. +Account APNs alert payloads carry the same routing key. + +Interactive approval App Intents remain available only where the activity is +known to belong to the current host. Account-wide remote items use exact Open +or Reply navigation instead of executing an action against the wrong machine. + +The Lock Screen widget reads the same App Group snapshot and applies the same +priority, phase vocabulary, privacy, and routing rules. + +## Validation boundaries + +Simulator and unit validation can prove snapshot merging, expiry/tombstones, +preference mapping, exact links, intent safety, widget decoding, and rendering. + +A physical iPhone is still required to prove real APNs delivery, +push-to-start-token minting, background Live Activity updates, and system +notification presentation. From c41709ac8c3a65bb6f41f814b4e586141d347d92 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 07:38:45 -0400 Subject: [PATCH 2/6] =?UTF-8?q?ship:=20iteration=201=20=E2=80=94=20address?= =?UTF-8?q?=20Attention=20review=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-web.yml | 4 +- .../push/pushPublisherService.test.ts | 52 +++++ .../src/services/push/pushPublisherService.ts | 10 +- apps/push-relay/package.json | 2 +- apps/push-relay/src/attention.ts | 32 +++- apps/push-relay/test/attention.test.ts | 181 +++++++++++++++++- 6 files changed, 270 insertions(+), 11 deletions(-) diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 2ef9af65d..9a31ed7c3 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -160,5 +160,5 @@ jobs: with: node-version: 22 - run: cd apps/push-relay && npm ci - - name: Deploy Worker - run: cd apps/push-relay && npx wrangler deploy + - name: Apply D1 migrations and deploy Worker + run: cd apps/push-relay && npm run deploy diff --git a/apps/ade-cli/src/services/push/pushPublisherService.test.ts b/apps/ade-cli/src/services/push/pushPublisherService.test.ts index ce6f4257d..68424deab 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.test.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.test.ts @@ -286,6 +286,58 @@ describe("createPushPublisherService flush", () => { publisher.dispose(); }); + it("caps Attention full snapshots at 64 items while preserving the canonical priority boundary", async () => { + const { publisher, publishAttention, cliSessions } = makeHarness(); + publishAttention.mockResolvedValue({ ok: true, revision: 1 }); + + publisher.handleSessionAttentionRequested("scope-1", { + sessionId: "needs-you", + kind: "chat", + title: "Release decision", + message: "Choose the rollout window", + laneId: "auth-lane", + }); + // Every lower-priority running item is newer, proving phase priority wins + // at the truncation boundary instead of recency accidentally displacing it. + vi.advanceTimersByTime(1); + for (let index = 0; index < 64; index += 1) { + const sessionId = `running-${String(index).padStart(2, "0")}`; + cliSessions.set(sessionId, { + title: `Background run ${index}`, + toolType: "codex", + chatSessionId: null, + }); + publisher.handleCliRuntimeSignal("scope-1", { + laneId: "auth-lane", + sessionId, + runtimeState: "running", + }); + } + + await vi.advanceTimersByTimeAsync(200); + + expect(publishAttention).toHaveBeenCalledTimes(1); + const payload = publishAttention.mock.calls[0][0]; + expect(payload).toMatchObject({ + machineName: "MacBook", + fullSnapshot: true, + }); + expect(payload.items).toHaveLength(64); + expect(payload.items[0]).toMatchObject({ + id: `agent:${"a".repeat(40)}:needs-you`, + phase: "needs_you", + destination: { + kind: "session", + sessionId: "needs-you", + }, + }); + expect(payload.items.map((item: { id: string }) => item.id)).not.toContain( + `agent:${"a".repeat(40)}:running-63`, + ); + + publisher.dispose(); + }); + it("alerts native structured questions with the unified needs-you copy immediately", async () => { const { publisher, publish, emit } = makeHarness(); await publisher.start(); diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index 63e4b727a..6020e9a64 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -6,6 +6,7 @@ import { ATTENTION_CONTRACT_VERSION, DEFAULT_ATTENTION_PREFERENCES, sanitizeAttentionPreview, + sortAttentionItems, type AttentionEventKind, type AttentionItem, type AttentionPhase, @@ -54,6 +55,8 @@ const RUNNING_TTL_MS = 2 * 60 * 60 * 1000; // 2h for running/starting const WAITING_TTL_MS = 24 * 60 * 60 * 1000; // 24h for waiting_for_* const PR_LIVE_ACTIVITY_TTL_MS = 45 * 60 * 1000; // keep recent PR status visible, then age it out const ATTENTION_RECENT_TTL_MS = 24 * 60 * 60 * 1000; +/** The relay rejects an Attention publish containing more than 64 items. */ +const ATTENTION_PUBLISH_MAX_ITEMS = 64; const DEFAULT_FLUSH_DEBOUNCE_MS = 2_000; const DEFAULT_PROMPT_FLUSH_MS = 150; const PUBLISH_RETRY_MS = 30_000; @@ -934,7 +937,12 @@ export function createPushPublisherService(deps: PushPublisherDeps) { const publishAttentionSnapshot = async (nowMs: number): Promise => { if (typeof deps.relayClient.publishAttention !== "function") return false; - const items = buildAttentionItems(nowMs); + // Bound the full snapshot before fingerprinting it. The relay treats the + // published window as authoritative for this machine, so selection must be + // deterministic and use the same canonical priority order as every ADE + // Attention surface (needs-you/failures first, then recency and stable id). + const items = sortAttentionItems(buildAttentionItems(nowMs)) + .slice(0, ATTENTION_PUBLISH_MAX_ITEMS); const fingerprint = JSON.stringify(items.map((item) => ({ id: item.id, revision: item.revision, diff --git a/apps/push-relay/package.json b/apps/push-relay/package.json index 26844e506..f4c5bc61b 100644 --- a/apps/push-relay/package.json +++ b/apps/push-relay/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "wrangler dev", - "deploy": "wrangler deploy", + "deploy": "npm run d1:migrate:remote && wrangler deploy", "d1:migrate:local": "wrangler d1 migrations apply ade-push-relay --local", "d1:migrate:remote": "wrangler d1 migrations apply ade-push-relay --remote", "test": "vitest run", diff --git a/apps/push-relay/src/attention.ts b/apps/push-relay/src/attention.ts index 4a0a03b9f..e88aff4cf 100644 --- a/apps/push-relay/src/attention.ts +++ b/apps/push-relay/src/attention.ts @@ -424,6 +424,7 @@ async function deliverAttentionNotifications( env: AttentionRelayEnv, userId: string, items: ParsedAttentionItem[], + sendPush: typeof sendApnsPush = sendApnsPush, ): Promise { const config = apnsConfig(env); if (!config || items.length === 0) return; @@ -513,7 +514,7 @@ async function deliverAttentionNotifications( const body = hideDetails ? boundedText(item.privacyPreview, MAX_PREVIEW_LENGTH) : boundedText(item.preview, MAX_PREVIEW_LENGTH); - const result = await sendApnsPush(config, { + const result = await sendPush(config, { environment: device.aps_environment as ApnsEnvironment, deviceToken: device.apns_token, topic: device.bundle_id || env.APNS_DEFAULT_TOPIC?.trim() || "", @@ -1680,6 +1681,14 @@ export async function handleAttentionMachinePublish( tombstones.length, ) ) { + // Identical heartbeats still drive desktop-first escalation. The delivery + // path checks acknowledgments and durable receipts, so a due alert retries + // without rewriting account state or duplicating an already-delivered push. + await deliverAttentionNotifications( + env, + account.userId, + items as ParsedAttentionItem[], + ); const current = await env.DB .prepare("select revision from attention_revisions where user_id = ? limit 1") .bind(account.userId) @@ -2414,9 +2423,23 @@ export async function pruneAttentionState(env: AttentionRelayEnv): Promise await deleteAttentionDeviceOwnership(env, device.user_id, device.device_id); } await Promise.all([ - env.DB.prepare("delete from attention_items where expires_at is not null and expires_at <= ?") - .bind(now.toISOString()) - .run(), + env.DB.batch([ + env.DB.prepare(` + delete from attention_delivery_receipts + where not exists ( + select 1 + from attention_items + where attention_items.user_id = attention_delivery_receipts.user_id + and attention_items.item_id = attention_delivery_receipts.item_id + and ( + attention_items.expires_at is null + or attention_items.expires_at > ? + ) + ) + `).bind(now.toISOString()), + env.DB.prepare("delete from attention_items where expires_at is not null and expires_at <= ?") + .bind(now.toISOString()), + ]), env.DB.prepare("delete from attention_tombstones where deleted_at <= ?") .bind(tombstoneCutoff) .run(), @@ -2433,6 +2456,7 @@ export const attentionTestInternals = Object.freeze({ attentionAlertRoutingPayload, attentionFullSnapshotUnchanged, deepLinkForItem, + deliverAttentionNotifications, desktopEscalationDelayMs, handleAuthorizedAttentionAccountRequest, linkMachineToAccount, diff --git a/apps/push-relay/test/attention.test.ts b/apps/push-relay/test/attention.test.ts index b273827bb..d9f2e7720 100644 --- a/apps/push-relay/test/attention.test.ts +++ b/apps/push-relay/test/attention.test.ts @@ -1,7 +1,7 @@ import { createRequire } from "node:module"; import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { attentionTestInternals, @@ -93,8 +93,11 @@ class SqliteD1Database { } } -function makeAttentionEnv(database: SqliteD1Database): AttentionRelayEnv { - return { DB: database as unknown as D1Database }; +function makeAttentionEnv( + database: SqliteD1Database, + overrides: Omit, "DB"> = {}, +): AttentionRelayEnv { + return { DB: database as unknown as D1Database, ...overrides }; } function row>( @@ -177,6 +180,10 @@ function insertAttentionDevice( const MACHINE_KEY = "a".repeat(32); +afterEach(() => { + vi.useRealTimers(); +}); + function validAgentItem(): Record { return { contractVersion: 1, @@ -326,6 +333,114 @@ describe("account Attention contract", () => { ).toBe(false); }); + it("retries a due desktop-first alert without duplicating delivered or acknowledged notifications", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T08:00:10.000Z")); + const database = new SqliteD1Database(); + const parsed = attentionTestInternals.parseAttentionItem(validAgentItem(), MACHINE_KEY); + expect(parsed).not.toBeNull(); + if (!parsed) { + database.close(); + return; + } + const sendPush = vi.fn(async () => ({ + ok: true, + status: 200, + apnsId: "apns-id", + reason: null, + tokenInvalid: false, + })); + try { + insertAttentionDevice(database, { + userId: "account-a", + deviceId: "phone-1", + apnsToken: "ab".repeat(32), + }); + database.native.prepare(` + insert into attention_items( + user_id, item_id, machine_key, source_revision, account_revision, + fingerprint, event_kind, phase, payload_json, seen_at, dismissed_at, + expires_at, updated_at + ) values ( + 'account-a', ?, ?, ?, 1, ?, ?, ?, ?, null, null, ?, ? + ) + `).run( + parsed.id, + MACHINE_KEY, + parsed.revision, + parsed.fingerprint, + parsed.eventKind, + parsed.phase, + JSON.stringify(parsed), + parsed.expiresAt, + parsed.updatedAt, + ); + database.native.prepare(` + insert into attention_presence(user_id, device_id, payload_json, observed_at) + values ( + 'account-a', 'desktop-1', + '{"platform":"macOS","appForeground":true}', + '2026-07-28T08:00:10.000Z' + ) + `).run(); + const env = makeAttentionEnv(database, { + APNS_KEY: "test-key", + APNS_KEY_ID: "TESTKEY123", + APNS_TEAM_ID: "TESTTEAM12", + }); + + await attentionTestInternals.deliverAttentionNotifications( + env, + "account-a", + [parsed], + sendPush, + ); + expect(sendPush).not.toHaveBeenCalled(); + + vi.setSystemTime(new Date("2026-07-28T08:00:40.000Z")); + await attentionTestInternals.deliverAttentionNotifications( + env, + "account-a", + [parsed], + sendPush, + ); + expect(sendPush).toHaveBeenCalledTimes(1); + expect(rows(database, ` + select state + from attention_delivery_receipts + where user_id = 'account-a' and item_id = ? and device_id = 'phone-1' + `, parsed.id)).toHaveLength(1); + + vi.setSystemTime(new Date("2026-07-28T08:00:41.000Z")); + await attentionTestInternals.deliverAttentionNotifications( + env, + "account-a", + [parsed], + sendPush, + ); + expect(sendPush).toHaveBeenCalledTimes(1); + + database.native.prepare(` + delete from attention_delivery_receipts + where user_id = 'account-a' and item_id = ? + `).run(parsed.id); + database.native.prepare(` + update attention_items + set seen_at = '2026-07-28T08:00:41.000Z' + where user_id = 'account-a' and item_id = ? + `).run(parsed.id); + await attentionTestInternals.deliverAttentionNotifications( + env, + "account-a", + [parsed], + sendPush, + ); + expect(sendPush).toHaveBeenCalledTimes(1); + } finally { + database.close(); + } + }); + it("redacts lock-screen content without breaking exact PR routing metadata", () => { const privateState = attentionTestInternals.privacyPreservingActivityContentState({ updatedAt: 1_752_000_000, @@ -1075,4 +1190,64 @@ describe("account Attention contract", () => { database.close(); } }); + + it("prunes delivery receipts without live Attention state for renewed devices", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T12:00:00.000Z")); + const database = new SqliteD1Database(); + try { + insertAttentionDevice(database, { + userId: "account-a", + deviceId: "active-phone", + leaseExpiresAt: "2026-09-01T00:00:00.000Z", + }); + database.native.prepare(` + insert into attention_items( + user_id, item_id, machine_key, source_revision, account_revision, + fingerprint, event_kind, phase, payload_json, seen_at, dismissed_at, + expires_at, updated_at + ) values + ( + 'account-a', 'expired-item', ?, 1, 1, 'expired-fingerprint', + 'agent_needs_you', 'needs_you', '{}', null, null, + '2026-07-28T11:59:59.000Z', '2026-07-28T11:00:00.000Z' + ), + ( + 'account-a', 'active-item', ?, 1, 2, 'active-fingerprint', + 'agent_needs_you', 'needs_you', '{}', null, null, + '2026-07-29T12:00:00.000Z', '2026-07-28T11:00:00.000Z' + ) + `).run(MACHINE_KEY, MACHINE_KEY); + database.native.prepare(` + insert into attention_delivery_receipts( + user_id, item_id, device_id, state, delivered_at + ) values + ('account-a', 'expired-item', 'active-phone', 'alert:expired', '2026-07-28T11:00:00.000Z'), + ('account-a', 'active-item', 'active-phone', 'alert:active', '2026-07-28T11:00:00.000Z'), + ('account-a', 'removed-item', 'active-phone', 'alert:removed', '2026-07-28T11:00:00.000Z') + `).run(); + + await pruneAttentionState(makeAttentionEnv(database)); + + expect(rows(database, ` + select item_id + from attention_delivery_receipts + where user_id = 'account-a' and device_id = 'active-phone' + order by item_id + `)).toEqual([{ item_id: "active-item" }]); + expect(rows(database, ` + select item_id + from attention_items + where user_id = 'account-a' + order by item_id + `)).toEqual([{ item_id: "active-item" }]); + expect(row(database, ` + select lease_expires_at + from attention_devices + where user_id = 'account-a' and device_id = 'active-phone' + `)?.lease_expires_at).toBe("2026-09-01T00:00:00.000Z"); + } finally { + database.close(); + } + }); }); From 00de6059f800a67b2ade75ea530fa2afea2528b2 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:20:26 -0400 Subject: [PATCH 3/6] =?UTF-8?q?ship:=20iteration=202=20=E2=80=94=20fix=20C?= =?UTF-8?q?I=20and=20review=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../push/pushPublisherService.test.ts | 81 +++++++++++- .../src/services/push/pushPublisherService.ts | 27 ++-- .../ADEAttentionNotch/ProtocolTransport.swift | 4 +- .../attention/attentionNotchRouter.test.ts | 5 +- .../attention/attentionNotchRouter.ts | 5 +- .../src/main/services/ipc/registerIpc.ts | 35 +++-- .../main/services/ipc/runtimeBridge.test.ts | 76 ++++++++++- .../renderer/components/app/TabNav.test.tsx | 28 +++- .../src/renderer/components/app/TabNav.tsx | 18 ++- .../attention/AttentionCenter.test.tsx | 78 +++++++++++ .../attention/AttentionSettingsPopover.tsx | 9 +- apps/ios/ADE/App/DeepLinkRouter.swift | 18 +-- apps/ios/ADE/Services/AccountService.swift | 3 + .../Shared/ADEAgentActivityAttributes.swift | 2 +- apps/ios/ADE/Shared/ADESharedModels.swift | 61 ++++++++- .../AttentionDrawerModel.swift | 16 +-- .../AttentionDrawerSheet.swift | 2 +- apps/ios/ADETests/ADETests.swift | 34 +++++ .../ADETests/AttentionDrawerModelTests.swift | 124 +++++++++++++++++- apps/ios/ADETests/PairingAndDpopTests.swift | 14 +- .../ADEWidgets/ADEAgentActivityWidget.swift | 1 + apps/push-relay/src/attention.ts | 11 +- apps/push-relay/test/attention.test.ts | 82 ++++++++++++ apps/push-relay/test/relay.test.ts | 8 ++ 24 files changed, 666 insertions(+), 76 deletions(-) diff --git a/apps/ade-cli/src/services/push/pushPublisherService.test.ts b/apps/ade-cli/src/services/push/pushPublisherService.test.ts index 68424deab..358738abc 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.test.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.test.ts @@ -135,7 +135,7 @@ describe("createPushPublisherService flush", () => { updatedAt: "", }; - function makeHarness(deviceOverride = device) { + function makeHarness(deviceOverride = device, now?: () => number) { const publish = vi.fn().mockResolvedValue({ ok: true }); const publishAttention = vi.fn().mockResolvedValue(null); const store = { @@ -182,6 +182,7 @@ describe("createPushPublisherService flush", () => { machineKey: "b".repeat(32), deviceId: "desktop-device", }), + now, flushDebounceMs: 2_000, promptFlushMs: 150, }); @@ -286,6 +287,57 @@ describe("createPushPublisherService flush", () => { publisher.dispose(); }); + it.each([ + { unchanged: true }, + { suppressed: true }, + ])("delivers queued alerts when the Attention result is suppressed or unchanged", async (result) => { + const { publisher, publish, publishAttention, emit } = makeHarness(); + publishAttention.mockResolvedValue({ ok: true, ...result }); + + emit(approval); + await vi.advanceTimersByTimeAsync(200); + + expect(publish).toHaveBeenCalledTimes(1); + expect(publish.mock.calls[0][0].notifications).toEqual( + expect.arrayContaining([ + expect.objectContaining({ dedupeKey: "alert:s-1:approval" }), + ]), + ); + + publisher.dispose(); + }); + + it("delivers queued alerts when the Attention fingerprint is locally unchanged", async () => { + const fixedNow = Date.parse("2026-07-05T12:00:00.000Z"); + const { publisher, publish, publishAttention, emit } = makeHarness( + device, + () => fixedNow, + ); + publishAttention.mockResolvedValue({ ok: true, revision: 1 }); + + emit(approval); + await vi.advanceTimersByTimeAsync(200); + expect(publishAttention).toHaveBeenCalledTimes(1); + expect( + (publish.mock.calls[0]?.[0].notifications ?? []) + .filter((item: { title: string }) => item.title), + ).toEqual([]); + publish.mockClear(); + + emit(approval); + await vi.advanceTimersByTimeAsync(200); + + expect(publishAttention).toHaveBeenCalledTimes(1); + expect(publish).toHaveBeenCalledTimes(1); + expect(publish.mock.calls[0][0].notifications).toEqual( + expect.arrayContaining([ + expect.objectContaining({ dedupeKey: "alert:s-1:approval" }), + ]), + ); + + publisher.dispose(); + }); + it("caps Attention full snapshots at 64 items while preserving the canonical priority boundary", async () => { const { publisher, publishAttention, cliSessions } = makeHarness(); publishAttention.mockResolvedValue({ ok: true, revision: 1 }); @@ -1144,6 +1196,33 @@ describe("createPushPublisherService flush", () => { publisher.dispose(); }); + + it("removes terminal recent runs before the last scope's empty Attention snapshot", async () => { + const { publisher, publishAttention, emit, detach } = makeHarness(); + publishAttention.mockResolvedValue({ ok: true, revision: 1 }); + emit(approval); + await vi.advanceTimersByTimeAsync(200); + emit({ + sessionId: "s-1", + timestamp: "", + event: { type: "status", turnStatus: "completed" }, + }); + await vi.advanceTimersByTimeAsync(2_500); + expect(publishAttention.mock.calls.at(-1)?.[0].items[0]).toMatchObject({ + phase: "completed", + }); + + publishAttention.mockClear(); + detach(); + await vi.runAllTicks(); + await Promise.resolve(); + + expect(publishAttention).toHaveBeenCalledTimes(1); + expect(publishAttention.mock.calls[0][0].items).toEqual([]); + expect(vi.getTimerCount()).toBe(0); + + publisher.dispose(); + }); }); describe("createPushRegistrationStore", () => { diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index 6020e9a64..af9947eaa 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -935,8 +935,10 @@ export function createPushPublisherService(deps: PushPublisherDeps) { return { item, commit }; }; - const publishAttentionSnapshot = async (nowMs: number): Promise => { - if (typeof deps.relayClient.publishAttention !== "function") return false; + const publishAttentionSnapshot = async ( + nowMs: number, + ): Promise<"published" | "unchanged" | "unavailable"> => { + if (typeof deps.relayClient.publishAttention !== "function") return "unavailable"; // Bound the full snapshot before fingerprinting it. The relay treats the // published window as authoritative for this machine, so selection must be // deterministic and use the same canonical priority order as every ADE @@ -952,7 +954,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { fingerprint === lastAttentionFingerprint && nowMs - lastAttentionPublishedAt < ATTENTION_HEARTBEAT_MS ) { - return true; + return "unchanged"; } try { const result = await deps.relayClient.publishAttention({ @@ -963,13 +965,15 @@ export function createPushPublisherService(deps: PushPublisherDeps) { if (result) { lastAttentionFingerprint = fingerprint; lastAttentionPublishedAt = nowMs; - return true; + return result.unchanged === true || result.suppressed === true + ? "unchanged" + : "published"; } - return false; + return "unavailable"; } catch (error) { logWarn("attention.publish_failed", error); scheduleRetry(); - return false; + return "unavailable"; } }; @@ -978,7 +982,8 @@ export function createPushPublisherService(deps: PushPublisherDeps) { pruneRuns(nowMs); prunePrActivities(nowMs); await resolveMissingMeta(); - const accountAttentionPublished = await publishAttentionSnapshot(nowMs); + const attentionPublishResult = await publishAttentionSnapshot(nowMs); + const accountAttentionPublished = attentionPublishResult === "published"; if (isGated()) { pendingAlerts = []; return; @@ -1506,6 +1511,12 @@ export function createPushPublisherService(deps: PushPublisherDeps) { removedContribution = true; } } + for (const [sessionId, run] of recentRuns) { + if (run.scopeKey === scopeKey) { + recentRuns.delete(sessionId); + removedContribution = true; + } + } const removedPrActivities = removePrActivitiesForScope(scopeKey); removedContribution = removedContribution || removedPrActivities; if (scopes.size === 0) { @@ -1524,7 +1535,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { prActivities.clear(); pendingAlerts = []; scheduleFinalAttentionSnapshot(); - } else if (removedPrActivities) { + } else if (removedContribution) { scheduleFlush(false); } }; diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/ProtocolTransport.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/ProtocolTransport.swift index 42b2750e1..6cacef2db 100644 --- a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/ProtocolTransport.swift +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/ProtocolTransport.swift @@ -32,11 +32,11 @@ final class StandardIOTransport { } func send(_ output: NotchOutput) { + outputLock.lock() + defer { outputLock.unlock() } do { var data = try encoder.encode(output) data.append(0x0A) - outputLock.lock() - defer { outputLock.unlock() } FileHandle.standardOutput.write(data) } catch { let message = "ADE Attention Notch could not encode output: \(error)\n" diff --git a/apps/desktop/src/main/services/attention/attentionNotchRouter.test.ts b/apps/desktop/src/main/services/attention/attentionNotchRouter.test.ts index 4f3f6d080..3d3a6dc65 100644 --- a/apps/desktop/src/main/services/attention/attentionNotchRouter.test.ts +++ b/apps/desktop/src/main/services/attention/attentionNotchRouter.test.ts @@ -8,10 +8,11 @@ import { resolveAttentionNotchOutput, } from "./attentionNotchRouter"; import type { AttentionItem, AttentionSnapshot } from "../../../shared/types"; +import { ATTENTION_CONTRACT_VERSION } from "../../../shared/types/attention"; function item(overrides: Partial = {}): AttentionItem { return { - contractVersion: 1, + contractVersion: ATTENTION_CONTRACT_VERSION, id: "agent-1", revision: 3, fingerprint: "agent-1:3", @@ -60,7 +61,7 @@ function item(overrides: Partial = {}): AttentionItem { function snapshot(attentionItem = item()): AttentionSnapshot { return { - contractVersion: 1, + contractVersion: ATTENTION_CONTRACT_VERSION, revision: 4, generatedAt: "2026-07-28T12:00:03.000Z", items: [attentionItem], diff --git a/apps/desktop/src/main/services/attention/attentionNotchRouter.ts b/apps/desktop/src/main/services/attention/attentionNotchRouter.ts index f0f71c41f..27e19d45d 100644 --- a/apps/desktop/src/main/services/attention/attentionNotchRouter.ts +++ b/apps/desktop/src/main/services/attention/attentionNotchRouter.ts @@ -6,6 +6,7 @@ import type { AttentionSnapshot, OpenProjectBinding, } from "../../../shared/types"; +import { ATTENTION_CONTRACT_VERSION } from "../../../shared/types/attention"; import type { AttentionNotchOutput } from "./attentionNotchHelper"; const MAX_NOTCH_ITEMS = 256; @@ -149,7 +150,7 @@ function isAttentionAction(value: unknown): boolean { function isAttentionItem(value: unknown): value is AttentionItem { if (!isRecord(value)) return false; if ( - value.contractVersion !== 1 + value.contractVersion !== ATTENTION_CONTRACT_VERSION || !isNonEmptyString(value.id, 512) || !Number.isSafeInteger(value.revision) || Number(value.revision) < 0 @@ -232,7 +233,7 @@ export function parseAttentionNotchSnapshot(input: unknown): AttentionSnapshot | return null; } if ( - input.contractVersion !== 1 + input.contractVersion !== ATTENTION_CONTRACT_VERSION || !isNullableString(input.streamId, 512) || !Number.isSafeInteger(input.revision) || Number(input.revision) < 0 diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index c2cbd09ca..c42b36db2 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -674,6 +674,7 @@ import type { createKeybindingsService } from "../keybindings/keybindingsService import type { createAgentToolsService } from "../agentTools/agentToolsService"; import type { createDevToolsService } from "../devTools/devToolsService"; import type { createOnboardingService } from "../onboarding/onboardingService"; +import { getSharedAccountAuthService } from "../../../../../ade-cli/src/services/account/sharedAccountAuthService"; import type { DevToolsCheckResult } from "../../../shared/types/devTools"; import type { createAutomationService } from "../automations/automationService"; import type { createAutomationPlannerService } from "../automations/automationPlannerService"; @@ -1583,6 +1584,7 @@ export function registerIpc({ publishAttentionNotchSnapshot, updateAttentionNotchSettings, openAttentionItem, + getCurrentAccountOwnerId, }: { getCtx: () => AppContext; getResourceUsageContexts?: () => AppContext[]; @@ -1606,6 +1608,7 @@ export function registerIpc({ publishAttentionNotchSnapshot?: (snapshot: AttentionSnapshot) => void; updateAttentionNotchSettings?: (settings: AttentionNotchSettings) => void; openAttentionItem?: (item: AttentionItem) => Promise; + getCurrentAccountOwnerId?: () => string | null; }) { // Process-scoped by design: renderer reloads and additional windows in the // same app launch do not repeat the account choice, while a full ADE relaunch @@ -1634,6 +1637,24 @@ export function registerIpc({ connectionPool: projectRecoveryConnectionPool, }) : null; + const requireCurrentAttentionAccountOwner = (value: unknown): string => { + const accountOwnerId = typeof value === "string" ? value.trim() : ""; + const currentOwnerId = ( + getCurrentAccountOwnerId + ? getCurrentAccountOwnerId() + : (() => { + const status = getSharedAccountAuthService().getStatus(); + return status.signedIn ? status.userId : null; + })() + )?.trim() || null; + if (!accountOwnerId) { + throw new Error("A valid Attention account owner is required."); + } + if (currentOwnerId !== accountOwnerId) { + throw new Error("The ADE account changed before Attention preferences could be used."); + } + return accountOwnerId; + }; const getOptionalSyncService = (): ReturnType | null => { if (getSyncService) return getSyncService() ?? null; @@ -3238,11 +3259,7 @@ export function registerIpc({ ipcMain.handle(IPC.attentionGetPreferences, async (_event, input: unknown) => { if (!localRuntimeConnectionPool) return DEFAULT_ATTENTION_PREFERENCES; const record = isRecord(input) ? input : {}; - const accountOwnerId = - typeof record.accountOwnerId === "string" ? record.accountOwnerId.trim() : ""; - if (!accountOwnerId) { - throw new Error("A valid Attention account owner is required."); - } + const accountOwnerId = requireCurrentAttentionAccountOwner(record.accountOwnerId); return await localRuntimeConnectionPool.callAttention( "getPreferences", { accountOwnerId }, @@ -3256,11 +3273,7 @@ export function registerIpc({ if (!isRecord(input) || !isRecord(input.preferences)) { throw new Error("A valid Attention preferences payload is required."); } - const accountOwnerId = - typeof input.accountOwnerId === "string" ? input.accountOwnerId.trim() : ""; - if (!accountOwnerId) { - throw new Error("A valid Attention account owner is required."); - } + const accountOwnerId = requireCurrentAttentionAccountOwner(input.accountOwnerId); await localRuntimeConnectionPool.callAttention( "putPreferences", { @@ -3272,7 +3285,7 @@ export function registerIpc({ ipcMain.handle(IPC.attentionOpenItem, async (_event, input: unknown) => { const snapshot = parseAttentionNotchSnapshot({ - contractVersion: 1, + contractVersion: ATTENTION_CONTRACT_VERSION, streamId: null, revision: ( typeof input === "object" diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index 4a64ed87a..5a1b40944 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { IPC } from "../../../shared/ipc"; +import { ATTENTION_CONTRACT_VERSION, type AttentionItem } from "../../../shared/types/attention"; import type { OpenProjectBinding, RemoteRuntimeTarget, @@ -1461,7 +1462,7 @@ describe("registerIpc sync bridge", () => { it("routes account Attention through the machine runtime without a project binding", async () => { const snapshot = { - contractVersion: 1, + contractVersion: ATTENTION_CONTRACT_VERSION, streamId: "account-stream", revision: 5, generatedAt: "2026-07-28T12:00:00.000Z", @@ -1473,11 +1474,13 @@ describe("registerIpc sync bridge", () => { if (action === "getPreferences") return { account: { hideDetails: true } }; return undefined; }); + const openAttentionItem = vi.fn(async () => undefined); registerIpc({ getCtx: () => ({ logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, }) as any, localRuntimeConnectionPool: { callAttention } as any, + getCurrentAccountOwnerId: () => "account-a", getWindowSession: () => ({ windowId: 7, project: null, @@ -1495,6 +1498,7 @@ describe("registerIpc sync bridge", () => { closeCurrentProject: vi.fn(), closeProjectByPath: vi.fn(), globalStatePath: "/tmp/ade-state.json", + openAttentionItem, }); await expect( @@ -1523,6 +1527,47 @@ describe("registerIpc sync bridge", () => { preferences: { account: { hideDetails: false } }, }, ); + const attentionItem: AttentionItem = { + contractVersion: ATTENTION_CONTRACT_VERSION, + id: "attention-1", + revision: 5, + fingerprint: "attention-1:5", + kind: "agent", + eventKind: "agent_needs_you", + phase: "needs_you", + machine: { + machineKey: "machine-a", + name: "Machine A", + online: true, + lastSeenAt: "2026-07-28T12:00:00.000Z", + }, + project: { + projectId: "project-a", + name: "Project A", + rootPath: "/repo", + }, + provider: "codex", + model: "gpt-5", + title: "Needs approval", + preview: "Approve the command", + privacyPreview: "Agent needs attention", + detail: null, + recentActivity: [], + planProgress: null, + destination: { + kind: "session", + sessionId: "session-a", + itemId: "attention-1", + eventId: null, + }, + actions: [], + occurredAt: "2026-07-28T12:00:00.000Z", + updatedAt: "2026-07-28T12:00:00.000Z", + seenAt: null, + dismissedAt: null, + expiresAt: null, + }; + await ipcHandlers.get(IPC.attentionOpenItem)?.(eventForSender(), attentionItem); expect(callAttention.mock.calls.map(([action]) => action)).toEqual([ "getSnapshot", @@ -1538,6 +1583,35 @@ describe("registerIpc sync bridge", () => { accountOwnerId: "account-a", preferences: { account: { hideDetails: false } }, }); + expect(openAttentionItem).toHaveBeenCalledWith(attentionItem); + }); + + it("rejects a stale renderer Attention preference owner before calling the runtime", async () => { + const callAttention = vi.fn(); + registerIpc({ + getCtx: () => ({ + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, + }) as any, + localRuntimeConnectionPool: { callAttention } as any, + getCurrentAccountOwnerId: () => "account-b", + switchProjectFromDialog: vi.fn(), + closeCurrentProject: vi.fn(), + closeProjectByPath: vi.fn(), + globalStatePath: "/tmp/ade-state.json", + }); + + await expect( + ipcHandlers.get(IPC.attentionGetPreferences)?.(eventForSender(), { + accountOwnerId: "account-a", + }), + ).rejects.toThrow(/account changed/i); + await expect( + ipcHandlers.get(IPC.attentionPutPreferences)?.(eventForSender(), { + accountOwnerId: "account-a", + preferences: { account: { hideDetails: true } }, + }), + ).rejects.toThrow(/account changed/i); + expect(callAttention).not.toHaveBeenCalled(); }); it("validates recovery identifiers and target ownership before mutating chat state", async () => { diff --git a/apps/desktop/src/renderer/components/app/TabNav.test.tsx b/apps/desktop/src/renderer/components/app/TabNav.test.tsx index aec915a87..2c06abf69 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.test.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.test.tsx @@ -2,7 +2,7 @@ import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { TabNav } from "./TabNav"; import { useAppStore } from "../../state/appStore"; @@ -10,6 +10,7 @@ import { useAppStore } from "../../state/appStore"; function resetStore() { useAppStore.setState({ project: { rootPath: "/Users/arul/ADE", name: "ADE" } as any, + projectBinding: null, projectHydrated: true, showWelcome: false, selectedLaneId: "lane-1", @@ -47,6 +48,7 @@ describe("TabNav", () => { }); afterEach(() => { + vi.useRealTimers(); cleanup(); Object.defineProperty(globalThis.window, "ade", { configurable: true, @@ -81,4 +83,28 @@ describe("TabNav", () => { expect(screen.getByRole("link", { name: "Work" }).getAttribute("aria-disabled")).toBe("true"); expect(screen.getByRole("link", { name: "Review" }).getAttribute("aria-disabled")).toBe("true"); }); + + it("describes projectless Attention navigation as available", () => { + vi.useFakeTimers(); + useAppStore.setState({ + project: null, + projectBinding: null, + showWelcome: true, + smartTooltipsEnabled: true, + } as any); + + render( + + + , + ); + + const attention = screen.getByRole("link", { name: "Attention" }); + fireEvent.mouseEnter(attention.parentElement as HTMLElement); + act(() => vi.advanceTimersByTime(321)); + + const tooltip = screen.getByRole("tooltip"); + expect(tooltip.textContent).toContain("Opens Attention."); + expect(tooltip.textContent).not.toContain("Open or create a project first."); + }); }); diff --git a/apps/desktop/src/renderer/components/app/TabNav.tsx b/apps/desktop/src/renderer/components/app/TabNav.tsx index 4df2d661b..691921855 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.tsx @@ -163,13 +163,17 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) const tooltip: SmartTooltipContent = { label: it.label, description: tooltipBase?.description ?? `Open the ${it.label} tab.`, - effect: !hasActiveProject - ? "Open or create a project first." - : showWelcome - ? "Finish choosing a project before navigating." - : isActive - ? "Already viewing this tab." - : `Opens ${it.label}.`, + effect: globallyAvailable + ? isActive + ? "Already viewing this tab." + : `Opens ${it.label}.` + : !hasActiveProject + ? "Open or create a project first." + : showWelcome + ? "Finish choosing a project before navigating." + : isActive + ? "Already viewing this tab." + : `Opens ${it.label}.`, docUrl: tooltipBase?.docUrl, }; diff --git a/apps/desktop/src/renderer/components/attention/AttentionCenter.test.tsx b/apps/desktop/src/renderer/components/attention/AttentionCenter.test.tsx index 73621e14f..6f031300e 100644 --- a/apps/desktop/src/renderer/components/attention/AttentionCenter.test.tsx +++ b/apps/desktop/src/renderer/components/attention/AttentionCenter.test.tsx @@ -396,6 +396,84 @@ describe("AttentionCenter", () => { }); }); + it("clears a closed save without letting its stale result interrupt a replacement", async () => { + let resolveStaleSave: () => void = () => {}; + const staleSave = new Promise((resolve) => { + resolveStaleSave = resolve; + }); + let resolveReplacementSave: () => void = () => {}; + const replacementSave = new Promise((resolve) => { + resolveReplacementSave = resolve; + }); + const putPreferences = vi.fn() + .mockImplementationOnce(() => staleSave) + .mockImplementationOnce(() => replacementSave); + const updateSettings = vi.fn(async () => undefined); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(window.ade ?? {}), + attention: { + getSnapshot: vi.fn(), + acknowledge: vi.fn(), + reportPresence: vi.fn(), + getPreferences: vi.fn(async () => DEFAULT_ATTENTION_PREFERENCES), + putPreferences, + }, + attentionNotch: { + publishSnapshot: vi.fn(), + updateSettings, + onAcknowledgeRequested: vi.fn(() => () => {}), + }, + }, + }); + render(); + + const trigger = screen.getByRole("button", { name: "Attention settings" }); + fireEvent.click(trigger); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Save" })).toBeTruthy(); + }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Saving…" })).toBeTruthy(); + }); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await waitFor(() => { + expect(screen.queryByRole("dialog", { name: "Attention settings" })).toBeNull(); + }); + + fireEvent.click(trigger); + await waitFor(() => { + expect( + (screen.getByRole("button", { name: "Save" }) as HTMLButtonElement).disabled, + ).toBe(false); + }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => { + expect(putPreferences).toHaveBeenCalledTimes(2); + expect(screen.getByRole("button", { name: "Saving…" })).toBeTruthy(); + }); + + await act(async () => { + resolveStaleSave(); + await staleSave; + }); + + expect(updateSettings).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Saving…" })).toBeTruthy(); + + await act(async () => { + resolveReplacementSave(); + await replacementSave; + }); + await waitFor(() => { + expect(updateSettings).toHaveBeenCalledTimes(1); + expect(screen.getByText("Saved")).toBeTruthy(); + }); + }); + it("does not apply an earlier account's delayed preferences after switching accounts", async () => { let resolveAccountA: (preferences: typeof DEFAULT_ATTENTION_PREFERENCES) => void = () => {}; diff --git a/apps/desktop/src/renderer/components/attention/AttentionSettingsPopover.tsx b/apps/desktop/src/renderer/components/attention/AttentionSettingsPopover.tsx index 78b4cc33b..54e94dfa7 100644 --- a/apps/desktop/src/renderer/components/attention/AttentionSettingsPopover.tsx +++ b/apps/desktop/src/renderer/components/attention/AttentionSettingsPopover.tsx @@ -109,6 +109,7 @@ export function AttentionSettingsPopover() { const closePopover = (returnFocus: boolean) => { requestGenerationRef.current += 1; restoreTriggerFocusRef.current = returnFocus; + setSaving(false); setOpen(false); }; @@ -227,7 +228,8 @@ export function AttentionSettingsPopover() { const save = async () => { if (saving) return; const ownerId = accountOwnerId; - const generation = requestGenerationRef.current; + const generation = requestGenerationRef.current + 1; + requestGenerationRef.current = generation; const isCurrentRequest = () => requestGenerationRef.current === generation && accountOwnerRef.current === ownerId; @@ -245,8 +247,11 @@ export function AttentionSettingsPopover() { await window.ade?.attentionNotch?.updateSettings( attentionNotchSettingsFromPreferences(preferences, notchEnabled), ); + if (!isCurrentRequest()) return; setSaved(true); - window.setTimeout(() => setSaved(false), 1_800); + window.setTimeout(() => { + if (isCurrentRequest()) setSaved(false); + }, 1_800); } catch (saveError) { if (!isCurrentRequest()) return; setError( diff --git a/apps/ios/ADE/App/DeepLinkRouter.swift b/apps/ios/ADE/App/DeepLinkRouter.swift index 0d5343e0e..25942e490 100644 --- a/apps/ios/ADE/App/DeepLinkRouter.swift +++ b/apps/ios/ADE/App/DeepLinkRouter.swift @@ -208,14 +208,8 @@ final class DeepLinkRouter { postSendToMac(url: url) case "pr": guard let number = ADEDeepLinkURLParsing.positiveInteger(query["number"]) else { return true } - if let accountMachineKey = query["accountmachinekey"], - !ADEDeepLinkURLParsing.isValidOpaqueId(accountMachineKey) { - return true - } - if let eventId = query["event"], - !ADEDeepLinkURLParsing.isValidOpaqueId(eventId) { - return true - } + let accountMachineKey = accountMachineKey(from: url) + let eventId = eventId(from: url) let detailTab = prDetailTab(from: query["tab"]) if query["repo"]?.isEmpty ?? true { post( @@ -223,8 +217,8 @@ final class DeepLinkRouter { identifier: "\(number)", prNumber: number, detailTab: detailTab, - accountMachineKey: query["accountmachinekey"], - eventId: query["event"] + accountMachineKey: accountMachineKey, + eventId: eventId ) return true } @@ -236,8 +230,8 @@ final class DeepLinkRouter { repoOwner: repo.owner, repoName: repo.repo, detailTab: detailTab, - accountMachineKey: query["accountmachinekey"], - eventId: query["event"] + accountMachineKey: accountMachineKey, + eventId: eventId ) case "linear-issue": guard let identifier = query["issue"], diff --git a/apps/ios/ADE/Services/AccountService.swift b/apps/ios/ADE/Services/AccountService.swift index 68dbb9ea6..ceec6107e 100644 --- a/apps/ios/ADE/Services/AccountService.swift +++ b/apps/ios/ADE/Services/AccountService.swift @@ -526,6 +526,9 @@ final class AccountService: ObservableObject { LiveActivityService.shared.prepareForAccountSignOut() } deviceOwnershipStore.transition(to: nextIdentity.userId) + // A live signed-in owner has committed the account boundary, including + // non-interactive Clerk session restoration. + isEndingAccountOwnership = false if identity?.userId != nextIdentity.userId { invalidatePairingAuthorization() clearAttentionSnapshot() diff --git a/apps/ios/ADE/Shared/ADEAgentActivityAttributes.swift b/apps/ios/ADE/Shared/ADEAgentActivityAttributes.swift index 5f5b2b134..20f9bc9ca 100644 --- a/apps/ios/ADE/Shared/ADEAgentActivityAttributes.swift +++ b/apps/ios/ADE/Shared/ADEAgentActivityAttributes.swift @@ -248,7 +248,7 @@ public struct ADEAgentRunsAttributes: ActivityAttributes { } public var isAccountWide: Bool { - if accountWide == true { return true } + if let accountWide { return accountWide } let marker = machineName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return marker == "all machines" || marker == "account" } diff --git a/apps/ios/ADE/Shared/ADESharedModels.swift b/apps/ios/ADE/Shared/ADESharedModels.swift index b49eb6ff3..6d938bacc 100644 --- a/apps/ios/ADE/Shared/ADESharedModels.swift +++ b/apps/ios/ADE/Shared/ADESharedModels.swift @@ -548,7 +548,7 @@ public struct AccountAttentionItem: Codable, Hashable, Identifiable, Sendable { public let kind: AccountAttentionItemKind public let eventKind: AccountAttentionEventKind public let phase: AccountAttentionPhase - public let machine: AccountAttentionMachine + public private(set) var machine: AccountAttentionMachine public let project: AccountAttentionProject public let laneId: String? public let laneName: String? @@ -624,6 +624,23 @@ public struct AccountAttentionItem: Codable, Hashable, Identifiable, Sendable { self.expiresAt = expiresAt } + fileprivate func updatingMachinePresence( + from presence: AccountAttentionMachine? + ) -> AccountAttentionItem { + guard let presence, presence.machineKey == machine.machineKey else { + return self + } + var updated = self + updated.machine = AccountAttentionMachine( + machineKey: machine.machineKey, + accountMachineKey: presence.accountMachineKey ?? machine.accountMachineKey, + name: presence.name, + online: presence.online, + lastSeenAt: presence.lastSeenAt + ) + return updated + } + public var isLive: Bool { switch phase { case .starting, .running, .needsYou, .blocked, .failed, .stale, @@ -666,6 +683,9 @@ public struct AccountAttentionSnapshot: Codable, Hashable, Sendable { public let streamId: String? public let revision: Int public let generatedAt: Date + /// Current account-machine presence. Relay includes this even when no + /// attention items changed, so cached items can refresh their scope state. + public let machines: [AccountAttentionMachine]? public let items: [AccountAttentionItem] public let tombstones: [AccountAttentionTombstone]? @@ -674,6 +694,7 @@ public struct AccountAttentionSnapshot: Codable, Hashable, Sendable { streamId: String? = nil, revision: Int, generatedAt: Date, + machines: [AccountAttentionMachine]? = nil, items: [AccountAttentionItem], tombstones: [AccountAttentionTombstone]? = nil ) { @@ -681,6 +702,7 @@ public struct AccountAttentionSnapshot: Codable, Hashable, Sendable { self.streamId = streamId self.revision = revision self.generatedAt = generatedAt + self.machines = machines self.items = items self.tombstones = tombstones } @@ -697,10 +719,12 @@ public struct AccountAttentionSnapshot: Codable, Hashable, Sendable { // resets any legacy/unknown account data. if let incomingStreamId = delta.streamId, incomingStreamId != streamId { - return delta + return normalizedAccountAttentionSnapshot(delta) } guard delta.revision >= revision else { return self } - var byId = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) }) + var byId = Dictionary(items.map { ($0.id, $0) }) { lhs, rhs in + rhs.revision > lhs.revision ? rhs : lhs + } for item in delta.items { if let existing = byId[item.id], existing.revision > item.revision { continue @@ -714,15 +738,42 @@ public struct AccountAttentionSnapshot: Codable, Hashable, Sendable { } byId.removeValue(forKey: tombstone.id) } - return AccountAttentionSnapshot( + return normalizedAccountAttentionSnapshot(AccountAttentionSnapshot( contractVersion: contractVersion, streamId: delta.streamId ?? streamId, revision: delta.revision, generatedAt: delta.generatedAt, + machines: delta.machines ?? machines, items: Array(byId.values), tombstones: delta.tombstones + )) + } +} + +private func normalizedAccountAttentionSnapshot( + _ snapshot: AccountAttentionSnapshot +) -> AccountAttentionSnapshot { + let machinesByKey = Dictionary( + (snapshot.machines ?? []).map { ($0.machineKey, $0) }, + uniquingKeysWith: { _, latest in latest } + ) + let itemsById = Dictionary(snapshot.items.map { item in + ( + item.id, + item.updatingMachinePresence(from: machinesByKey[item.machine.machineKey]) ) + }) { lhs, rhs in + rhs.revision > lhs.revision ? rhs : lhs } + return AccountAttentionSnapshot( + contractVersion: snapshot.contractVersion, + streamId: snapshot.streamId, + revision: snapshot.revision, + generatedAt: snapshot.generatedAt, + machines: snapshot.machines, + items: Array(itemsById.values), + tombstones: snapshot.tombstones + ) } /// Centralizes the read-before-commit merge used by account Attention refresh. @@ -732,5 +783,5 @@ public func accountAttentionSnapshotForCommit( current: AccountAttentionSnapshot?, incoming: AccountAttentionSnapshot ) -> AccountAttentionSnapshot { - current?.merging(incoming) ?? incoming + current?.merging(incoming) ?? normalizedAccountAttentionSnapshot(incoming) } diff --git a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift index 9822439c5..1643d471f 100644 --- a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift +++ b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift @@ -476,16 +476,16 @@ public final class AttentionDrawerModel: ObservableObject { /// dismissal is scoped to the active attention IDs and is pruned once the /// backing state clears, so a future CI/review/agent regression reappears. public func clearVisibleItems() { - guard !items.isEmpty else { - markAllSeen() - return - } + let visible = visibleItems(in: .needsYou) + guard !visible.isEmpty else { return } - dismissedItemIDs.formUnion(items.map(\.id)) - let accountIds = accountBackedItemIDs.intersection(Set(items.map(\.id))) + let visibleIds = Set(visible.map(\.id)) + dismissedItemIDs.formUnion(visibleIds) + let accountIds = accountBackedItemIDs.intersection(visibleIds) persistDismissedItems() - items.removeAll() - markAllSeen() + items.removeAll { visibleIds.contains($0.id) } + validateSelectedProject() + recomputeUnreadCount() if !accountIds.isEmpty { Task { await AccountService.shared.acknowledgeAttentionItems(Array(accountIds), dismiss: true) } } diff --git a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift index 3e7e7cb48..dbd639ba5 100644 --- a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift +++ b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift @@ -43,7 +43,7 @@ struct AttentionDrawerSheet: View { } label: { Label("Dismiss pending", systemImage: "rectangle.stack.badge.minus") } - .disabled(drawer.items.isEmpty) + .disabled(needsYou.isEmpty) } label: { Image(systemName: "ellipsis.circle") } diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index f75f4fa65..bb73d293e 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -819,6 +819,40 @@ final class ADETests: XCTestCase { XCTAssertEqual(service.requestedPrNavigation?.detailTab, .files) } + @MainActor + func testDeepLinkRouterDropsInvalidOptionalHttpsPrScope() throws { + let previousShared = SyncService.shared + defer { SyncService.shared = previousShared } + + let database = makeDatabase(baseURL: makeTemporaryDirectory()) + defer { database.close() } + let service = SyncService(database: database) + SyncService.shared = service + + DeepLinkRouter.shared.handle(try XCTUnwrap(URL(string: + "https://ade-app.dev/open?type=pr&repo=arul/ADE&number=42&accountMachineKey=not%20valid&event=%2F" + ))) + + XCTAssertEqual( + service.requestedPrNavigation?.target, + .githubNumber(42, repoOwner: "arul", repoName: "ADE") + ) + XCTAssertNil(service.requestedPrNavigation?.accountMachineKey) + XCTAssertNil(service.requestedPrNavigation?.eventId) + + service.requestedPrNavigation = nil + DeepLinkRouter.shared.handle(try XCTUnwrap(URL(string: + "https://ade-app.dev/open?type=pr&number=43&accountMachineKey=%2F&event=not%20valid" + ))) + + XCTAssertEqual( + service.requestedPrNavigation?.target, + .githubNumber(43, repoOwner: nil, repoName: nil) + ) + XCTAssertNil(service.requestedPrNavigation?.accountMachineKey) + XCTAssertNil(service.requestedPrNavigation?.eventId) + } + func testSendToMacTargetParsesHttpsAdePrLinks() throws { let target = SendToMacTarget( url: try XCTUnwrap(URL(string: "https://ade-app.dev/open?type=pr&repo=arul/ADE&number=42")) diff --git a/apps/ios/ADETests/AttentionDrawerModelTests.swift b/apps/ios/ADETests/AttentionDrawerModelTests.swift index ad7054f2d..e60c9ad56 100644 --- a/apps/ios/ADETests/AttentionDrawerModelTests.swift +++ b/apps/ios/ADETests/AttentionDrawerModelTests.swift @@ -388,6 +388,73 @@ final class AttentionDrawerModelTests: XCTestCase { ) } + func testAccountSnapshotRefreshesCachedMachinePresenceWithoutItemChanges() { + let now = Date() + let cachedMachine = AccountAttentionMachine( + machineKey: "studio", + accountMachineKey: "account-studio", + name: "Studio Mac", + online: false, + lastSeenAt: now.addingTimeInterval(-120) + ) + let current = AccountAttentionSnapshot( + revision: 8, + generatedAt: now, + machines: [cachedMachine], + items: [ + makeAccountItem( + id: "cached", + revision: 8, + title: "Cached", + now: now, + machine: cachedMachine + ), + ] + ) + let refreshedPresence = AccountAttentionMachine( + machineKey: "studio", + name: "Studio Mac", + online: true, + lastSeenAt: now.addingTimeInterval(1) + ) + let unchangedRevision = AccountAttentionSnapshot( + revision: 8, + generatedAt: now.addingTimeInterval(1), + machines: [refreshedPresence], + items: [] + ) + + let merged = current.merging(unchangedRevision) + + XCTAssertEqual(merged.items.count, 1) + XCTAssertTrue(merged.items[0].machine.online) + XCTAssertEqual(merged.items[0].machine.lastSeenAt, refreshedPresence.lastSeenAt) + XCTAssertEqual( + merged.items[0].machine.accountMachineKey, + cachedMachine.accountMachineKey, + "Presence-only rows must not erase the canonical routing identity" + ) + XCTAssertEqual(merged.machines, [refreshedPresence]) + } + + func testAccountSnapshotDuplicateItemIdsKeepHighestRevision() { + let now = Date() + let incoming = AccountAttentionSnapshot( + revision: 7, + generatedAt: now, + items: [ + makeAccountItem(id: "duplicate", revision: 2, title: "Older", now: now), + makeAccountItem(id: "duplicate", revision: 7, title: "Newest", now: now), + ] + ) + + let committed = accountAttentionSnapshotForCommit(current: nil, incoming: incoming) + + XCTAssertEqual(committed.items.count, 1) + XCTAssertEqual(committed.items[0].revision, 7) + XCTAssertEqual(committed.items[0].title, "Newest") + } + func testOutOfOrderSnapshotCommitCannotRegressRevisionOrDropNewerItems() { let now = Date() let base = AccountAttentionSnapshot( @@ -630,6 +697,48 @@ final class AttentionDrawerModelTests: XCTestCase { XCTAssertTrue(freshModel.items.isEmpty, "persisted dismissals should hide the same still-active attention") } + func testClearVisibleItemsOnlyDismissesNeedsYouItemsInSelectedProject() { + let model = AttentionDrawerModel(defaults: defaults) + let now = Date() + model.rebuild(from: AccountAttentionSnapshot( + revision: 2, + generatedAt: now, + items: [ + makeAccountItem( + id: "project-a", + revision: 1, + title: "Project A", + now: now, + eventKind: .agentNeedsYou, + phase: .needsYou, + projectId: "a", + projectName: "Project A" + ), + makeAccountItem( + id: "project-b", + revision: 2, + title: "Project B", + now: now, + eventKind: .agentNeedsYou, + phase: .needsYou, + projectId: "b", + projectName: "Project B" + ), + ] + )) + model.selectProject("a") + + model.clearVisibleItems() + + XCTAssertEqual(model.items.map(\.id), ["project-b"]) + XCTAssertNil(model.selectedProjectId) + XCTAssertEqual(model.unreadCount, 1) + XCTAssertEqual( + Set(defaults.stringArray(forKey: AttentionDrawerModel.dismissedItemIDsKey) ?? []), + ["project-a"] + ) + } + func testClearedItemsReappearAfterBackingStateClears() { let model = AttentionDrawerModel(defaults: defaults) let now = Date() @@ -839,22 +948,27 @@ final class AttentionDrawerModelTests: XCTestCase { id: String, revision: Int, title: String, - now: Date + now: Date, + machine: AccountAttentionMachine? = nil, + eventKind: AccountAttentionEventKind = .agentRunning, + phase: AccountAttentionPhase = .running, + projectId: String = "ade", + projectName: String = "ADE" ) -> AccountAttentionItem { AccountAttentionItem( id: id, revision: revision, fingerprint: "\(id):\(revision)", kind: .agent, - eventKind: .agentRunning, - phase: .running, - machine: .init( + eventKind: eventKind, + phase: phase, + machine: machine ?? .init( machineKey: "studio", name: "Studio Mac", online: true, lastSeenAt: now ), - project: .init(projectId: "ade", name: "ADE"), + project: .init(projectId: projectId, name: projectName), title: title, preview: "Working", privacyPreview: "Agent working", diff --git a/apps/ios/ADETests/PairingAndDpopTests.swift b/apps/ios/ADETests/PairingAndDpopTests.swift index c4b719745..9e4de0278 100644 --- a/apps/ios/ADETests/PairingAndDpopTests.swift +++ b/apps/ios/ADETests/PairingAndDpopTests.swift @@ -702,12 +702,14 @@ final class PairingAndDpopTests: XCTestCase { XCTAssertTrue( ADEAgentRunsAttributes(machineName: "All machines").isAccountWide ) - XCTAssertFalse( - ADEAgentRunsAttributes( - machineName: "Studio Mac", - accountWide: false - ).isAccountWide - ) + for legacyMarker in ["All machines", "account"] { + XCTAssertFalse( + ADEAgentRunsAttributes( + machineName: legacyMarker, + accountWide: false + ).isAccountWide + ) + } } // MARK: - Sealed account adoption diff --git a/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift b/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift index b28d30020..6a04f746f 100644 --- a/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift +++ b/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift @@ -623,6 +623,7 @@ private struct AgentRunRow: View { .foregroundStyle(phase.needsAttention ? phase.tint : .secondary) .lineLimit(1) } + .contentShape(Rectangle()) } /// Approve / Deny capsules, aligned under the title (past the status glyph). diff --git a/apps/push-relay/src/attention.ts b/apps/push-relay/src/attention.ts index e88aff4cf..0307e3644 100644 --- a/apps/push-relay/src/attention.ts +++ b/apps/push-relay/src/attention.ts @@ -87,6 +87,7 @@ const ACCOUNT_MACHINE_ONLINE_WINDOW_MS = 90_000; const DEFAULT_DESKTOP_ESCALATION_DELAY_SECONDS = 30; const DESKTOP_PRESENCE_WINDOW_MS = 45_000; const TOMBSTONE_RETENTION_MS = 24 * 60 * 60 * 1_000; +const MAX_OWNERSHIP_EPOCH_FUTURE_MS = 5 * 60 * 1_000; const remoteJwksByUrl = new Map>(); const EVENT_KINDS = new Set([ @@ -1285,6 +1286,7 @@ function parsedOwnershipEpoch(value: unknown): number | null { return typeof value === "number" && Number.isSafeInteger(value) && value > 0 + && value <= Date.now() + MAX_OWNERSHIP_EPOCH_FUTURE_MS ? value : null; } @@ -1970,9 +1972,16 @@ async function handlePresence( if (!deviceId || !observedAt) { return json({ ok: false, error: "invalid presence" }, { status: 400 }); } + const platform = payload.platform === "macOS" + || payload.platform === "iOS" + || payload.platform === "web" + || payload.platform === "unknown" + ? payload.platform + : "unknown"; const stored = { - ...payload, deviceId, + platform, + appForeground: payload.appForeground === true, observedAt, visibleItemIds, }; diff --git a/apps/push-relay/test/attention.test.ts b/apps/push-relay/test/attention.test.ts index d9f2e7720..beb31c823 100644 --- a/apps/push-relay/test/attention.test.ts +++ b/apps/push-relay/test/attention.test.ts @@ -611,6 +611,88 @@ describe("account Attention contract", () => { } }); + it("accepts the ownership epoch future boundary and rejects values beyond it", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T08:00:00.000Z")); + const database = new SqliteD1Database(); + try { + const maximumAcceptedEpoch = Date.now() + 5 * 60 * 1_000; + const accepted = await accountRoute( + database, + "account-a", + "PUT", + "/attention/account/devices/phone-boundary", + { + ownershipEpoch: maximumAcceptedEpoch, + apnsToken: "91".repeat(32), + bundleId: "com.ade.ios", + apsEnvironment: "sandbox", + }, + ); + expect(accepted.status).toBe(200); + + const rejected = await accountRoute( + database, + "account-a", + "PUT", + "/attention/account/devices/phone-too-far", + { + ownershipEpoch: maximumAcceptedEpoch + 1, + apnsToken: "92".repeat(32), + bundleId: "com.ade.ios", + apsEnvironment: "sandbox", + }, + ); + expect(rejected.status).toBe(400); + expect(await rejected.json()).toEqual({ + ok: false, + error: "invalid ownership epoch", + }); + } finally { + database.close(); + } + }); + + it("persists only the consumed presence fields", async () => { + const database = new SqliteD1Database(); + try { + const observedAt = "2026-07-28T08:00:00.000Z"; + const response = await accountRoute( + database, + "account-a", + "POST", + "/attention/account/presence", + { + deviceId: "desktop-1", + deviceName: "Studio", + platform: "macOS", + appForeground: true, + ambientSurfaceVisible: true, + visibleItemIds: ["agent-1", "", "agent-2"], + observedAt, + untrusted: "x".repeat(300_000), + }, + ); + expect(response.status).toBe(200); + + const presence = row<{ payload_json: string }>(database, ` + select payload_json + from attention_presence + where user_id = 'account-a' and device_id = 'desktop-1' + `); + expect(JSON.parse(presence?.payload_json ?? "{}")).toEqual({ + deviceId: "desktop-1", + platform: "macOS", + appForeground: true, + observedAt, + visibleItemIds: ["agent-1", "agent-2"], + }); + expect(presence?.payload_json.length).toBeLessThan(1_000); + } finally { + database.close(); + } + }); + it("imports legacy routes once without reclaiming a phone that changed accounts", async () => { const database = new SqliteD1Database(); const env = makeAttentionEnv(database); diff --git a/apps/push-relay/test/relay.test.ts b/apps/push-relay/test/relay.test.ts index c85f888b6..d7fd5170c 100644 --- a/apps/push-relay/test/relay.test.ts +++ b/apps/push-relay/test/relay.test.ts @@ -56,6 +56,14 @@ class FakeD1Database { return new FakeD1Statement(sql, this); } + async batch(statements: FakeD1Statement[]): Promise> { + const results: Array<{ success: boolean }> = []; + for (const statement of statements) { + results.push(await statement.run()); + } + return results; + } + first(sql: string, values: unknown[]): T | null { if (sql.includes("from machines")) { const [machineKey] = values; From 3cf26c2ca4f50a4c6006c0352275b6e95dd43593 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:53:24 -0400 Subject: [PATCH 4/6] =?UTF-8?q?ship:=20iteration=203=20=E2=80=94=20address?= =?UTF-8?q?=20Attention=20lifecycle=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../push/pushPublisherService.test.ts | 8 +- .../src/services/push/pushPublisherService.ts | 7 +- .../migrations/0003_account_attention.sql | 1 + apps/push-relay/src/attention.ts | 177 ++++++++++++---- apps/push-relay/test/attention.test.ts | 192 ++++++++++++++++++ 5 files changed, 339 insertions(+), 46 deletions(-) diff --git a/apps/ade-cli/src/services/push/pushPublisherService.test.ts b/apps/ade-cli/src/services/push/pushPublisherService.test.ts index 358738abc..546a39f45 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.test.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.test.ts @@ -298,11 +298,13 @@ describe("createPushPublisherService flush", () => { await vi.advanceTimersByTimeAsync(200); expect(publish).toHaveBeenCalledTimes(1); - expect(publish.mock.calls[0][0].notifications).toEqual( + const payload = publish.mock.calls[0][0]; + expect(payload.notifications).toEqual( expect.arrayContaining([ expect.objectContaining({ dedupeKey: "alert:s-1:approval" }), ]), ); + expect(payload.liveActivity).toBeUndefined(); publisher.dispose(); }); @@ -329,11 +331,13 @@ describe("createPushPublisherService flush", () => { expect(publishAttention).toHaveBeenCalledTimes(1); expect(publish).toHaveBeenCalledTimes(1); - expect(publish.mock.calls[0][0].notifications).toEqual( + const payload = publish.mock.calls[0][0]; + expect(payload.notifications).toEqual( expect.arrayContaining([ expect.objectContaining({ dedupeKey: "alert:s-1:approval" }), ]), ); + expect(payload.liveActivity).toBeUndefined(); publisher.dispose(); }); diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index af9947eaa..280861e0f 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -984,6 +984,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { await resolveMissingMeta(); const attentionPublishResult = await publishAttentionSnapshot(nowMs); const accountAttentionPublished = attentionPublishResult === "published"; + const accountAttentionAvailable = attentionPublishResult !== "unavailable"; if (isGated()) { pendingAlerts = []; return; @@ -1066,7 +1067,11 @@ export function createPushPublisherService(deps: PushPublisherDeps) { const liveActivityDeviceIds = devices .filter((device) => Boolean(device.pushToStartToken) && shouldDeliverLiveActivityForPrefs(device.prefs)) .map((device) => device.deviceId); - const laPlan = accountAttentionPublished + // Once account Attention has accepted this machine's snapshot, it owns the + // Live Activity even when this flush is unchanged or relay-suppressed. + // Queued alerts still fall back above unless the account publish actually + // emitted the changed snapshot. + const laPlan = accountAttentionAvailable ? null : planLiveActivity(liveActivityDeviceIds, nowMs); diff --git a/apps/push-relay/migrations/0003_account_attention.sql b/apps/push-relay/migrations/0003_account_attention.sql index 27eb30895..b0947b85d 100644 --- a/apps/push-relay/migrations/0003_account_attention.sql +++ b/apps/push-relay/migrations/0003_account_attention.sql @@ -46,6 +46,7 @@ create table if not exists attention_tombstones ( item_id text not null, source_revision integer not null, account_revision integer not null, + revivable integer not null default 0 check (revivable in (0, 1)), deleted_at text not null, primary key(user_id, item_id) ); diff --git a/apps/push-relay/src/attention.ts b/apps/push-relay/src/attention.ts index 0307e3644..ac7cc0eaa 100644 --- a/apps/push-relay/src/attention.ts +++ b/apps/push-relay/src/attention.ts @@ -33,6 +33,12 @@ type AttentionTombstoneRow = { deleted_at: string; }; +type IncomingAttentionTombstone = { + id: string; + revision: number; + revivable: boolean; +}; + type AttentionDeviceRow = { device_id: string; apns_token: string | null; @@ -323,6 +329,85 @@ function attentionFullSnapshotUnchanged( }); } +function implicitFullSnapshotTombstone( + item: { item_id: string; source_revision: number }, + incomingItemCount: number, +): IncomingAttentionTombstone { + return { + id: item.item_id, + revision: Math.max(0, Number(item.source_revision) || 0), + // A full snapshot at the transport ceiling cannot distinguish a true + // removal from an item displaced by a higher-priority row. Keep that + // omission revivable until a later below-capacity snapshot confirms it. + revivable: incomingItemCount === MAX_ATTENTION_ITEMS, + }; +} + +function attentionTombstoneBlocksItem( + tombstone: { source_revision: number; revivable: number }, + itemRevision: number, +): boolean { + return Number(tombstone.revivable) !== 1 + && Number(tombstone.source_revision) >= itemRevision; +} + +async function upsertAttentionTombstone( + env: AttentionRelayEnv, + args: { + userId: string; + itemId: string; + sourceRevision: number; + accountRevision: number; + deletedAt: string; + revivable: boolean; + }, +): Promise { + await env.DB.prepare(` + insert into attention_tombstones( + user_id, item_id, source_revision, account_revision, revivable, deleted_at + ) + values (?, ?, ?, ?, ?, ?) + on conflict(user_id, item_id) do update set + source_revision = excluded.source_revision, + account_revision = excluded.account_revision, + revivable = case + when excluded.source_revision > attention_tombstones.source_revision + then excluded.revivable + else min(attention_tombstones.revivable, excluded.revivable) + end, + deleted_at = excluded.deleted_at + where excluded.source_revision >= attention_tombstones.source_revision + `).bind( + args.userId, + args.itemId, + args.sourceRevision, + args.accountRevision, + args.revivable ? 1 : 0, + args.deletedAt, + ).run(); +} + +async function sealCapacityTombstones( + env: AttentionRelayEnv, + userId: string, + machineKey: string, +): Promise { + await env.DB.prepare(` + update attention_tombstones + set revivable = 0 + where user_id = ? + and revivable = 1 + and ( + item_id like ? + or item_id like ? + ) + `).bind( + userId, + `agent:${machineKey}:%`, + `pull-request:${machineKey}:%`, + ).run(); +} + function mergedDevicePreferences( device: AttentionDeviceRow, devicePreferences: Record, @@ -1447,22 +1532,14 @@ async function linkMachineToAccount( .bind(machineKey, previous.user_id) .run(); for (const item of previousItems.results) { - await env.DB.prepare(` - insert into attention_tombstones( - user_id, item_id, source_revision, account_revision, deleted_at - ) - values (?, ?, ?, ?, ?) - on conflict(user_id, item_id) do update set - source_revision = excluded.source_revision, - account_revision = excluded.account_revision, - deleted_at = excluded.deleted_at - `).bind( - previous.user_id, - item.item_id, - Number(item.source_revision), - previousRevision, - now, - ).run(); + await upsertAttentionTombstone(env, { + userId: previous.user_id, + itemId: item.item_id, + sourceRevision: Number(item.source_revision), + accountRevision: previousRevision, + deletedAt: now, + revivable: false, + }); } const previousDevices = await env.DB .prepare("select device_id from attention_devices where user_id = ? and source_machine_key = ?") @@ -1471,6 +1548,12 @@ async function linkMachineToAccount( for (const device of previousDevices.results) { await deleteAttentionDeviceOwnership(env, previous.user_id, device.device_id); } + if (previousItems.results.length > 0) { + // The destination account is delivered later in the publish flow, but + // other devices that remain signed into the previous account also need + // an update/end after this machine's rows leave that aggregate. + await deliverAccountLiveActivity(env, previous.user_id); + } } if (freshOwnership) { // A machine returning to an account starts a fresh source stream. Remove @@ -1633,8 +1716,11 @@ export async function handleAttentionMachinePublish( if ((parsedTombstones as Array<{ id: string }>).some((entry) => !ownsItemId(entry.id))) { return json({ ok: false, error: "foreign attention tombstone" }, { status: 403 }); } - const tombstonesById = new Map( - (parsedTombstones as Array<{ id: string; revision: number }>).map((entry) => [entry.id, entry]), + const tombstonesById = new Map( + (parsedTombstones as Array<{ id: string; revision: number }>).map((entry) => [ + entry.id, + { ...entry, revivable: false }, + ]), ); const publishedIds = new Set( (items as ParsedAttentionItem[]).map((item) => item.id), @@ -1659,11 +1745,11 @@ export async function handleAttentionMachinePublish( }>(); existingMachineItems = existing.results; for (const row of existing.results) { - if (!publishedIds.has(row.item_id)) { - tombstonesById.set(row.item_id, { - id: row.item_id, - revision: Math.max(Date.now(), Number(row.source_revision) || 0), - }); + if (!publishedIds.has(row.item_id) && !tombstonesById.has(row.item_id)) { + tombstonesById.set( + row.item_id, + implicitFullSnapshotTombstone(row, rawItems.length), + ); } } } @@ -1708,12 +1794,15 @@ export async function handleAttentionMachinePublish( for (const item of items as ParsedAttentionItem[]) { const tombstone = await env.DB.prepare(` - select source_revision + select source_revision, revivable from attention_tombstones where user_id = ? and item_id = ? limit 1 - `).bind(account.userId, item.id).first<{ source_revision: number }>(); - if (tombstone && Number(tombstone.source_revision) >= item.revision) { + `).bind(account.userId, item.id).first<{ + source_revision: number; + revivable: number; + }>(); + if (tombstone && attentionTombstoneBlocksItem(tombstone, item.revision)) { continue; } await env.DB.prepare(` @@ -1760,7 +1849,14 @@ export async function handleAttentionMachinePublish( .run(); } - for (const tombstone of tombstones as Array<{ id: string; revision: number }>) { + if (fullSnapshot && rawItems.length < MAX_ATTENTION_ITEMS) { + // Any revivable tombstone still present after applying the incoming items + // is absent from an unsaturated authoritative snapshot, so it is now a + // genuine removal and must stop accepting stale same-revision revivals. + await sealCapacityTombstones(env, account.userId, machineKey); + } + + for (const tombstone of tombstones) { const existing = await env.DB .prepare("select source_revision from attention_items where user_id = ? and machine_key = ? and item_id = ? limit 1") .bind(account.userId, machineKey, tombstone.id) @@ -1772,23 +1868,14 @@ export async function handleAttentionMachinePublish( .prepare("delete from attention_items where user_id = ? and machine_key = ? and item_id = ? and source_revision <= ?") .bind(account.userId, machineKey, tombstone.id, tombstone.revision) .run(); - await env.DB.prepare(` - insert into attention_tombstones( - user_id, item_id, source_revision, account_revision, deleted_at - ) - values (?, ?, ?, ?, ?) - on conflict(user_id, item_id) do update set - source_revision = excluded.source_revision, - account_revision = excluded.account_revision, - deleted_at = excluded.deleted_at - where excluded.source_revision >= attention_tombstones.source_revision - `).bind( - account.userId, - tombstone.id, - tombstone.revision, + await upsertAttentionTombstone(env, { + userId: account.userId, + itemId: tombstone.id, + sourceRevision: tombstone.revision, accountRevision, - now, - ).run(); + deletedAt: now, + revivable: tombstone.revivable, + }); } await deliverAttentionNotifications( env, @@ -2464,13 +2551,17 @@ export const attentionTestInternals = Object.freeze({ activityRun, attentionAlertRoutingPayload, attentionFullSnapshotUnchanged, + attentionTombstoneBlocksItem, deepLinkForItem, deliverAttentionNotifications, desktopEscalationDelayMs, handleAuthorizedAttentionAccountRequest, + implicitFullSnapshotTombstone, linkMachineToAccount, notificationTitle, normalizedSnapshotCursor, parseAttentionItem, privacyPreservingActivityContentState, + sealCapacityTombstones, + upsertAttentionTombstone, }); diff --git a/apps/push-relay/test/attention.test.ts b/apps/push-relay/test/attention.test.ts index beb31c823..01d69cc0c 100644 --- a/apps/push-relay/test/attention.test.ts +++ b/apps/push-relay/test/attention.test.ts @@ -100,6 +100,20 @@ function makeAttentionEnv( return { DB: database as unknown as D1Database, ...overrides }; } +async function generateTestP8(): Promise { + const keyPair = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + const pkcs8 = await crypto.subtle.exportKey("pkcs8", keyPair.privateKey); + const bytes = new Uint8Array(pkcs8); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + const body = btoa(binary).replace(/(.{64})/g, "$1\n"); + return `-----BEGIN PRIVATE KEY-----\n${body}\n-----END PRIVATE KEY-----`; +} + function row>( database: SqliteD1Database, sql: string, @@ -182,6 +196,7 @@ const MACHINE_KEY = "a".repeat(32); afterEach(() => { vi.useRealTimers(); + vi.unstubAllGlobals(); }); function validAgentItem(): Record { @@ -333,6 +348,76 @@ describe("account Attention contract", () => { ).toBe(false); }); + it("revives capacity-displaced items but seals confirmed removals", async () => { + const database = new SqliteD1Database(); + const env = makeAttentionEnv(database); + const itemId = `agent:${MACHINE_KEY}:session-capacity`; + try { + const capacityTombstone = attentionTestInternals.implicitFullSnapshotTombstone({ + item_id: itemId, + source_revision: 7, + }, 64); + expect(capacityTombstone).toEqual({ + id: itemId, + revision: 7, + revivable: true, + }); + expect(attentionTestInternals.implicitFullSnapshotTombstone({ + item_id: itemId, + source_revision: 7, + }, 63).revivable).toBe(false); + + await attentionTestInternals.upsertAttentionTombstone(env, { + userId: "account-a", + itemId, + sourceRevision: 7, + accountRevision: 1, + deletedAt: "2026-07-28T08:00:00.000Z", + revivable: true, + }); + const displaced = row<{ source_revision: number; revivable: number }>(database, ` + select source_revision, revivable + from attention_tombstones + where user_id = 'account-a' and item_id = ? + `, itemId); + expect(displaced).toMatchObject({ source_revision: 7, revivable: 1 }); + expect(attentionTestInternals.attentionTombstoneBlocksItem(displaced!, 7)).toBe(false); + + await attentionTestInternals.sealCapacityTombstones( + env, + "account-a", + MACHINE_KEY, + ); + const confirmedRemoval = row<{ source_revision: number; revivable: number }>(database, ` + select source_revision, revivable + from attention_tombstones + where user_id = 'account-a' and item_id = ? + `, itemId); + expect(confirmedRemoval).toMatchObject({ source_revision: 7, revivable: 0 }); + expect( + attentionTestInternals.attentionTombstoneBlocksItem(confirmedRemoval!, 7), + ).toBe(true); + + // A later ambiguous capacity omission cannot weaken an already + // authoritative same-revision removal. + await attentionTestInternals.upsertAttentionTombstone(env, { + userId: "account-a", + itemId, + sourceRevision: 7, + accountRevision: 2, + deletedAt: "2026-07-28T08:01:00.000Z", + revivable: true, + }); + expect(row(database, ` + select revivable + from attention_tombstones + where user_id = 'account-a' and item_id = ? + `, itemId)?.revivable).toBe(0); + } finally { + database.close(); + } + }); + it("retries a due desktop-first alert without duplicating delivered or acknowledged notifications", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-07-28T08:00:10.000Z")); @@ -833,6 +918,113 @@ describe("account Attention contract", () => { } }); + it("ends the previous account aggregate when a linked machine transfers", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T08:00:00.000Z")); + const database = new SqliteD1Database(); + const sendPush = vi.fn(async ( + _input: RequestInfo | URL, + _init?: RequestInit, + ) => new Response(null, { + status: 200, + headers: { "apns-id": "transfer-end" }, + })); + vi.stubGlobal("fetch", sendPush); + const env = makeAttentionEnv(database, { + APNS_KEY: await generateTestP8(), + APNS_KEY_ID: "MOVEKEY123", + APNS_TEAM_ID: "MOVETEAM1", + }); + const parsed = attentionTestInternals.parseAttentionItem( + validAgentItem(), + MACHINE_KEY, + ); + expect(parsed).not.toBeNull(); + if (!parsed) { + database.close(); + return; + } + try { + await attentionTestInternals.linkMachineToAccount( + env, + "account-a", + MACHINE_KEY, + "Studio", + ); + insertAttentionDevice(database, { + userId: "account-a", + deviceId: "remaining-phone", + apnsToken: "ab".repeat(32), + }); + database.native.prepare(` + insert into attention_activity_state( + user_id, device_id, activity_id, started, fingerprint, updated_at + ) values ( + 'account-a', 'remaining-phone', 'agent-runs', 1, 'old-fingerprint', + '2026-07-28T08:00:00.000Z' + ) + `).run(); + database.native.prepare(` + insert into attention_activity_tokens( + user_id, device_id, activity_id, token, updated_at + ) values ( + 'account-a', 'remaining-phone', 'agent-runs', ?, + '2026-07-28T08:00:00.000Z' + ) + `).run("cd".repeat(32)); + database.native.prepare(` + insert into attention_items( + user_id, item_id, machine_key, source_revision, account_revision, + fingerprint, event_kind, phase, payload_json, seen_at, dismissed_at, + expires_at, updated_at + ) values (?, ?, ?, ?, 1, ?, ?, ?, ?, null, null, ?, ?) + `).run( + "account-a", + parsed.id, + MACHINE_KEY, + parsed.revision, + parsed.fingerprint, + parsed.eventKind, + parsed.phase, + JSON.stringify(parsed), + parsed.expiresAt, + parsed.updatedAt, + ); + + await attentionTestInternals.linkMachineToAccount( + env, + "account-b", + MACHINE_KEY, + "Studio", + ); + + expect(sendPush).toHaveBeenCalledTimes(1); + const request = sendPush.mock.calls[0]?.[1] as RequestInit; + expect(JSON.parse(String(request.body))).toMatchObject({ + aps: { + event: "end", + "content-state": { + activeCount: 0, + runs: [], + prs: [], + }, + }, + }); + expect(rows(database, ` + select * + from attention_activity_state + where user_id = 'account-a' and device_id = 'remaining-phone' + `)).toHaveLength(0); + expect(row(database, ` + select revivable + from attention_tombstones + where user_id = 'account-a' and item_id = ? + `, parsed.id)?.revivable).toBe(0); + } finally { + database.close(); + } + }); + it("checks destination quota before an atomic ownership transfer and renews the lease", async () => { const database = new SqliteD1Database(); const transferToken = "ef".repeat(32); From 128a4f53f357e0342c18c1d131a05a86f16902d8 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:25:07 -0400 Subject: [PATCH 5/6] =?UTF-8?q?ship:=20iteration=204=20=E2=80=94=20make=20?= =?UTF-8?q?Attention=20cursors=20atomic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/push-relay/src/attention.ts | 434 ++++++++++++------ apps/push-relay/test/attention.test.ts | 582 ++++++++++++++++++++++++- apps/push-relay/test/relay.test.ts | 49 ++- 3 files changed, 920 insertions(+), 145 deletions(-) diff --git a/apps/push-relay/src/attention.ts b/apps/push-relay/src/attention.ts index ac7cc0eaa..23d4af55d 100644 --- a/apps/push-relay/src/attention.ts +++ b/apps/push-relay/src/attention.ts @@ -351,22 +351,29 @@ function attentionTombstoneBlocksItem( && Number(tombstone.source_revision) >= itemRevision; } -async function upsertAttentionTombstone( +function upsertAttentionTombstoneStatement( env: AttentionRelayEnv, args: { userId: string; itemId: string; sourceRevision: number; - accountRevision: number; + accountRevision: number | null; deletedAt: string; revivable: boolean; }, -): Promise { - await env.DB.prepare(` +): D1PreparedStatement { + return env.DB.prepare(` insert into attention_tombstones( user_id, item_id, source_revision, account_revision, revivable, deleted_at ) - values (?, ?, ?, ?, ?, ?) + values ( + ?, ?, ?, + coalesce( + ?, + (select revision from attention_revisions where user_id = ?) + ), + ?, ? + ) on conflict(user_id, item_id) do update set source_revision = excluded.source_revision, account_revision = excluded.account_revision, @@ -382,17 +389,32 @@ async function upsertAttentionTombstone( args.itemId, args.sourceRevision, args.accountRevision, + args.userId, args.revivable ? 1 : 0, args.deletedAt, - ).run(); + ); } -async function sealCapacityTombstones( +async function upsertAttentionTombstone( + env: AttentionRelayEnv, + args: { + userId: string; + itemId: string; + sourceRevision: number; + accountRevision: number; + deletedAt: string; + revivable: boolean; + }, +): Promise { + await upsertAttentionTombstoneStatement(env, args).run(); +} + +function sealCapacityTombstonesStatement( env: AttentionRelayEnv, userId: string, machineKey: string, -): Promise { - await env.DB.prepare(` +): D1PreparedStatement { + return env.DB.prepare(` update attention_tombstones set revivable = 0 where user_id = ? @@ -405,7 +427,178 @@ async function sealCapacityTombstones( userId, `agent:${machineKey}:%`, `pull-request:${machineKey}:%`, - ).run(); + ); +} + +async function sealCapacityTombstones( + env: AttentionRelayEnv, + userId: string, + machineKey: string, +): Promise { + await sealCapacityTombstonesStatement(env, userId, machineKey).run(); +} + +function attentionItemUpsertStatement( + env: AttentionRelayEnv, + userId: string, + machineKey: string, + item: ParsedAttentionItem, +): D1PreparedStatement { + return env.DB.prepare(` + insert into attention_items( + user_id, item_id, machine_key, source_revision, account_revision, + fingerprint, event_kind, phase, payload_json, seen_at, dismissed_at, + expires_at, updated_at + ) + select ?, ?, ?, ?, revision, ?, ?, ?, ?, null, null, ?, ? + from attention_revisions + where user_id = ? + and not exists ( + select 1 + from attention_tombstones + where user_id = ? and item_id = ? + and revivable != 1 + and source_revision >= ? + ) + on conflict(user_id, item_id) do update set + machine_key = excluded.machine_key, + source_revision = excluded.source_revision, + account_revision = excluded.account_revision, + fingerprint = excluded.fingerprint, + event_kind = excluded.event_kind, + phase = excluded.phase, + payload_json = excluded.payload_json, + seen_at = case + when attention_items.fingerprint = excluded.fingerprint then attention_items.seen_at + else null + end, + dismissed_at = case + when attention_items.fingerprint = excluded.fingerprint then attention_items.dismissed_at + else null + end, + expires_at = excluded.expires_at, + updated_at = excluded.updated_at + where excluded.source_revision >= attention_items.source_revision + `).bind( + userId, + item.id, + machineKey, + item.revision, + item.fingerprint, + item.eventKind, + item.phase, + JSON.stringify(item), + item.expiresAt, + item.updatedAt, + userId, + userId, + item.id, + item.revision, + ); +} + +function attentionItemTombstoneDeleteStatement( + env: AttentionRelayEnv, + userId: string, + item: ParsedAttentionItem, +): D1PreparedStatement { + return env.DB.prepare(` + delete from attention_tombstones + where user_id = ? and item_id = ? + and (revivable = 1 or source_revision < ?) + `).bind(userId, item.id, item.revision); +} + +function attentionTombstoneUpsertForCurrentRevisionStatement( + env: AttentionRelayEnv, + args: { + userId: string; + machineKey: string; + itemId: string; + sourceRevision: number; + deletedAt: string; + revivable: boolean; + }, +): D1PreparedStatement { + return env.DB.prepare(` + insert into attention_tombstones( + user_id, item_id, source_revision, account_revision, revivable, deleted_at + ) + select ?, ?, ?, revision, ?, ? + from attention_revisions + where user_id = ? + and not exists ( + select 1 + from attention_items + where user_id = ? and machine_key = ? and item_id = ? + and source_revision > ? + ) + on conflict(user_id, item_id) do update set + source_revision = excluded.source_revision, + account_revision = excluded.account_revision, + revivable = case + when excluded.source_revision > attention_tombstones.source_revision + then excluded.revivable + else min(attention_tombstones.revivable, excluded.revivable) + end, + deleted_at = excluded.deleted_at + where excluded.source_revision >= attention_tombstones.source_revision + `).bind( + args.userId, + args.itemId, + args.sourceRevision, + args.revivable ? 1 : 0, + args.deletedAt, + args.userId, + args.userId, + args.machineKey, + args.itemId, + args.sourceRevision, + ); +} + +async function commitAttentionMachineChanges( + env: AttentionRelayEnv, + args: { + userId: string; + machineKey: string; + items: ParsedAttentionItem[]; + tombstones: IncomingAttentionTombstone[]; + sealCapacityTombstones: boolean; + now: string; + }, +): Promise { + const statements: D1PreparedStatement[] = []; + for (const item of args.items) { + statements.push( + attentionItemUpsertStatement(env, args.userId, args.machineKey, item), + attentionItemTombstoneDeleteStatement(env, args.userId, item), + ); + } + if (args.sealCapacityTombstones) { + statements.push( + sealCapacityTombstonesStatement(env, args.userId, args.machineKey), + ); + } + for (const tombstone of args.tombstones) { + statements.push( + env.DB + .prepare(` + delete from attention_items + where user_id = ? and machine_key = ? and item_id = ? and source_revision <= ? + `) + .bind(args.userId, args.machineKey, tombstone.id, tombstone.revision), + attentionTombstoneUpsertForCurrentRevisionStatement(env, { + userId: args.userId, + machineKey: args.machineKey, + itemId: tombstone.id, + sourceRevision: tombstone.revision, + deletedAt: args.now, + revivable: tombstone.revivable, + }), + ); + } + return commitAttentionRevision(env, args.userId, statements, args.now); } function mergedDevicePreferences( @@ -552,10 +745,32 @@ async function deliverAttentionNotifications( : "ambient"; if (policy !== "notify") continue; const current = await env.DB - .prepare("select seen_at, dismissed_at from attention_items where user_id = ? and item_id = ? limit 1") + .prepare(` + select source_revision, fingerprint, seen_at, dismissed_at + from attention_items + where user_id = ? and item_id = ? + limit 1 + `) .bind(userId, item.id) - .first<{ seen_at: string | null; dismissed_at: string | null }>(); - if (current?.seen_at || current?.dismissed_at) continue; + .first<{ + source_revision: number; + fingerprint: string; + seen_at: string | null; + dismissed_at: string | null; + }>(); + // The atomic publish may intentionally reject a stale item or an item + // blocked by a sealed tombstone. Only the exact row that committed is + // eligible to notify; otherwise an ignored inbound payload could still + // produce a phone alert. + if ( + !current + || Number(current.source_revision) !== item.revision + || current.fingerprint !== item.fingerprint + || current.seen_at + || current.dismissed_at + ) { + continue; + } // Give an active desktop/notch the first chance to surface the item. The // machine heartbeat republishes the full snapshot every 30s; if the item // remains unseen, the next pass escalates it to the phone. @@ -1313,17 +1528,42 @@ function parseAttentionItem(value: unknown, machineKey: string): ParsedAttention } as ParsedAttentionItem; } -async function bumpRevision(env: AttentionRelayEnv, userId: string): Promise { - const now = new Date().toISOString(); - const row = await env.DB.prepare(` +function attentionRevisionBumpStatement( + env: AttentionRelayEnv, + userId: string, + now: string, +): D1PreparedStatement { + return env.DB.prepare(` insert into attention_revisions(user_id, revision, updated_at) values (?, 1, ?) on conflict(user_id) do update set revision = attention_revisions.revision + 1, updated_at = excluded.updated_at returning revision - `).bind(userId, now).first<{ revision: number }>(); - return Number(row?.revision ?? 1); + `).bind(userId, now); +} + +async function commitAttentionRevision( + env: AttentionRelayEnv, + userId: string, + statements: D1PreparedStatement[], + now = new Date().toISOString(), +): Promise { + const [revisionResult, ...mutationResults] = await env.DB.batch<{ revision: number }>([ + attentionRevisionBumpStatement(env, userId, now), + ...statements, + ]); + if ( + !revisionResult?.success + || mutationResults.some((result) => !result.success) + ) { + throw new Error("attention revision transaction failed"); + } + const revision = Number(revisionResult.results[0]?.revision); + if (!Number.isSafeInteger(revision) || revision < 1) { + throw new Error("attention revision transaction did not return a revision"); + } + return revision; } function attentionDeviceOwnershipDeleteStatements( @@ -1524,22 +1764,22 @@ async function linkMachineToAccount( `) .bind(machineKey, previous.user_id) .all<{ item_id: string; source_revision: number }>(); - const previousRevision = previousItems.results.length > 0 - ? await bumpRevision(env, previous.user_id) - : 0; - await env.DB - .prepare("delete from attention_items where machine_key = ? and user_id = ?") - .bind(machineKey, previous.user_id) - .run(); - for (const item of previousItems.results) { - await upsertAttentionTombstone(env, { - userId: previous.user_id, - itemId: item.item_id, - sourceRevision: Number(item.source_revision), - accountRevision: previousRevision, - deletedAt: now, - revivable: false, - }); + if (previousItems.results.length > 0) { + const statements: D1PreparedStatement[] = [ + env.DB + .prepare("delete from attention_items where machine_key = ? and user_id = ?") + .bind(machineKey, previous.user_id), + ...previousItems.results.map((item) => + attentionTombstoneUpsertForCurrentRevisionStatement(env, { + userId: previous.user_id, + machineKey, + itemId: item.item_id, + sourceRevision: Number(item.source_revision), + deletedAt: now, + revivable: false, + })), + ]; + await commitAttentionRevision(env, previous.user_id, statements, now); } const previousDevices = await env.DB .prepare("select device_id from attention_devices where user_id = ? and source_machine_key = ?") @@ -1789,94 +2029,16 @@ export async function handleAttentionMachinePublish( unchanged: true, }); } - const accountRevision = await bumpRevision(env, account.userId); const now = new Date().toISOString(); - - for (const item of items as ParsedAttentionItem[]) { - const tombstone = await env.DB.prepare(` - select source_revision, revivable - from attention_tombstones - where user_id = ? and item_id = ? - limit 1 - `).bind(account.userId, item.id).first<{ - source_revision: number; - revivable: number; - }>(); - if (tombstone && attentionTombstoneBlocksItem(tombstone, item.revision)) { - continue; - } - await env.DB.prepare(` - insert into attention_items( - user_id, item_id, machine_key, source_revision, account_revision, - fingerprint, event_kind, phase, payload_json, seen_at, dismissed_at, - expires_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, null, null, ?, ?) - on conflict(user_id, item_id) do update set - machine_key = excluded.machine_key, - source_revision = excluded.source_revision, - account_revision = excluded.account_revision, - fingerprint = excluded.fingerprint, - event_kind = excluded.event_kind, - phase = excluded.phase, - payload_json = excluded.payload_json, - seen_at = case - when attention_items.fingerprint = excluded.fingerprint then attention_items.seen_at - else null - end, - dismissed_at = case - when attention_items.fingerprint = excluded.fingerprint then attention_items.dismissed_at - else null - end, - expires_at = excluded.expires_at, - updated_at = excluded.updated_at - where excluded.source_revision >= attention_items.source_revision - `).bind( - account.userId, - item.id, - machineKey, - item.revision, - accountRevision, - item.fingerprint, - item.eventKind, - item.phase, - JSON.stringify(item), - item.expiresAt, - item.updatedAt, - ).run(); - await env.DB - .prepare("delete from attention_tombstones where user_id = ? and item_id = ?") - .bind(account.userId, item.id) - .run(); - } - - if (fullSnapshot && rawItems.length < MAX_ATTENTION_ITEMS) { - // Any revivable tombstone still present after applying the incoming items - // is absent from an unsaturated authoritative snapshot, so it is now a - // genuine removal and must stop accepting stale same-revision revivals. - await sealCapacityTombstones(env, account.userId, machineKey); - } - - for (const tombstone of tombstones) { - const existing = await env.DB - .prepare("select source_revision from attention_items where user_id = ? and machine_key = ? and item_id = ? limit 1") - .bind(account.userId, machineKey, tombstone.id) - .first<{ source_revision: number }>(); - if (existing && Number(existing.source_revision) > tombstone.revision) { - continue; - } - await env.DB - .prepare("delete from attention_items where user_id = ? and machine_key = ? and item_id = ? and source_revision <= ?") - .bind(account.userId, machineKey, tombstone.id, tombstone.revision) - .run(); - await upsertAttentionTombstone(env, { - userId: account.userId, - itemId: tombstone.id, - sourceRevision: tombstone.revision, - accountRevision, - deletedAt: now, - revivable: tombstone.revivable, - }); - } + const accountRevision = await commitAttentionMachineChanges(env, { + userId: account.userId, + machineKey, + items: items as ParsedAttentionItem[], + tombstones, + sealCapacityTombstones: + fullSnapshot && rawItems.length < MAX_ATTENTION_ITEMS, + now, + }); await deliverAttentionNotifications( env, account.userId, @@ -2027,14 +2189,31 @@ async function handleAcknowledgment( if (!seenAt || dismissedAt === undefined) { return json({ ok: false, error: "invalid timestamp" }, { status: 400 }); } - const revision = await bumpRevision(env, userId); - for (const itemId of itemIds as string[]) { - await env.DB.prepare(` + if (itemIds.length === 0) { + const current = await env.DB + .prepare("select revision from attention_revisions where user_id = ? limit 1") + .bind(userId) + .first<{ revision: number }>(); + return json({ + ok: true, + revision: Number(current?.revision ?? 0), + itemIds, + }); + } + const statements = (itemIds as string[]).map((itemId) => + env.DB.prepare(` update attention_items - set seen_at = ?, dismissed_at = coalesce(?, dismissed_at), account_revision = ? + set seen_at = ?, + dismissed_at = coalesce(?, dismissed_at), + account_revision = ( + select revision + from attention_revisions + where user_id = ? + ) where user_id = ? and item_id = ? - `).bind(seenAt, dismissedAt, revision, userId, itemId).run(); - } + `).bind(seenAt, dismissedAt, userId, userId, itemId), + ); + const revision = await commitAttentionRevision(env, userId, statements); await deliverAccountLiveActivity(env, userId); return json({ ok: true, revision, itemIds }); } @@ -2552,6 +2731,7 @@ export const attentionTestInternals = Object.freeze({ attentionAlertRoutingPayload, attentionFullSnapshotUnchanged, attentionTombstoneBlocksItem, + commitAttentionMachineChanges, deepLinkForItem, deliverAttentionNotifications, desktopEscalationDelayMs, diff --git a/apps/push-relay/test/attention.test.ts b/apps/push-relay/test/attention.test.ts index 01d69cc0c..1f543ec64 100644 --- a/apps/push-relay/test/attention.test.ts +++ b/apps/push-relay/test/attention.test.ts @@ -1,5 +1,7 @@ import { createRequire } from "node:module"; -import { readFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -54,21 +56,32 @@ class SqliteD1Statement { return { success: true }; } - runSync(): void { + runSync(): Array> { + if (/\breturning\b/i.test(this.sql)) { + return this.database.prepare(this.sql).all(...this.values); + } this.database.prepare(this.sql).run(...this.values); + return []; } } class SqliteD1Database { - readonly native = new DatabaseSync(":memory:"); - - constructor() { - for (const migration of [ - "../migrations/0001_push_registrations.sql", - "../migrations/0002_rate_and_budget.sql", - "../migrations/0003_account_attention.sql", - ]) { - this.native.exec(readFileSync(new URL(migration, import.meta.url), "utf8")); + readonly native: NativeDatabase; + private nextBatchFailureIndex: number | null = null; + private nextBatchObservation: + | { index: number; observe: () => Promise } + | null = null; + + constructor(path = ":memory:", migrate = true) { + this.native = new DatabaseSync(path); + if (migrate) { + for (const migration of [ + "../migrations/0001_push_registrations.sql", + "../migrations/0002_rate_and_budget.sql", + "../migrations/0003_account_attention.sql", + ]) { + this.native.exec(readFileSync(new URL(migration, import.meta.url), "utf8")); + } } } @@ -76,18 +89,43 @@ class SqliteD1Database { return new SqliteD1Statement(this.native, sql); } - async batch(statements: SqliteD1Statement[]): Promise> { + async batch( + statements: SqliteD1Statement[], + ): Promise> }>> { this.native.exec("begin immediate"); try { - for (const statement of statements) statement.runSync(); + const results: Array<{ + success: boolean; + results: Array>; + }> = []; + for (const [index, statement] of statements.entries()) { + if (index === this.nextBatchFailureIndex) { + throw new Error(`injected batch failure at statement ${index}`); + } + results.push({ success: true, results: statement.runSync() }); + if (index === this.nextBatchObservation?.index) { + await this.nextBatchObservation.observe(); + } + } this.native.exec("commit"); - return statements.map(() => ({ success: true })); + return results; } catch (error) { this.native.exec("rollback"); throw error; + } finally { + this.nextBatchFailureIndex = null; + this.nextBatchObservation = null; } } + failNextBatchAt(index: number): void { + this.nextBatchFailureIndex = index; + } + + observeNextBatchAfter(index: number, observe: () => Promise): void { + this.nextBatchObservation = { index, observe }; + } + close(): void { this.native.close(); } @@ -348,6 +386,359 @@ describe("account Attention contract", () => { ).toBe(false); }); + it("rolls back a published item when its cursor transaction fails", async () => { + const database = new SqliteD1Database(); + const parsed = attentionTestInternals.parseAttentionItem(validAgentItem(), MACHINE_KEY); + expect(parsed).not.toBeNull(); + if (!parsed) { + database.close(); + return; + } + try { + database.failNextBatchAt(2); + await expect(attentionTestInternals.commitAttentionMachineChanges( + makeAttentionEnv(database), + { + userId: "account-a", + machineKey: MACHINE_KEY, + items: [parsed], + tombstones: [], + sealCapacityTombstones: false, + now: "2026-07-28T08:00:00.000Z", + }, + )).rejects.toThrow("injected batch failure at statement 2"); + + expect(row(database, ` + select revision + from attention_revisions + where user_id = 'account-a' + `)).toBeUndefined(); + expect(rows(database, ` + select item_id + from attention_items + where user_id = 'account-a' + `)).toEqual([]); + } finally { + database.close(); + } + }); + + it("rolls back item deletion when its tombstone cursor transaction fails", async () => { + const database = new SqliteD1Database(); + const parsed = attentionTestInternals.parseAttentionItem(validAgentItem(), MACHINE_KEY); + expect(parsed).not.toBeNull(); + if (!parsed) { + database.close(); + return; + } + try { + database.native.prepare(` + insert into attention_revisions(user_id, revision, updated_at) + values ('account-a', 4, '2026-07-28T07:59:00.000Z') + `).run(); + database.native.prepare(` + insert into attention_items( + user_id, item_id, machine_key, source_revision, account_revision, + fingerprint, event_kind, phase, payload_json, seen_at, dismissed_at, + expires_at, updated_at + ) values ( + 'account-a', ?, ?, 7, 4, ?, ?, ?, ?, null, null, ?, ? + ) + `).run( + parsed.id, + MACHINE_KEY, + parsed.fingerprint, + parsed.eventKind, + parsed.phase, + JSON.stringify(parsed), + parsed.expiresAt, + parsed.updatedAt, + ); + + database.failNextBatchAt(2); + await expect(attentionTestInternals.commitAttentionMachineChanges( + makeAttentionEnv(database), + { + userId: "account-a", + machineKey: MACHINE_KEY, + items: [], + tombstones: [{ id: parsed.id, revision: 7, revivable: false }], + sealCapacityTombstones: false, + now: "2026-07-28T08:00:00.000Z", + }, + )).rejects.toThrow("injected batch failure at statement 2"); + + expect(row(database, ` + select revision + from attention_revisions + where user_id = 'account-a' + `)?.revision).toBe(4); + expect(row(database, ` + select account_revision + from attention_items + where user_id = 'account-a' and item_id = ? + `, parsed.id)?.account_revision).toBe(4); + expect(rows(database, ` + select item_id + from attention_tombstones + where user_id = 'account-a' + `)).toEqual([]); + } finally { + database.close(); + } + }); + + it("rolls back acknowledgment rows when their cursor transaction fails", async () => { + const database = new SqliteD1Database(); + const parsed = attentionTestInternals.parseAttentionItem(validAgentItem(), MACHINE_KEY); + expect(parsed).not.toBeNull(); + if (!parsed) { + database.close(); + return; + } + try { + database.native.prepare(` + insert into attention_revisions(user_id, revision, updated_at) + values ('account-a', 4, '2026-07-28T07:59:00.000Z') + `).run(); + database.native.prepare(` + insert into attention_items( + user_id, item_id, machine_key, source_revision, account_revision, + fingerprint, event_kind, phase, payload_json, seen_at, dismissed_at, + expires_at, updated_at + ) values ( + 'account-a', ?, ?, 7, 4, ?, ?, ?, ?, null, null, ?, ? + ) + `).run( + parsed.id, + MACHINE_KEY, + parsed.fingerprint, + parsed.eventKind, + parsed.phase, + JSON.stringify(parsed), + parsed.expiresAt, + parsed.updatedAt, + ); + + database.failNextBatchAt(1); + await expect(accountRoute( + database, + "account-a", + "POST", + "/attention/account/ack", + { + itemIds: [parsed.id], + seenAt: "2026-07-28T08:00:00.000Z", + dismissedAt: null, + }, + )).rejects.toThrow("injected batch failure at statement 1"); + + expect(row(database, ` + select revision + from attention_revisions + where user_id = 'account-a' + `)?.revision).toBe(4); + expect(row(database, ` + select account_revision, seen_at + from attention_items + where user_id = 'account-a' and item_id = ? + `, parsed.id)).toMatchObject({ + account_revision: 4, + seen_at: null, + }); + } finally { + database.close(); + } + }); + + it("keeps an empty acknowledgment cursor-stable", async () => { + const database = new SqliteD1Database(); + try { + database.native.prepare(` + insert into attention_revisions(user_id, revision, updated_at) + values ('account-a', 4, '2026-07-28T07:59:00.000Z') + `).run(); + + const response = await accountRoute( + database, + "account-a", + "POST", + "/attention/account/ack", + { + itemIds: [], + seenAt: "2026-07-28T08:00:00.000Z", + dismissedAt: null, + }, + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + ok: true, + revision: 4, + itemIds: [], + }); + expect(row(database, ` + select revision + from attention_revisions + where user_id = 'account-a' + `)?.revision).toBe(4); + } finally { + database.close(); + } + }); + + it("never exposes a cursor before its item, tombstone, or acknowledgment rows", async () => { + const directory = mkdtempSync(join(tmpdir(), "ade-attention-atomic-")); + const path = join(directory, "relay.sqlite"); + const database = new SqliteD1Database(path); + const observer = new SqliteD1Database(path, false); + const first = attentionTestInternals.parseAttentionItem( + validAgentItem(), + MACHINE_KEY, + ); + const secondRaw = validAgentItem(); + secondRaw.id = `agent:${MACHINE_KEY}:session-2`; + secondRaw.fingerprint = "fingerprint-session-2"; + secondRaw.destination = { + kind: "session", + sessionId: "session-2", + itemId: "approval-2", + eventId: "event-2", + }; + const second = attentionTestInternals.parseAttentionItem( + secondRaw, + MACHINE_KEY, + ); + expect(first).not.toBeNull(); + expect(second).not.toBeNull(); + if (!first || !second) { + observer.close(); + database.close(); + rmSync(directory, { recursive: true, force: true }); + return; + } + type SnapshotBody = { + revision: number; + items: Array<{ id: string; seenAt: string | null }>; + tombstones: Array<{ id: string }>; + }; + try { + const initialRevision = await attentionTestInternals.commitAttentionMachineChanges( + makeAttentionEnv(database), + { + userId: "account-a", + machineKey: MACHINE_KEY, + items: [first, second], + tombstones: [], + sealCapacityTombstones: false, + now: "2026-07-28T08:00:00.000Z", + }, + ); + expect(initialRevision).toBe(1); + + const updatedRaw = validAgentItem(); + updatedRaw.revision = 8; + updatedRaw.fingerprint = "fingerprint-8"; + updatedRaw.preview = "The migration changed while you were reviewing it."; + updatedRaw.updatedAt = "2026-07-28T08:01:00.000Z"; + const updated = attentionTestInternals.parseAttentionItem( + updatedRaw, + MACHINE_KEY, + ); + expect(updated).not.toBeNull(); + if (!updated) return; + + let duringPublish: SnapshotBody | null = null; + database.observeNextBatchAfter(0, async () => { + const response = await accountRoute( + observer, + "account-a", + "GET", + "/attention/account/snapshot?since=1&streamId=account-a", + ); + duringPublish = await response.json() as SnapshotBody; + }); + const publishRevision = await attentionTestInternals.commitAttentionMachineChanges( + makeAttentionEnv(database), + { + userId: "account-a", + machineKey: MACHINE_KEY, + items: [updated], + tombstones: [{ id: second.id, revision: second.revision, revivable: false }], + sealCapacityTombstones: false, + now: "2026-07-28T08:01:00.000Z", + }, + ); + expect(publishRevision).toBe(2); + expect(duringPublish).toEqual(expect.objectContaining({ + revision: 1, + items: [], + tombstones: [], + })); + + const publishedDelta = await ( + await accountRoute( + observer, + "account-a", + "GET", + "/attention/account/snapshot?since=1&streamId=account-a", + ) + ).json() as SnapshotBody; + expect(publishedDelta.revision).toBe(2); + expect(publishedDelta.items.map((item) => item.id)).toEqual([updated.id]); + expect(publishedDelta.tombstones.map((item) => item.id)).toEqual([second.id]); + + let duringAcknowledgment: SnapshotBody | null = null; + database.observeNextBatchAfter(0, async () => { + const response = await accountRoute( + observer, + "account-a", + "GET", + "/attention/account/snapshot?since=2&streamId=account-a", + ); + duringAcknowledgment = await response.json() as SnapshotBody; + }); + const acknowledgment = await accountRoute( + database, + "account-a", + "POST", + "/attention/account/ack", + { + itemIds: [updated.id], + seenAt: "2026-07-28T08:02:00.000Z", + dismissedAt: null, + }, + ); + expect(acknowledgment.status).toBe(200); + expect(await acknowledgment.json()).toMatchObject({ revision: 3 }); + expect(duringAcknowledgment).toEqual(expect.objectContaining({ + revision: 2, + items: [], + tombstones: [], + })); + + const acknowledgedDelta = await ( + await accountRoute( + observer, + "account-a", + "GET", + "/attention/account/snapshot?since=2&streamId=account-a", + ) + ).json() as SnapshotBody; + expect(acknowledgedDelta.revision).toBe(3); + expect(acknowledgedDelta.items).toEqual([ + expect.objectContaining({ + id: updated.id, + seenAt: "2026-07-28T08:02:00.000Z", + }), + ]); + } finally { + observer.close(); + database.close(); + rmSync(directory, { recursive: true, force: true }); + } + }); + it("revives capacity-displaced items but seals confirmed removals", async () => { const database = new SqliteD1Database(); const env = makeAttentionEnv(database); @@ -526,6 +917,94 @@ describe("account Attention contract", () => { } }); + it("does not notify for an inbound item rejected by the committed account state", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T08:01:00.000Z")); + const database = new SqliteD1Database(); + const stale = attentionTestInternals.parseAttentionItem( + validAgentItem(), + MACHINE_KEY, + ); + expect(stale).not.toBeNull(); + if (!stale) { + database.close(); + return; + } + const currentRaw = validAgentItem(); + currentRaw.revision = stale.revision + 1; + currentRaw.fingerprint = "newer-fingerprint"; + currentRaw.updatedAt = "2026-07-28T08:00:30.000Z"; + const current = attentionTestInternals.parseAttentionItem( + currentRaw, + MACHINE_KEY, + ); + expect(current).not.toBeNull(); + if (!current) { + database.close(); + return; + } + const sendPush = vi.fn(async () => ({ + ok: true, + status: 200, + apnsId: "apns-id", + reason: null, + tokenInvalid: false, + })); + try { + insertAttentionDevice(database, { + userId: "account-a", + deviceId: "phone-1", + apnsToken: "ab".repeat(32), + }); + database.native.prepare(` + insert into attention_items( + user_id, item_id, machine_key, source_revision, account_revision, + fingerprint, event_kind, phase, payload_json, seen_at, dismissed_at, + expires_at, updated_at + ) values ( + 'account-a', ?, ?, ?, 2, ?, ?, ?, ?, null, null, ?, ? + ) + `).run( + current.id, + MACHINE_KEY, + current.revision, + current.fingerprint, + current.eventKind, + current.phase, + JSON.stringify(current), + current.expiresAt, + current.updatedAt, + ); + const env = makeAttentionEnv(database, { + APNS_KEY: "test-key", + APNS_KEY_ID: "TESTKEY123", + APNS_TEAM_ID: "TESTTEAM12", + }); + + await attentionTestInternals.deliverAttentionNotifications( + env, + "account-a", + [stale], + sendPush, + ); + expect(sendPush).not.toHaveBeenCalled(); + + database.native.prepare(` + delete from attention_items + where user_id = 'account-a' and item_id = ? + `).run(current.id); + await attentionTestInternals.deliverAttentionNotifications( + env, + "account-a", + [stale], + sendPush, + ); + expect(sendPush).not.toHaveBeenCalled(); + } finally { + database.close(); + } + }); + it("redacts lock-screen content without breaking exact PR routing metadata", () => { const privateState = attentionTestInternals.privacyPreservingActivityContentState({ updatedAt: 1_752_000_000, @@ -918,6 +1397,81 @@ describe("account Attention contract", () => { } }); + it("rolls back machine-transfer tombstones with their prior-account cursor", async () => { + const database = new SqliteD1Database(); + const env = makeAttentionEnv(database); + const parsed = attentionTestInternals.parseAttentionItem( + validAgentItem(), + MACHINE_KEY, + ); + expect(parsed).not.toBeNull(); + if (!parsed) { + database.close(); + return; + } + try { + await attentionTestInternals.linkMachineToAccount( + env, + "account-a", + MACHINE_KEY, + "Studio", + ); + database.native.prepare(` + insert into attention_revisions(user_id, revision, updated_at) + values ('account-a', 4, '2026-07-28T07:59:00.000Z') + `).run(); + database.native.prepare(` + insert into attention_items( + user_id, item_id, machine_key, source_revision, account_revision, + fingerprint, event_kind, phase, payload_json, seen_at, dismissed_at, + expires_at, updated_at + ) values (?, ?, ?, ?, 4, ?, ?, ?, ?, null, null, ?, ?) + `).run( + "account-a", + parsed.id, + MACHINE_KEY, + parsed.revision, + parsed.fingerprint, + parsed.eventKind, + parsed.phase, + JSON.stringify(parsed), + parsed.expiresAt, + parsed.updatedAt, + ); + + database.failNextBatchAt(2); + await expect(attentionTestInternals.linkMachineToAccount( + env, + "account-b", + MACHINE_KEY, + "Studio", + )).rejects.toThrow("injected batch failure at statement 2"); + + expect(row(database, ` + select revision + from attention_revisions + where user_id = 'account-a' + `)?.revision).toBe(4); + expect(row(database, ` + select account_revision + from attention_items + where user_id = 'account-a' and item_id = ? + `, parsed.id)?.account_revision).toBe(4); + expect(rows(database, ` + select item_id + from attention_tombstones + where user_id = 'account-a' + `)).toEqual([]); + expect(row(database, ` + select user_id + from attention_machine_links + where machine_key = ? + `, MACHINE_KEY)?.user_id).toBe("account-a"); + } finally { + database.close(); + } + }); + it("ends the previous account aggregate when a linked machine transfers", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-07-28T08:00:00.000Z")); diff --git a/apps/push-relay/test/relay.test.ts b/apps/push-relay/test/relay.test.ts index d7fd5170c..a764b1457 100644 --- a/apps/push-relay/test/relay.test.ts +++ b/apps/push-relay/test/relay.test.ts @@ -51,17 +51,45 @@ class FakeD1Database { activityTokens: ActivityTokenRow[] = []; suppressions: SuppressionRow[] = []; rateCounters = new Map(); + private nextBatchFailureIndex: number | null = null; prepare(sql: string): FakeD1Statement { return new FakeD1Statement(sql, this); } async batch(statements: FakeD1Statement[]): Promise> { - const results: Array<{ success: boolean }> = []; - for (const statement of statements) { - results.push(await statement.run()); + const snapshot = { + machines: this.machines.map((row) => ({ ...row })), + devices: this.devices.map((row) => ({ ...row })), + activityTokens: this.activityTokens.map((row) => ({ ...row })), + suppressions: this.suppressions.map((row) => ({ ...row })), + rateCounters: new Map( + [...this.rateCounters].map(([key, value]) => [key, { ...value }]), + ), + }; + try { + const results: Array<{ success: boolean }> = []; + for (const [index, statement] of statements.entries()) { + if (index === this.nextBatchFailureIndex) { + throw new Error(`injected batch failure at statement ${index}`); + } + results.push(await statement.run()); + } + return results; + } catch (error) { + this.machines = snapshot.machines; + this.devices = snapshot.devices; + this.activityTokens = snapshot.activityTokens; + this.suppressions = snapshot.suppressions; + this.rateCounters = snapshot.rateCounters; + throw error; + } finally { + this.nextBatchFailureIndex = null; } - return results; + } + + failNextBatchAt(index: number): void { + this.nextBatchFailureIndex = index; } first(sql: string, values: unknown[]): T | null { @@ -369,6 +397,19 @@ describe("push relay", () => { expect(signature).toBe("sha256=5c5c3a3081a0c6bec96c4191a88ab17b59382b902c6071672ea6d8daa30764f3"); // gitleaks:allow }); + it("rolls back the fake D1 batch when a later statement fails", async () => { + db.failNextBatchAt(1); + await expect(db.batch([ + db.prepare("insert into machines(machine_key, secret, created_at, last_seen_at) values (?, ?, ?, ?)") + .bind(MACHINE_KEY, SECRET, "2026-07-28T08:00:00.000Z", "2026-07-28T08:00:00.000Z"), + db.prepare("insert into publish_suppression(machine_key, suppression_key, content_hash, published_at) values (?, ?, ?, ?)") + .bind(MACHINE_KEY, "attention", "fingerprint", "2026-07-28T08:00:00.000Z"), + ])).rejects.toThrow("injected batch failure at statement 1"); + + expect(db.machines).toEqual([]); + expect(db.suppressions).toEqual([]); + }); + it("claims a machine once and allows an idempotent re-claim", async () => { const env = makeEnv(db, undefined); await claimMachine(db, env); From ccce46c4babd0e2947b8dce6130d0eb9cb4e4cb5 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:48:27 -0400 Subject: [PATCH 6/6] =?UTF-8?q?ship:=20iteration=205=20=E2=80=94=20address?= =?UTF-8?q?=20Attention=20delivery=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../push/pushPublisherService.test.ts | 11 ++- .../src/services/push/pushRelayClient.ts | 27 ++++--- apps/desktop/src/main/main.ts | 5 +- .../main/services/ipc/runtimeBridge.test.ts | 7 ++ apps/ios/ADE/Services/AccountService.swift | 72 ++++++++++++++++--- apps/ios/ADE/Services/KeychainService.swift | 29 +++++++- apps/ios/ADETests/PairingAndDpopTests.swift | 36 ++++++++++ 7 files changed, 162 insertions(+), 25 deletions(-) diff --git a/apps/ade-cli/src/services/push/pushPublisherService.test.ts b/apps/ade-cli/src/services/push/pushPublisherService.test.ts index 546a39f45..4811e1336 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.test.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.test.ts @@ -1453,7 +1453,7 @@ describe("createPushRelayClient", () => { expect(signature).toBe("sha256=5c5c3a3081a0c6bec96c4191a88ab17b59382b902c6071672ea6d8daa30764f3"); // gitleaks:allow }); - it("publishes Attention with both machine HMAC and account bearer authorization", async () => { + it("claims a fresh machine before publishing Attention with both authorizations", async () => { const client = createPushRelayClient({ store: makeStore(), logger, @@ -1466,7 +1466,14 @@ describe("createPushRelayClient", () => { items: [], }); - const [url, init] = fetchMock.mock.calls[0]; + expect(fetchMock).toHaveBeenCalledTimes(2); + const [claimUrl, claimInit] = fetchMock.mock.calls[0]; + expect(claimUrl).toBe(`https://relay.test/machines/${MACHINE_KEY}/claim`); + expect(claimInit.headers.authorization).toBeUndefined(); + expect(claimInit.headers["x-ade-push-signature"]).toBeUndefined(); + expect(JSON.parse(claimInit.body)).toEqual({ secret: MACHINE_SECRET }); + + const [url, init] = fetchMock.mock.calls[1]; expect(url).toBe(`https://relay.test/machines/${MACHINE_KEY}/attention`); expect(init.headers.authorization).toBe("Bearer account-access-token"); expect(init.headers["x-ade-push-signature"]).toBe( diff --git a/apps/ade-cli/src/services/push/pushRelayClient.ts b/apps/ade-cli/src/services/push/pushRelayClient.ts index b5015532c..58a7611cd 100644 --- a/apps/ade-cli/src/services/push/pushRelayClient.ts +++ b/apps/ade-cli/src/services/push/pushRelayClient.ts @@ -187,22 +187,26 @@ export function createPushRelayClient(args: { return `/machines/${machineKey}${suffix}`; }; + const claimMachine = async (): Promise => { + if (args.store.isClaimed()) return; + const { machineKey, machineSecret } = args.store.getOrCreateIdentity(); + const response = await request("POST", `/machines/${machineKey}/claim`, { + body: { secret: machineSecret }, + }); + // 200 (already claimed with same secret) and 201 (fresh) both mean claimed. + if (response.ok) { + args.store.markClaimed(); + return; + } + requireOk("claim", response); + }; + return { baseUrl, /** Idempotent claim; the relay treats a re-claim with the same secret as a no-op. */ async claim(): Promise { - if (args.store.isClaimed()) return; - const { machineKey, machineSecret } = args.store.getOrCreateIdentity(); - const response = await request("POST", `/machines/${machineKey}/claim`, { - body: { secret: machineSecret }, - }); - // 200 (already claimed with same secret) and 201 (fresh) both mean claimed. - if (response.ok) { - args.store.markClaimed(); - return; - } - requireOk("claim", response); + await claimMachine(); }, async registerDevice(registration: PushDeviceRegistration): Promise { @@ -247,6 +251,7 @@ export function createPushRelayClient(args: { async publishAttention(payload: AttentionRelayPublishPayload): Promise | null> { if (!args.getAccountAccessToken) return null; + await claimMachine(); const response = await request("POST", machinePath("/attention"), { body: payload, signed: true, diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index dfc6bdcb9..65b75d8dc 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -6727,7 +6727,10 @@ app.whenReady().then(async () => { const projectRoot = item.project.rootPath?.trim() ?? ""; if (projectRoot && fs.existsSync(projectRoot)) { const delivered = await deliverAppNavigationToProject(projectRoot, request); - const win = delivered.ok ? BrowserWindow.fromId(delivered.windowId) : null; + if (!delivered.ok) { + throw new Error(delivered.message); + } + const win = BrowserWindow.fromId(delivered.windowId); if (options.acknowledge) { await sendAttentionNotchAcknowledge( { itemId: item.id, mode: "seen" }, diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index 5a1b40944..e0a117a0d 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -1584,6 +1584,13 @@ describe("registerIpc sync bridge", () => { preferences: { account: { hideDetails: false } }, }); expect(openAttentionItem).toHaveBeenCalledWith(attentionItem); + + openAttentionItem.mockRejectedValueOnce( + new Error("No ADE window is available for this project."), + ); + await expect( + ipcHandlers.get(IPC.attentionOpenItem)?.(eventForSender(), attentionItem), + ).rejects.toThrow("No ADE window is available for this project."); }); it("rejects a stale renderer Attention preference owner before calling the runtime", async () => { diff --git a/apps/ios/ADE/Services/AccountService.swift b/apps/ios/ADE/Services/AccountService.swift index ceec6107e..c07239fa6 100644 --- a/apps/ios/ADE/Services/AccountService.swift +++ b/apps/ios/ADE/Services/AccountService.swift @@ -94,23 +94,66 @@ struct AccountDeviceOwnershipState: Codable, Equatable { struct AccountDeviceOwnershipStore { private let defaults: UserDefaults private let key: String + private let durableDeviceId: () -> String? + private let loadDurableEpoch: (String) -> Int? + private let saveDurableEpoch: (Int, String) -> Void + + init() { + let keychain = KeychainService() + self.init( + defaults: ADESharedContainer.defaults, + key: ADESharedContainer.accountDeviceOwnershipStateKey, + durableDeviceId: { keychain.loadDeviceId() }, + loadDurableEpoch: { keychain.loadAccountDeviceOwnershipEpoch(deviceId: $0) }, + saveDurableEpoch: { + keychain.saveAccountDeviceOwnershipEpoch($0, deviceId: $1) + } + ) + } + + init(defaults: UserDefaults, key: String) { + self.init( + defaults: defaults, + key: key, + durableDeviceId: { nil }, + loadDurableEpoch: { _ in nil }, + saveDurableEpoch: { _, _ in } + ) + } init( - defaults: UserDefaults = ADESharedContainer.defaults, - key: String = ADESharedContainer.accountDeviceOwnershipStateKey + defaults: UserDefaults, + key: String, + durableDeviceId: @escaping () -> String?, + loadDurableEpoch: @escaping (String) -> Int?, + saveDurableEpoch: @escaping (Int, String) -> Void ) { self.defaults = defaults self.key = key + self.durableDeviceId = durableDeviceId + self.loadDurableEpoch = loadDurableEpoch + self.saveDurableEpoch = saveDurableEpoch } var state: AccountDeviceOwnershipState { - guard let data = defaults.data(forKey: key), - let decoded = try? JSONDecoder().decode(AccountDeviceOwnershipState.self, from: data), - decoded.ownershipEpoch > 0, - decoded.ownershipEpoch <= AccountDeviceOwnershipState.maximumSafeEpoch else { - return AccountDeviceOwnershipState(ownershipEpoch: 1, ownerId: nil) + let sharedState: AccountDeviceOwnershipState + if let data = defaults.data(forKey: key), + let decoded = try? JSONDecoder().decode(AccountDeviceOwnershipState.self, from: data), + Self.isValid(epoch: decoded.ownershipEpoch) { + sharedState = decoded + } else { + sharedState = AccountDeviceOwnershipState(ownershipEpoch: 1, ownerId: nil) } - return decoded + guard let deviceId = normalizedDurableDeviceId, + let durableEpoch = loadDurableEpoch(deviceId), + Self.isValid(epoch: durableEpoch), + durableEpoch > sharedState.ownershipEpoch else { + return sharedState + } + // App Group defaults are removed on reinstall while the Keychain device + // identity survives. Recover the epoch high-water mark, but never infer an + // owner from stale defaults; the next signed-in transition advances it. + return AccountDeviceOwnershipState(ownershipEpoch: durableEpoch, ownerId: nil) } /// Commits the account boundary before any network request is allowed to use @@ -142,6 +185,19 @@ struct AccountDeviceOwnershipStore { guard let data = try? JSONEncoder().encode(state) else { return } defaults.set(data, forKey: key) defaults.synchronize() + if let deviceId = normalizedDurableDeviceId { + saveDurableEpoch(state.ownershipEpoch, deviceId) + } + } + + private var normalizedDurableDeviceId: String? { + let normalized = durableDeviceId()? + .trimmingCharacters(in: .whitespacesAndNewlines) + return normalized?.isEmpty == false ? normalized : nil + } + + private static func isValid(epoch: Int) -> Bool { + epoch > 0 && epoch <= AccountDeviceOwnershipState.maximumSafeEpoch } } diff --git a/apps/ios/ADE/Services/KeychainService.swift b/apps/ios/ADE/Services/KeychainService.swift index 95d9c1f1e..ad104b635 100644 --- a/apps/ios/ADE/Services/KeychainService.swift +++ b/apps/ios/ADE/Services/KeychainService.swift @@ -5,6 +5,7 @@ final class KeychainService { private let service = "com.ade.ios.sync" private let tokenAccount = "connection-token" private let deviceIdAccount = "device-id" + private let accountDeviceOwnershipEpochPrefix = "attention-ownership-epoch:" private func tokenAccount(for hostKey: String?) -> String { guard let hostKey, !hostKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { @@ -102,10 +103,32 @@ final class KeychainService { loadString(account: deviceIdAccount) } + func saveAccountDeviceOwnershipEpoch(_ epoch: Int, deviceId: String) { + guard epoch > 0, + let account = accountDeviceOwnershipEpochAccount(deviceId: deviceId) else { + return + } + saveString(String(epoch), account: account) + } + + func loadAccountDeviceOwnershipEpoch(deviceId: String) -> Int? { + guard let account = accountDeviceOwnershipEpochAccount(deviceId: deviceId), + let raw = loadString(account: account) else { + return nil + } + return Int(raw) + } + + private func accountDeviceOwnershipEpochAccount(deviceId: String) -> String? { + let normalized = deviceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return nil } + return "\(accountDeviceOwnershipEpochPrefix)\(normalized)" + } + /// Removes every saved machine pairing secret while preserving this - /// installation's device identity. Used by the versioned trust reset: ADE - /// must not accidentally revive a pre-reset machine through a keyed or - /// legacy token alias. + /// installation's device identity and ownership epoch. Used by the versioned + /// trust reset: ADE must not accidentally revive a pre-reset machine through + /// a keyed or legacy token alias. @discardableResult func clearAllConnectionTokens() -> Bool { let query: [String: Any] = [ diff --git a/apps/ios/ADETests/PairingAndDpopTests.swift b/apps/ios/ADETests/PairingAndDpopTests.swift index 9e4de0278..8566ac06a 100644 --- a/apps/ios/ADETests/PairingAndDpopTests.swift +++ b/apps/ios/ADETests/PairingAndDpopTests.swift @@ -620,6 +620,42 @@ final class PairingAndDpopTests: XCTestCase { ) } + func testAccountDeviceOwnershipEpochRecoversAfterAppGroupReset() throws { + let suiteName = "PairingAndDpopTests.ownership-reinstall.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let key = "ownership" + let deviceId = "durable-device" + var durableEpochs: [String: Int] = [:] + let makeStore = { + AccountDeviceOwnershipStore( + defaults: defaults, + key: key, + durableDeviceId: { deviceId }, + loadDurableEpoch: { durableEpochs[$0] }, + saveDurableEpoch: { durableEpochs[$1] = $0 } + ) + } + + let original = makeStore() + XCTAssertEqual(original.transition(to: "user-a").ownershipEpoch, 2) + XCTAssertEqual(original.transition(to: nil).ownershipEpoch, 3) + XCTAssertEqual(durableEpochs[deviceId], 3) + + defaults.removeObject(forKey: key) + let reinstalled = makeStore() + XCTAssertEqual( + reinstalled.state, + AccountDeviceOwnershipState(ownershipEpoch: 3, ownerId: nil), + "A reset App Group must recover the high-water mark without restoring a stale owner" + ) + XCTAssertEqual( + reinstalled.transition(to: "user-a"), + AccountDeviceOwnershipState(ownershipEpoch: 4, ownerId: "user-a"), + "The surviving device identity must advance past Relay's prior ownership epoch" + ) + } + @MainActor func testAccountRegistrationSerializesDelayedAThenRunsLatestB() async { let queue = LatestAccountRegistrationQueue()