diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 0516adf6c..187d82ff0 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -182,7 +182,7 @@ The runtime exposes two layers of JSON-RPC methods (`src/multiProjectRpcServer.t ```text ade/initialize ade/initialized ping shutdown exit runtime/info machineInfo.get -account.call +account.call attention.call projects.list projects.add projects.remove projects.touch projects.browseDirectories projects.getDetail projects.getWorkSummary projects.getDefaultParentDir @@ -208,6 +208,13 @@ directory operations. Prefer the typed `ade login`, `ade auth status`, commands; they select the CTO role where credential-bearing operations require it and keep account-machine pairing on the DPoP-bound runtime path. +`attention.call` is the CTO-gated account-wide Activity surface backing `ade +code`'s `/activity` pane (`getSnapshot`, `getMachineSnapshot`, `acknowledge`, +`reportPresence`, `getPreferences`, `putPreferences`). `attention` stays as the +frozen wire identifier for the method, the action domain, and the item ids even +though the product surface is now called Activity. Agents on a desktop endpoint +reach the same operations through `ade actions run attention.`. + `runtimeEvents.subscribe` returns `eventEpoch`, `nextCursor`, `hasMore`, `gap`, and `oldestCursor`; when `gap` is true, the caller's cursor predates the retained buffer and it should refresh state before resuming from `oldestCursor` / `nextCursor`. `personalChats.call` dispatches the machine action registry advertised as @@ -489,7 +496,7 @@ 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 list --domain attention --text # discover account-wide Activity actions (domain name is a frozen wire identifier) 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 diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 6922bcf0d..36195cfef 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -3465,7 +3465,7 @@ describe("adeRpcServer", () => { (entry: { name: string }) => entry.name === "attention.getSnapshot", ); expect(getSnapshotAction).toMatchObject({ - description: expect.stringContaining("account-wide Attention stream"), + description: expect.stringContaining("account-wide Activity stream"), input: expect.stringContaining("streamId"), example: expect.stringContaining("attention.getSnapshot"), }); diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 5febf3db3..986c1dd94 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -138,7 +138,7 @@ import type { BuiltInBrowserDesktopBridgeClient } from "./services/builtInBrowse import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; import { createPushRegistrationStore } from "./services/push/pushRegistrationStore"; import { createPushRelayClient } from "./services/push/pushRelayClient"; -import { getSharedPushPublisherService, resolvePushRelayStateFile, type PushPrNotification, type PushPublisherService } from "./services/push/pushPublisherService"; +import { getSharedPushPublisherService, resolvePushRelayStateFile, type PushPrNotification, type PushPublisherDeps, type PushPublisherService } from "./services/push/pushPublisherService"; import type { createFileService } from "../../desktop/src/main/services/files/fileService"; import type { AppNavigationRequest, AppNavigationResult, PortLease } from "../../desktop/src/shared/types"; import type { PrEventPayload } from "../../desktop/src/shared/types/prs"; @@ -235,6 +235,7 @@ export type AdeRuntimeSyncOptions = { phonePairingStateDir?: string; projectCatalogProvider?: Parameters[0]["projectCatalogProvider"]; rosterProvider?: Parameters[0]["rosterProvider"]; + activityRosterProvider?: PushPublisherDeps["activityRosterProvider"]; foreignChatProvider?: Parameters[0]["foreignChatProvider"]; personalChatScope?: Parameters[0]["personalChatScope"]; remoteCommandExecutor?: Parameters[0]["remoteCommandExecutor"]; @@ -1530,8 +1531,12 @@ export async function createAdeRuntime(args: { } return { machineKey, deviceId }; }, + activityRosterProvider: resolvedArgs.syncRuntime?.activityRosterProvider, }; }); + pushPublisherService.setActivityRosterProvider( + resolvedArgs.syncRuntime?.activityRosterProvider ?? null, + ); const detachPushSources = publishPushEvents ? pushPublisherService.attachSources(projectId, { // The lightweight no-agent headless chat stub intentionally exposes diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index c88e5a793..2344ba6fe 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -2306,7 +2306,7 @@ const HELP_BY_COMMAND: Record = { $ 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 + Discover account-wide Activity 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"}' @@ -15989,6 +15989,17 @@ async function runServe( personalChatScope, }), ); + // Shared by mobile roster delivery and protocol-2 Activity publishing. The + // closure is evaluated only after `scopeRegistry` has been assigned. + const activityRosterProvider = { + buildSnapshot: () => + buildRosterSnapshot({ + projectRegistry, + scopeRegistry, + hostProjectId: preferredSyncProjectId, + logger: headlessProjectLogger, + }), + }; scopeRegistry = new ProjectScopeRegistry(projectRegistry, { syncRuntime: { enabled: syncEnabled, @@ -16008,15 +16019,8 @@ async function runServe( // which is assigned by this very `new ProjectScopeRegistry(...)` call — // safe because `buildSnapshot` only runs later (on `roster_subscribe`), // by which point the binding is set (mirrors machineProjectCatalogProvider). - rosterProvider: { - buildSnapshot: () => - buildRosterSnapshot({ - projectRegistry, - scopeRegistry, - hostProjectId: preferredSyncProjectId, - logger: headlessProjectLogger, - }), - }, + rosterProvider: activityRosterProvider, + activityRosterProvider, // Cross-project chat "quick look": lets the phone stream a foreign // project's chat transcript read-only without a project switch. Reads // straight off that project's `.ade` transcripts dir (registry-validated, diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index bd6966d70..c1f90b2a1 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -1261,7 +1261,7 @@ export function createMultiProjectRpcRequestHandler( if (!publisher) { throw new JsonRpcError( JsonRpcErrorCode.invalidRequest, - "Account Attention is unavailable until the ADE brain is ready.", + "Account Activity is unavailable until the ADE brain is ready.", ); } if (action === "getSnapshot") { @@ -1349,7 +1349,7 @@ export function createMultiProjectRpcRequestHandler( if (!accountOwnerId || currentAccountOwnerUserId() !== accountOwnerId) { throw new JsonRpcError( JsonRpcErrorCode.invalidRequest, - "The ADE account changed before Attention preferences could be read.", + "The ADE account changed before Activity preferences could be read.", ); } return await publisher.getAttentionPreferences(accountOwnerId); @@ -1364,7 +1364,7 @@ export function createMultiProjectRpcRequestHandler( ) { throw new JsonRpcError( JsonRpcErrorCode.invalidRequest, - "The ADE account changed before Attention preferences could be saved.", + "The ADE account changed before Activity preferences could be saved.", ); } await publisher.putAttentionPreferences( @@ -1375,7 +1375,7 @@ export function createMultiProjectRpcRequestHandler( } throw new JsonRpcError( JsonRpcErrorCode.methodNotFound, - `Unknown Attention action: ${action || "(empty)"}`, + `Unknown Activity action: ${action || "(empty)"}`, ); } diff --git a/apps/ade-cli/src/services/push/activityFingerprint.test.ts b/apps/ade-cli/src/services/push/activityFingerprint.test.ts new file mode 100644 index 000000000..0fc639ad3 --- /dev/null +++ b/apps/ade-cli/src/services/push/activityFingerprint.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { + ATTENTION_CONTRACT_VERSION, + type AttentionItem, +} from "../../../../desktop/src/shared/types/attention"; +import { + activityAlertFingerprint, + activityContentFingerprint, +} from "./activityFingerprint"; + +function item(overrides: Partial = {}): AttentionItem { + return { + contractVersion: ATTENTION_CONTRACT_VERSION, + id: "agent:machine:session", + revision: 1, + fingerprint: "legacy", + activityTier: "ambient", + kind: "agent", + eventKind: "agent_running", + phase: "running", + machine: { + machineKey: "machine", + name: "MacBook", + online: true, + lastSeenAt: "2026-08-01T12:00:00.000Z", + }, + project: { projectId: "project", name: "ADE" }, + laneId: "lane", + laneName: "feature", + provider: "Codex", + model: "gpt-5", + title: "Codex is working", + preview: "Working for 1.2s · 120 tokens · 3 files", + privacyPreview: "An ADE agent is working.", + destination: { kind: "session", sessionId: "session", itemId: "approval-1" }, + actions: [{ id: "open", kind: "open", label: "Open" }], + occurredAt: "2026-08-01T12:00:00.000Z", + updatedAt: "2026-08-01T12:00:00.000Z", + statusSince: "2026-08-01T12:00:00.000Z", + seenAt: null, + dismissedAt: null, + expiresAt: "2026-08-01T14:00:00.000Z", + ...overrides, + }; +} + +describe("Activity fingerprints", () => { + it("keeps content and alert identity stable across elapsed/count preview churn", () => { + const first = item(); + const churned = item({ + revision: 99, + preview: "Working for 48.9s · 8,420 tokens · 27 files", + occurredAt: "2026-08-01T12:00:48.000Z", + updatedAt: "2026-08-01T12:00:48.000Z", + expiresAt: "2026-08-01T14:00:48.000Z", + machine: { + ...first.machine, + online: false, + lastSeenAt: "2026-08-01T12:00:48.000Z", + }, + detail: "different noisy detail", + recentActivity: ["tool event"], + }); + + expect(activityContentFingerprint(churned)).toBe(activityContentFingerprint(first)); + expect(activityAlertFingerprint(churned)).toBe(activityAlertFingerprint(first)); + }); + + it("changes alert identity when the session destination item changes", () => { + const first = item(); + const nextApproval = item({ + destination: { kind: "session", sessionId: "session", itemId: "approval-2" }, + }); + + expect(activityAlertFingerprint(nextApproval)).not.toBe(activityAlertFingerprint(first)); + }); + + it("changes alert identity when a session or pull request re-enters a phase", () => { + const firstQuestion = item({ + eventKind: "agent_needs_you", + phase: "needs_you", + statusSince: "2026-08-01T12:00:00.000Z", + destination: { kind: "session", sessionId: "session", itemId: null }, + }); + const reenteredQuestion = item({ + ...firstQuestion, + statusSince: "2026-08-01T12:05:00.000Z", + }); + const firstReview = item({ + id: "pull-request:machine:42", + kind: "pull_request", + eventKind: "pr_review_requested", + phase: "review_requested", + statusSince: "2026-08-01T12:00:00.000Z", + destination: { kind: "pull_request", number: 42, tab: "activity" }, + }); + const reenteredReview = item({ + ...firstReview, + statusSince: "2026-08-01T12:05:00.000Z", + }); + + expect(activityAlertFingerprint(reenteredQuestion)) + .not.toBe(activityAlertFingerprint(firstQuestion)); + expect(activityAlertFingerprint(reenteredReview)) + .not.toBe(activityAlertFingerprint(firstReview)); + }); +}); diff --git a/apps/ade-cli/src/services/push/activityFingerprint.ts b/apps/ade-cli/src/services/push/activityFingerprint.ts new file mode 100644 index 000000000..cd280c83d --- /dev/null +++ b/apps/ade-cli/src/services/push/activityFingerprint.ts @@ -0,0 +1,88 @@ +import { createHash } from "node:crypto"; +import { + sanitizeAttentionPreview, + type AttentionItem, +} from "../../../../desktop/src/shared/types/attention"; + +function sha256(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value), "utf8").digest("hex"); +} + +/** + * Remove high-frequency progress copy that does not change what an Activity + * row means. The sanitized preview remains part of the content identity; only + * elapsed durations and token/file counters are normalized away. + */ +export function normalizeActivityPreview(value: string): string { + return sanitizeAttentionPreview(value) + .replace(/\b\d+(?:\.\d+)?\s?(?:ms|s|m|h)\b/gi, "") + .replace(/\b\d[\d,]*(?:\.\d+)?(?=\s+(?:tokens?|files?)\b)/gi, "#") + .replace(/\s+/g, " ") + .trim(); +} + +/** What the Activity row looks like, excluding timestamps and ack state. */ +export function activityContentFingerprint(item: AttentionItem): string { + return sha256({ + id: item.id, + kind: item.kind, + eventKind: item.eventKind, + phase: item.phase, + activityTier: item.activityTier ?? null, + laneId: item.laneId ?? null, + laneName: item.laneName ?? null, + provider: item.provider ?? null, + model: item.model ?? null, + title: item.title, + projectId: item.project.projectId, + projectName: item.project.name, + destination: item.destination, + actions: item.actions.map((action) => `${action.id}:${action.kind}`), + planProgress: item.planProgress ?? null, + normalizedPreview: normalizeActivityPreview(item.preview), + }); +} + +/** Stable identity of one phase entry, independent of preview-copy churn. */ +export function activityAlertFingerprint(item: AttentionItem): string { + if (item.kind === "pull_request" && item.destination.kind === "pull_request") { + return sha256({ + id: item.id, + eventKind: item.eventKind, + phase: item.phase, + statusSince: item.statusSince ?? null, + number: item.destination.number, + }); + } + return sha256({ + id: item.id, + eventKind: item.eventKind, + phase: item.phase, + statusSince: item.statusSince ?? null, + itemId: item.destination.kind === "session" + ? item.destination.itemId ?? "" + : "", + }); +} + +/** Cheap publisher-side change detector, including acknowledgment changes. */ +export function activityPublishFingerprint(item: AttentionItem): string { + return [ + item.contentFingerprint ?? activityContentFingerprint(item), + item.alertFingerprint ?? activityAlertFingerprint(item), + item.seenAt ?? "", + item.dismissedAt ?? "", + ].join("\u0000"); +} + +/** Populate the split fingerprints while preserving the legacy field. */ +export function withActivityFingerprints(item: AttentionItem): AttentionItem { + const contentFingerprint = activityContentFingerprint(item); + const alertFingerprint = activityAlertFingerprint(item); + return { + ...item, + fingerprint: contentFingerprint, + contentFingerprint, + alertFingerprint, + }; +} diff --git a/apps/ade-cli/src/services/push/pushPublisherService.test.ts b/apps/ade-cli/src/services/push/pushPublisherService.test.ts index 7f0b9cf02..fb302e417 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.test.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.test.ts @@ -5,6 +5,7 @@ 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 { SyncRosterProject } from "../../../../desktop/src/shared/types/sync"; import type { PushDeviceRegistration, PushQuietHours, @@ -13,6 +14,7 @@ import { createPushRegistrationStore, type PushRegistrationStore, type StoredAttentionAcknowledgment, + type StoredRemoteAttentionAcknowledgment, } from "./pushRegistrationStore"; import { createPushRelayClient } from "./pushRelayClient"; import { @@ -44,11 +46,39 @@ function run(overrides: Partial): AgentRunState { itemId: null, startedAt: 0, lastActiveAt: 0, + statusSinceAt: 0, metaResolved: true, ...overrides, }; } +function rosterProject(count: number, lastActivityAt = "2026-08-01T12:00:00.000Z"): SyncRosterProject { + return { + projectId: "roster-project", + rootPath: "/projects/roster", + displayName: "Roster project", + booted: false, + runningCount: 0, + attentionCount: 0, + lanes: [{ id: "lane-roster", name: "Roster lane" }], + chats: Array.from({ length: count }, (_, index) => ({ + id: `disk-session-${String(index).padStart(3, "0")}`, + laneId: "lane-roster", + title: `Disk session ${index}`, + provider: "codex", + model: "gpt-5", + toolType: "codex-chat", + status: "idle" as const, + lastActivityAt, + preview: `Processed ${index} files in 12s`, + })), + }; +} + +async function settleMicrotasks(): Promise { + for (let index = 0; index < 12; index += 1) await Promise.resolve(); +} + describe("quiet hours", () => { it("parses HH:MM and rejects malformed input", () => { expect(parseHhMm("22:00")).toBe(22 * 60); @@ -149,6 +179,12 @@ describe("createPushPublisherService flush", () => { function makeHarness( deviceOverride: typeof device | Array = device, now?: () => number, + options: { + activityProtocol?: number | null; + activityRosterProvider?: { buildSnapshot(): Promise } | null; + lastPublishedRevisionById?: Record; + remoteAttentionAcknowledgments?: StoredRemoteAttentionAcknowledgment[]; + } = {}, ) { const publish = vi.fn().mockResolvedValue({ ok: true }); const publishAttention = vi.fn().mockResolvedValue(null); @@ -156,6 +192,19 @@ describe("createPushPublisherService flush", () => { let accountOwnerId: string | null = "owner-a"; const devices = Array.isArray(deviceOverride) ? [...deviceOverride] : [deviceOverride]; const attentionAcknowledgments = new Map(); + const remoteAttentionAcknowledgments = new Map(); + for (const acknowledgment of options.remoteAttentionAcknowledgments ?? []) { + remoteAttentionAcknowledgments.set( + `${acknowledgment.accountOwnerId ?? ""}\u0000${acknowledgment.itemId}`, + acknowledgment, + ); + } + let activityProtocol = options.activityProtocol ?? null; + let activityRosterEpoch = 0; + let lastPublishedActivityRevisions = { + accountOwnerId: "owner-a" as string | null, + revisions: { ...(options.lastPublishedRevisionById ?? {}) }, + }; const attentionAcknowledgmentKey = ( accountOwnerId: string | null, itemId: string, @@ -240,6 +289,49 @@ describe("createPushPublisherService flush", () => { } } }, + recordRemoteAttentionAcknowledgments: (args: { + accountOwnerId: string | null; + acknowledgments: Array<{ + itemId: string; + sourceRevision: number; + seenAt: string | null; + dismissedAt: string | null; + }>; + updatedAt: string; + }) => { + for (const acknowledgment of args.acknowledgments) { + const key = attentionAcknowledgmentKey(args.accountOwnerId, acknowledgment.itemId); + remoteAttentionAcknowledgments.set(key, { + ...acknowledgment, + accountOwnerId: args.accountOwnerId, + updatedAt: args.updatedAt, + }); + } + }, + listRemoteAttentionAcknowledgments: (ownerId?: string | null) => + [...remoteAttentionAcknowledgments.values()].filter((acknowledgment) => + ownerId === undefined || acknowledgment.accountOwnerId === ownerId), + getActivityProtocol: () => activityProtocol, + setActivityProtocol: (protocol: number | null) => { + activityProtocol = protocol; + }, + nextActivityRosterEpoch: vi.fn(() => { + activityRosterEpoch += 1; + return activityRosterEpoch; + }), + getLastPublishedActivityRevisions: () => ({ + accountOwnerId: lastPublishedActivityRevisions.accountOwnerId, + revisions: { ...lastPublishedActivityRevisions.revisions }, + }), + setLastPublishedActivityRevisions: (value: { + accountOwnerId: string | null; + revisions: Record; + }) => { + lastPublishedActivityRevisions = { + accountOwnerId: value.accountOwnerId, + revisions: { ...value.revisions }, + }; + }, }; const relayClient = { publish, @@ -271,8 +363,14 @@ describe("createPushPublisherService flush", () => { summary: null, }), }; + const publisherLogger = { + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + }; const publisher = createPushPublisherService({ - logger: { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() } as never, + logger: publisherLogger as never, store: store as never, relayClient: relayClient as never, machineName: "MacBook", @@ -284,6 +382,7 @@ describe("createPushPublisherService flush", () => { now, flushDebounceMs: 2_000, promptFlushMs: 150, + activityRosterProvider: options.activityRosterProvider, }); const cliSessions = new Map { cliSessions, detach, attentionAcknowledgments, + remoteAttentionAcknowledgments, + publisherLogger, + getLastPublishedActivityRevisions: () => ({ + accountOwnerId: lastPublishedActivityRevisions.accountOwnerId, + revisions: { ...lastPublishedActivityRevisions.revisions }, + }), getAttentionAcknowledgment: ( itemId: string, ownerId: string | null = accountOwnerId, @@ -1029,7 +1134,7 @@ describe("createPushPublisherService flush", () => { itemIds: ["agent:other-machine:unknown"], sourceRevisions: { "agent:other-machine:unknown": 1 }, expectedAccountOwnerId: "owner-a", - })).rejects.toThrow(/latest Attention snapshot/i); + })).rejects.toThrow(/latest Activity snapshot/i); const current = (await publisher.getMachineAttentionSnapshot()).items[0]!; setAccountOwnerId("owner-b"); @@ -1056,9 +1161,9 @@ describe("createPushPublisherService flush", () => { getAttentionAcknowledgment, } = makeHarness(); publishAttention.mockResolvedValue({ ok: true, revision: 1 }); - acknowledgeAttention.mockResolvedValue({ ok: true, revision: 2 }); emit(approval); const item = (await publisher.getMachineAttentionSnapshot()).items[0]!; + acknowledgeAttention.mockResolvedValue({ applied: [item.id], stale: [] }); await publisher.acknowledgeMachineAttention({ itemIds: [item.id], @@ -1073,6 +1178,7 @@ describe("createPushPublisherService flush", () => { expect(publishAttention).toHaveBeenCalledTimes(1); expect(acknowledgeAttention).toHaveBeenCalledWith({ itemIds: [item.id], + sourceRevisions: { [item.id]: item.revision }, seenAt: "2026-07-05T12:00:01.000Z", expectedAccountOwnerId: "owner-a", }); @@ -1080,6 +1186,57 @@ describe("createPushPublisherService flush", () => { publisher.dispose(); }); + it("keeps relay-stale machine acknowledgments pending with their source fence", async () => { + const { + publisher, + emit, + publishAttention, + acknowledgeAttention, + getAttentionAcknowledgment, + } = makeHarness(device, undefined, { activityProtocol: 2 }); + emit(approval); + const item = (await publisher.getMachineAttentionSnapshot()).items[0]!; + const refreshedRevision = item.revision + 1; + publishAttention.mockResolvedValue({ + ok: true, + protocol: 2, + revision: 1, + acks: [{ + itemId: item.id, + sourceRevision: refreshedRevision, + seenAt: null, + dismissedAt: null, + }], + }); + acknowledgeAttention + .mockResolvedValueOnce({ applied: [], stale: [item.id] }) + .mockResolvedValueOnce({ applied: [item.id], stale: [] }); + + await publisher.acknowledgeMachineAttention({ + itemIds: [item.id], + sourceRevisions: { [item.id]: item.revision }, + expectedAccountOwnerId: "owner-a", + seenAt: "2026-07-05T12:00:01.000Z", + }); + await vi.advanceTimersByTimeAsync(200); + + expect(acknowledgeAttention).toHaveBeenCalledWith(expect.objectContaining({ + itemIds: [item.id], + sourceRevisions: { [item.id]: item.revision }, + })); + expect(getAttentionAcknowledgment(item.id, "owner-a")?.pendingRelaySync).toBe(true); + expect(publishAttention).toHaveBeenCalledTimes(1); + + publisher.poke(); + await vi.advanceTimersByTimeAsync(200); + + expect(acknowledgeAttention).toHaveBeenCalledTimes(2); + expect(acknowledgeAttention.mock.calls[1]?.[0].sourceRevisions) + .toEqual({ [item.id]: refreshedRevision }); + expect(getAttentionAcknowledgment(item.id, "owner-a")?.pendingRelaySync).toBe(false); + publisher.dispose(); + }); + it("stops a multi-group acknowledgment reconcile when the account changes mid-flight", async () => { const { publisher, @@ -1116,7 +1273,7 @@ describe("createPushPublisherService flush", () => { }); acknowledgeAttention.mockImplementationOnce(async () => { setAccountOwnerId("owner-b"); - return { ok: true, revision: 2 }; + return { applied: [first.id], stale: [] }; }); await vi.advanceTimersByTimeAsync(200); @@ -1149,7 +1306,7 @@ describe("createPushPublisherService flush", () => { publisher.dispose(); }); - it("advances the Attention revision without spamming a duplicate alert", async () => { + it("does not republish when only the source revision advances", async () => { const fixedNow = Date.parse("2026-07-05T12:00:00.000Z"); const { publisher, publish, publishAttention, emit } = makeHarness( device, @@ -1169,8 +1326,8 @@ describe("createPushPublisherService flush", () => { emit(approval); await vi.advanceTimersByTimeAsync(200); - expect(publishAttention).toHaveBeenCalledTimes(2); - expect(publish).not.toHaveBeenCalled(); + expect(publishAttention).toHaveBeenCalledTimes(1); + expect(publish).toHaveBeenCalledTimes(1); publisher.dispose(); }); @@ -1227,6 +1384,582 @@ describe("createPushPublisherService flush", () => { publisher.dispose(); }); + it("coalesces 50 running-agent events into exactly one protocol-2 publish", async () => { + const { publisher, publishAttention, emit } = makeHarness( + device, + undefined, + { activityProtocol: 2 }, + ); + publishAttention.mockResolvedValue({ ok: true, protocol: 2, revision: 1, acks: [] }); + + for (let index = 0; index < 50; index += 1) { + emit({ + sessionId: "s-running", + timestamp: new Date().toISOString(), + event: { type: "text", text: `stream chunk ${index}` }, + }); + } + await vi.advanceTimersByTimeAsync(2_500); + + expect(publishAttention).toHaveBeenCalledTimes(1); + expect(publishAttention.mock.calls[0][0]).toMatchObject({ + mode: "reconcile", + page: 0, + final: true, + items: [expect.objectContaining({ phase: "running", activityTier: "ambient" })], + }); + publisher.dispose(); + }); + + it("publishes changed items and dropped ids as an explicit delta", async () => { + const { publisher, publishAttention, emit } = makeHarness( + device, + undefined, + { activityProtocol: 2 }, + ); + publishAttention.mockResolvedValue({ ok: true, protocol: 2, revision: 1, acks: [] }); + emit({ + sessionId: "s-running", + timestamp: "", + event: { type: "text", text: "working" }, + }); + await vi.advanceTimersByTimeAsync(2_500); + publishAttention.mockClear(); + + publisher._debug.onPtyExit("scope-1", { + ptyId: "pty-s-running", + sessionId: "s-running", + laneId: "auth-lane", + exitCode: 130, + }); + await vi.advanceTimersByTimeAsync(2_500); + + expect(publishAttention).toHaveBeenCalledTimes(1); + expect(publishAttention.mock.calls[0][0]).toMatchObject({ + mode: "delta", + items: [], + tombstones: [expect.objectContaining({ + id: `agent:${"a".repeat(40)}:s-running`, + deletedAt: expect.any(String), + })], + }); + publisher.dispose(); + }); + + it("pages a 200-session roster reconcile under the item and body caps", async () => { + const buildSnapshot = vi.fn().mockResolvedValue([rosterProject(200)]); + const { publisher, publishAttention } = makeHarness( + device, + undefined, + { + activityProtocol: 2, + activityRosterProvider: { buildSnapshot }, + }, + ); + publishAttention.mockResolvedValue({ ok: true, protocol: 2, revision: 1, acks: [] }); + + await publisher.start(); + await vi.advanceTimersByTimeAsync(200); + + expect(buildSnapshot).toHaveBeenCalledTimes(1); + expect(publishAttention).toHaveBeenCalledTimes(5); + const payloads = publishAttention.mock.calls.map(([payload]) => payload); + expect(payloads.every((payload) => payload.mode === "reconcile")).toBe(true); + expect(payloads.every((payload) => payload.items.length <= 48)).toBe(true); + expect(payloads.every((payload) => payload.tombstones.length <= 48)).toBe(true); + expect(payloads.slice(0, -1).every((payload) => payload.final === false)).toBe(true); + expect(payloads.at(-1)?.final).toBe(true); + expect(payloads.flatMap((payload) => payload.items)).toHaveLength(200); + expect( + payloads.every((payload) => Buffer.byteLength(JSON.stringify(payload), "utf8") < 256 * 1024), + ).toBe(true); + publisher.dispose(); + }); + + it("shrinks the roster cap by ten percent when the relay truncates items", async () => { + const buildSnapshot = vi.fn().mockResolvedValue([rosterProject(300)]); + const { publisher, publishAttention } = makeHarness( + device, + undefined, + { + activityProtocol: 2, + activityRosterProvider: { buildSnapshot }, + }, + ); + publishAttention.mockImplementation(async () => ({ + ok: true, + protocol: 2, + revision: 1, + acks: [], + itemsTruncated: publishAttention.mock.calls.length === 1, + })); + + await publisher.start(); + await vi.advanceTimersByTimeAsync(200); + const snapshot = await publisher.getMachineAttentionSnapshot(); + + expect(snapshot.items).toHaveLength(270); + publisher.dispose(); + }); + + it("never ratchets the roster cap below 100 and resets it for a new publisher", async () => { + const buildSnapshot = vi.fn().mockResolvedValue([rosterProject(300)]); + const first = makeHarness(device, undefined, { + activityProtocol: 2, + activityRosterProvider: { buildSnapshot }, + }); + first.publishAttention.mockResolvedValue({ + ok: true, + protocol: 2, + revision: 1, + acks: [], + itemsTruncated: true, + }); + await first.publisher.start(); + for (let index = 0; index < 15; index += 1) { + await vi.advanceTimersByTimeAsync(200); + first.publisher.poke(); + } + await vi.advanceTimersByTimeAsync(200); + expect((await first.publisher.getMachineAttentionSnapshot()).items).toHaveLength(100); + first.publisher.dispose(); + + const restarted = makeHarness(device, undefined, { + activityProtocol: 2, + activityRosterProvider: { buildSnapshot }, + }); + expect((await restarted.publisher.getMachineAttentionSnapshot()).items).toHaveLength(300); + restarted.publisher.dispose(); + }); + + it("explicitly tombstones roster overflow", async () => { + const buildSnapshot = vi.fn().mockResolvedValue([rosterProject(301)]); + const { publisher, publishAttention } = makeHarness( + device, + undefined, + { + activityProtocol: 2, + activityRosterProvider: { buildSnapshot }, + }, + ); + publishAttention.mockResolvedValue({ ok: true, protocol: 2, revision: 1, acks: [] }); + + await publisher.start(); + await vi.advanceTimersByTimeAsync(200); + + const payloads = publishAttention.mock.calls.map(([payload]) => payload); + expect(payloads.flatMap((payload) => payload.items)).toHaveLength(300); + expect(payloads.flatMap((payload) => payload.tombstones)).toEqual([ + expect.objectContaining({ + id: `agent:${"a".repeat(40)}:disk-session-300`, + deletedAt: expect.any(String), + }), + ]); + publisher.dispose(); + }); + + it("publishes an empty presence heartbeat without rebuilding the roster", async () => { + const buildSnapshot = vi.fn().mockResolvedValue([rosterProject(1)]); + const { publisher, publishAttention, emit } = makeHarness( + device, + undefined, + { + activityProtocol: 2, + activityRosterProvider: { buildSnapshot }, + }, + ); + publishAttention.mockResolvedValue({ ok: true, protocol: 2, revision: 1, acks: [] }); + emit({ + sessionId: "s-running", + timestamp: "", + event: { type: "text", text: "working" }, + }); + await vi.advanceTimersByTimeAsync(2_500); + expect(buildSnapshot).toHaveBeenCalledTimes(1); + publishAttention.mockClear(); + + await vi.advanceTimersByTimeAsync(30_000); + + expect(publishAttention).toHaveBeenCalledTimes(1); + expect(publishAttention).toHaveBeenCalledWith({ + machineName: "MacBook", + mode: "presence", + rosterEpoch: 1, + items: [], + tombstones: [], + }); + expect(buildSnapshot).toHaveBeenCalledTimes(1); + publisher.dispose(); + }); + + it("skips roster rebuilds and durable epochs while signed out", async () => { + const buildSnapshot = vi.fn().mockResolvedValue([rosterProject(1)]); + const { + publisher, + publishAttention, + setAccountOwnerId, + store, + } = makeHarness(device, undefined, { + activityProtocol: 2, + activityRosterProvider: { buildSnapshot }, + }); + setAccountOwnerId(null); + + await publisher.start(); + await vi.advanceTimersByTimeAsync(90_000); + + expect(publishAttention).not.toHaveBeenCalled(); + expect(buildSnapshot).not.toHaveBeenCalled(); + expect(store.nextActivityRosterEpoch).not.toHaveBeenCalled(); + publisher.dispose(); + }); + + it("treats a null Activity publish response as unavailable", async () => { + const buildSnapshot = vi.fn().mockResolvedValue([rosterProject(1)]); + const { + publisher, + publishAttention, + publisherLogger, + store, + } = makeHarness(device, undefined, { + activityProtocol: 2, + activityRosterProvider: { buildSnapshot }, + }); + publishAttention.mockResolvedValue(null); + + await publisher.start(); + await vi.advanceTimersByTimeAsync(200); + + expect(publishAttention).toHaveBeenCalledTimes(1); + expect(store.nextActivityRosterEpoch).toHaveBeenCalledTimes(1); + expect(publisherLogger.warn).not.toHaveBeenCalledWith( + "attention.publish_failed", + expect.anything(), + ); + publisher.dispose(); + }); + + it("persists remote dismissal acknowledgments and downgrades signal items", async () => { + const { publisher, publishAttention, emit } = makeHarness( + device, + undefined, + { activityProtocol: 2 }, + ); + publishAttention.mockImplementation(async (payload) => ({ + ok: true, + protocol: 2, + revision: 1, + acks: payload.items.map((item: { id: string; revision: number }) => ({ + itemId: item.id, + seenAt: "2026-07-05T12:00:01.000Z", + dismissedAt: "2026-07-05T12:00:02.000Z", + sourceRevision: item.revision, + })), + })); + + emit(approval); + await vi.advanceTimersByTimeAsync(200); + const item = (await publisher.getMachineAttentionSnapshot()).items[0]!; + + expect(item).toMatchObject({ + phase: "needs_you", + activityTier: "ambient", + seenAt: "2026-07-05T12:00:01.000Z", + dismissedAt: "2026-07-05T12:00:02.000Z", + }); + publishAttention.mockClear(); + publisher.poke(); + await vi.advanceTimersByTimeAsync(200); + expect(publishAttention.mock.calls[0][0]).toMatchObject({ + mode: "delta", + items: [expect.objectContaining({ + id: item.id, + activityTier: "ambient", + dismissedAt: "2026-07-05T12:00:02.000Z", + })], + }); + publisher.dispose(); + }); + + it("falls back to a live-only full snapshot when protocol is absent", async () => { + const buildSnapshot = vi.fn().mockResolvedValue([rosterProject(3)]); + const { publisher, publishAttention, emit } = makeHarness( + device, + undefined, + { activityRosterProvider: { buildSnapshot } }, + ); + publishAttention.mockResolvedValue({ ok: true, revision: 1 }); + emit(approval); + await vi.advanceTimersByTimeAsync(200); + + expect(publishAttention).toHaveBeenCalledTimes(1); + expect(publishAttention.mock.calls[0][0]).toMatchObject({ + machineName: "MacBook", + fullSnapshot: true, + items: [expect.objectContaining({ id: `agent:${"a".repeat(40)}:s-1` })], + }); + expect(publishAttention.mock.calls[0][0].mode).toBeUndefined(); + expect(buildSnapshot).not.toHaveBeenCalled(); + publisher.dispose(); + }); + + it("keeps idle roster revisions stable across uncached rebuilds", async () => { + let clock = Date.parse("2026-08-01T12:00:00.000Z"); + const buildSnapshot = vi.fn().mockResolvedValue([ + rosterProject(1, "2026-07-01T09:30:00.000Z"), + ]); + const { publisher } = makeHarness( + device, + () => clock, + { + activityProtocol: 2, + activityRosterProvider: { buildSnapshot }, + }, + ); + + const first = (await publisher.getMachineAttentionSnapshot()).items[0]!; + clock += 11_000; + const second = (await publisher.getMachineAttentionSnapshot()).items[0]!; + + expect(buildSnapshot).toHaveBeenCalledTimes(2); + expect(first).toMatchObject({ + activityTier: "idle", + phase: "stale", + expiresAt: null, + statusSince: "2026-07-01T09:30:00.000Z", + }); + expect(second.revision).toBe(first.revision); + expect(second.revision).toBe(Date.parse("2026-07-01T09:30:00.000Z")); + publisher.dispose(); + }); + + it("keeps roster alert identity stable when activity advances within one status", async () => { + let clock = Date.parse("2026-08-01T12:00:00.000Z"); + const roster = rosterProject(1, "2026-08-01T11:59:00.000Z"); + roster.chats[0]!.status = "awaiting"; + const buildSnapshot = vi.fn(async () => [roster]); + const { publisher } = makeHarness(device, () => clock, { + activityProtocol: 2, + activityRosterProvider: { buildSnapshot }, + }); + + const first = (await publisher.getMachineAttentionSnapshot()).items[0]!; + roster.chats[0]!.lastActivityAt = "2026-08-01T12:00:05.000Z"; + clock += 11_000; + const second = (await publisher.getMachineAttentionSnapshot()).items[0]!; + + expect(buildSnapshot).toHaveBeenCalledTimes(2); + expect(first).toMatchObject({ phase: "needs_you", activityTier: "signal" }); + expect(second.revision).toBeGreaterThan(first.revision); + expect(second.updatedAt).not.toBe(first.updatedAt); + expect(second.statusSince).toBe(first.statusSince); + expect(second.alertFingerprint).toBe(first.alertFingerprint); + publisher.dispose(); + }); + + it("changes roster statusSince whenever a chat re-enters a status", async () => { + let clock = Date.parse("2026-08-01T12:00:00.000Z"); + const roster = rosterProject(1, "2026-08-01T11:59:00.000Z"); + roster.chats[0]!.status = "awaiting"; + const buildSnapshot = vi.fn(async () => [roster]); + const { publisher } = makeHarness(device, () => clock, { + activityProtocol: 2, + activityRosterProvider: { buildSnapshot }, + }); + + const awaiting = (await publisher.getMachineAttentionSnapshot()).items[0]!; + roster.chats[0]!.status = "running"; + clock += 11_000; + const running = (await publisher.getMachineAttentionSnapshot()).items[0]!; + roster.chats[0]!.status = "awaiting"; + clock += 11_000; + const awaitingAgain = (await publisher.getMachineAttentionSnapshot()).items[0]!; + + expect([awaiting.phase, running.phase, awaitingAgain.phase]) + .toEqual(["needs_you", "running", "needs_you"]); + expect(Date.parse(running.statusSince!)).toBeGreaterThan(Date.parse(awaiting.statusSince!)); + expect(Date.parse(awaitingAgain.statusSince!)).toBeGreaterThan(Date.parse(running.statusSince!)); + expect(awaitingAgain.alertFingerprint).not.toBe(awaiting.alertFingerprint); + publisher.dispose(); + }); + + it("anchors invalid roster activity dates once across uncached rebuilds", async () => { + let rebuildAt = Date.parse("2026-08-01T12:00:00.000Z"); + const buildSnapshot = vi.fn().mockResolvedValue([ + rosterProject(2, "not-an-iso-date"), + ]); + const { publisher } = makeHarness( + device, + () => rebuildAt, + { + activityProtocol: 2, + activityRosterProvider: { buildSnapshot }, + }, + ); + + const first = (await publisher.getMachineAttentionSnapshot()).items; + rebuildAt += 11_000; + const second = (await publisher.getMachineAttentionSnapshot()).items; + + expect(first).toHaveLength(2); + expect(first.map((entry) => entry.revision)) + .toEqual([rebuildAt - 11_000, rebuildAt - 11_000]); + expect(first.map((entry) => entry.updatedAt)) + .toEqual(["2026-08-01T12:00:00.000Z", "2026-08-01T12:00:00.000Z"]); + expect(second.map((entry) => entry.revision)).toEqual([rebuildAt, rebuildAt]); + expect(second.map((entry) => entry.statusSince)) + .toEqual(first.map((entry) => entry.statusSince)); + expect(second.map((entry) => entry.alertFingerprint)) + .toEqual(first.map((entry) => entry.alertFingerprint)); + publisher.dispose(); + }); + + it("keeps live-to-roster source revisions monotonic", async () => { + const roster = rosterProject(1, "2026-07-01T09:30:00.000Z"); + roster.chats[0]!.id = "live-to-roster"; + const { publisher, publishAttention, cliSessions } = makeHarness( + device, + undefined, + { + activityProtocol: 2, + activityRosterProvider: { buildSnapshot: async () => [roster] }, + }, + ); + publishAttention.mockResolvedValue({ + ok: true, + protocol: 2, + revision: 1, + acks: [], + }); + cliSessions.set("live-to-roster", { + title: "Live session", + toolType: "codex", + chatSessionId: null, + }); + publisher.handleCliRuntimeSignal("scope-1", { + laneId: "lane-roster", + sessionId: "live-to-roster", + runtimeState: "running", + }); + await vi.advanceTimersByTimeAsync(2_500); + const live = publishAttention.mock.calls[0][0].items[0]; + publishAttention.mockClear(); + + publisher._debug.onPtyExit("scope-1", { + ptyId: "pty-live-to-roster", + sessionId: "live-to-roster", + laneId: "lane-roster", + exitCode: 130, + }); + await vi.advanceTimersByTimeAsync(2_500); + + const rosterPayload = publishAttention.mock.calls[0][0]; + expect(rosterPayload.tombstones).toEqual([]); + expect(rosterPayload.items[0]).toMatchObject({ + id: live.id, + phase: "stale", + revision: live.revision, + }); + expect(rosterPayload.items[0].revision) + .toBeGreaterThan(Date.parse("2026-07-01T09:30:00.000Z")); + publisher.dispose(); + }); + + it("clamps rebuilt roster rows to persisted and remote source revisions", async () => { + const itemId = `agent:${"a".repeat(40)}:disk-session-000`; + const persistedFloor = Date.parse("2026-07-06T00:00:00.000Z"); + const remoteFloor = persistedFloor + 1; + const { publisher } = makeHarness(device, undefined, { + activityProtocol: 2, + activityRosterProvider: { + buildSnapshot: async () => [rosterProject(1, "2026-07-01T00:00:00.000Z")], + }, + lastPublishedRevisionById: { [itemId]: persistedFloor }, + remoteAttentionAcknowledgments: [{ + itemId, + accountOwnerId: "owner-a", + sourceRevision: remoteFloor, + seenAt: null, + dismissedAt: null, + updatedAt: "2026-07-06T00:00:01.000Z", + }], + }); + + const item = (await publisher.getMachineAttentionSnapshot()).items[0]!; + + expect(item.id).toBe(itemId); + expect(item.revision).toBe(remoteFloor); + expect(item.updatedAt).toBe("2026-07-01T00:00:00.000Z"); + publisher.dispose(); + }); + + it("keeps live statusSince immutable while the phase is unchanged", async () => { + const { publisher, emit } = makeHarness( + device, + undefined, + { activityProtocol: 2 }, + ); + emit({ + sessionId: "s-running", + timestamp: "", + event: { type: "text", text: "first" }, + }); + const first = (await publisher.getMachineAttentionSnapshot()).items[0]!; + vi.setSystemTime(new Date("2026-07-05T12:00:05.000Z")); + emit({ + sessionId: "s-running", + timestamp: "", + event: { type: "text", text: "second" }, + }); + const second = (await publisher.getMachineAttentionSnapshot()).items[0]!; + + expect(second.revision).toBeGreaterThan(first.revision); + expect(second.statusSince).toBe(first.statusSince); + publisher.dispose(); + }); + + it("publishes a second item-less question after needs-you phase re-entry", async () => { + const { publisher, publishAttention } = makeHarness( + device, + undefined, + { activityProtocol: 2 }, + ); + publishAttention.mockResolvedValue({ + ok: true, + protocol: 2, + revision: 1, + acks: [], + }); + + publisher.handleSessionAttentionRequested("scope-1", { + sessionId: "question-reentry", + kind: "chat", + title: "Choose rollout", + message: "Which rollout should I use?", + laneId: "auth-lane", + }); + await vi.advanceTimersByTimeAsync(200); + publisher.handleSessionAttentionResolved("scope-1", "question-reentry"); + await vi.advanceTimersByTimeAsync(200); + publisher.handleSessionAttentionRequested("scope-1", { + sessionId: "question-reentry", + kind: "chat", + title: "Choose rollout", + message: "Which rollout should I use?", + laneId: "auth-lane", + }); + await vi.advanceTimersByTimeAsync(200); + + const needsYouItems = publishAttention.mock.calls + .flatMap(([payload]) => payload.items) + .filter((entry: { phase: string }) => entry.phase === "needs_you"); + expect(needsYouItems).toHaveLength(2); + expect(needsYouItems.map((entry) => entry.destination.itemId)).toEqual([null, null]); + expect(needsYouItems[1].statusSince).not.toBe(needsYouItems[0].statusSince); + expect(needsYouItems[1].alertFingerprint).not.toBe(needsYouItems[0].alertFingerprint); + publisher.dispose(); + }); + it("alerts native structured questions with the unified needs-you copy immediately", async () => { const { publisher, publish, emit } = makeHarness(); await publisher.start(); @@ -2120,7 +2853,7 @@ describe("createPushPublisherService flush", () => { publishAttention.mockClear(); detach(); await vi.runAllTicks(); - await Promise.resolve(); + await settleMicrotasks(); expect(publishAttention).toHaveBeenCalledTimes(1); expect(publishAttention).toHaveBeenCalledWith({ @@ -2151,7 +2884,7 @@ describe("createPushPublisherService flush", () => { publishAttention.mockClear(); detach(); await vi.runAllTicks(); - await Promise.resolve(); + await settleMicrotasks(); expect(publishAttention).toHaveBeenCalledTimes(1); expect(publishAttention.mock.calls[0][0].items).toEqual([]); @@ -2357,6 +3090,45 @@ describe("createPushRegistrationStore", () => { }); expect(reopened.listPendingAttentionAcknowledgments()).toHaveLength(2); }); + + it("persists protocol, roster epochs, remote acknowledgments, and revision floors", () => { + const store = createPushRegistrationStore({ filePath }); + store.getOrCreateIdentity(); + store.setActivityProtocol(2); + expect(store.nextActivityRosterEpoch()).toBe(1); + expect(store.nextActivityRosterEpoch()).toBe(2); + store.recordRemoteAttentionAcknowledgments({ + accountOwnerId: "owner-a", + acknowledgments: [{ + itemId: "agent:machine:session-1", + sourceRevision: 9, + seenAt: "2026-07-05T01:00:00.000Z", + dismissedAt: "2026-07-05T01:01:00.000Z", + }], + updatedAt: "2026-07-05T01:01:00.000Z", + }); + store.setLastPublishedActivityRevisions({ + accountOwnerId: "owner-a", + revisions: { "agent:machine:session-1": 11 }, + }); + + const reopened = createPushRegistrationStore({ filePath }); + expect(reopened.getActivityProtocol()).toBe(2); + expect(reopened.nextActivityRosterEpoch()).toBe(3); + expect(reopened.listRemoteAttentionAcknowledgments("owner-a")).toEqual([ + expect.objectContaining({ + itemId: "agent:machine:session-1", + accountOwnerId: "owner-a", + sourceRevision: 9, + dismissedAt: "2026-07-05T01:01:00.000Z", + }), + ]); + expect(reopened.listRemoteAttentionAcknowledgments("owner-b")).toEqual([]); + expect(reopened.getLastPublishedActivityRevisions()).toEqual({ + accountOwnerId: "owner-a", + revisions: { "agent:machine:session-1": 11 }, + }); + }); }); const MACHINE_KEY = "0123456789abcdef0123456789abcdef"; // gitleaks:allow — test fixture @@ -2539,6 +3311,78 @@ describe("createPushRelayClient", () => { ); }); + it("decodes the typed Activity publish result at the relay boundary", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ok: true, + protocol: 2, + revision: 19, + acks: [{ + itemId: "agent:machine:session-1", + sourceRevision: 7, + seenAt: "2026-07-05T00:01:00.000Z", + dismissedAt: null, + }], + upserted: 1, + removed: 0, + itemsTruncated: true, + }), + }); + const client = createPushRelayClient({ + store: makeStore({ isClaimed: () => true }), + logger, + baseUrl: "https://relay.test", + getAccountAccessToken: async () => "account-access-token", + getAccountUserId: () => "account-a", + }); + + const result = await client.publishAttention({ + machineName: "MacBook", + mode: "delta", + rosterEpoch: 1, + items: [], + tombstones: [], + }); + + expect(result?.protocol).toBe(2); + expect(result?.acks).toEqual([expect.objectContaining({ + itemId: "agent:machine:session-1", + sourceRevision: 7, + })]); + expect(result?.itemsTruncated).toBe(true); + }); + + it("rejects malformed Activity publish results", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ok: true, + protocol: "2", + acks: [{ itemId: "session-1", sourceRevision: "stale" }], + }), + }); + const client = createPushRelayClient({ + store: makeStore({ isClaimed: () => true }), + logger, + baseUrl: "https://relay.test", + getAccountAccessToken: async () => "account-access-token", + getAccountUserId: () => "account-a", + }); + + await expect(client.publishAttention({ + machineName: "MacBook", + mode: "delta", + rosterEpoch: 1, + items: [], + tombstones: [], + })).rejects.toThrow(/invalid Activity publish result/i); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toContain("/attention"); + }); + it("binds incremental snapshot cursors to the authenticated stream", async () => { fetchMock.mockResolvedValueOnce({ ok: true, @@ -2569,6 +3413,128 @@ describe("createPushRelayClient", () => { expect(init.headers.authorization).toBe("Bearer account-access-token"); }); + it("passes through the additive Activity snapshot fields", async () => { + const activityItem = { + id: "agent:machine-a:session-1", + revision: 17, + activityTier: "idle", + contentFingerprint: "content-17", + alertFingerprint: "alert-17", + statusSince: "2026-07-05T00:00:00.000Z", + }; + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + contractVersion: 1, + streamId: "account-a", + revision: 17, + generatedAt: "2026-07-05T00:00:00.000Z", + items: [activityItem], + itemsTruncated: true, + tombstones: [], + machines: [], + }), + }); + const client = createPushRelayClient({ + store: makeStore(), + logger, + baseUrl: "https://relay.test", + getAccountAccessToken: async () => "account-access-token", + getAccountUserId: () => "account-a", + }); + + const result = await client.getAttentionSnapshot(); + + expect(result?.itemsTruncated).toBe(true); + expect(result?.items[0]).toMatchObject(activityItem); + expect(result?.streamId).toBe("account-a"); + }); + + it("sends revision and owner fences and parses stale acknowledgments", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ok: true, + revision: 19, + applied: ["item-applied"], + stale: ["item-stale"], + }), + }); + const client = createPushRelayClient({ + store: makeStore(), + logger, + baseUrl: "https://relay.test", + getAccountAccessToken: async () => "account-access-token", + getAccountUserId: () => "account-a", + }); + + await expect(client.acknowledgeAttention({ + itemIds: ["item-applied", "item-stale"], + sourceRevisions: { "item-applied": 4, "item-stale": 7 }, + expectedAccountOwnerId: "account-a", + seenAt: "2026-07-05T00:01:00.000Z", + })).resolves.toEqual({ + applied: ["item-applied"], + stale: ["item-stale"], + }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://relay.test/attention/account/ack"); + expect(JSON.parse(init.body)).toEqual({ + itemIds: ["item-applied", "item-stale"], + sourceRevisions: { "item-applied": 4, "item-stale": 7 }, + expectedAccountOwnerId: "account-a", + seenAt: "2026-07-05T00:01:00.000Z", + }); + }); + + it("omits machine and device overrides from full preference writes", async () => { + const client = createPushRelayClient({ + store: makeStore(), + logger, + baseUrl: "https://relay.test", + getAccountAccessToken: async () => "account-access-token", + getAccountUserId: () => "account-a", + }); + + await client.putAttentionPreferences("account-a", { + ...DEFAULT_ATTENTION_PREFERENCES, + devices: { "phone-1": { hideDetails: true } }, + machines: { "machine-a": { notificationsEnabled: false } }, + }); + + const [url, init] = fetchMock.mock.calls[0]; + const body = JSON.parse(init.body) as Record; + expect(url).toBe("https://relay.test/attention/account/preferences"); + expect(body.devices).toBeUndefined(); + expect(body.machines).toBeUndefined(); + expect(body.account).toEqual(DEFAULT_ATTENTION_PREFERENCES.account); + }); + + it("patches one encoded Activity machine preference scope", async () => { + const client = createPushRelayClient({ + store: makeStore(), + logger, + baseUrl: "https://relay.test", + getAccountAccessToken: async () => "account-access-token", + getAccountUserId: () => "account-a", + }); + + await client.putActivityMachinePreferences( + "account-a", + "machine/a", + { notificationsEnabled: false }, + ); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe( + "https://relay.test/attention/account/preferences/machines/machine%2Fa", + ); + expect(init.method).toBe("PATCH"); + expect(JSON.parse(init.body)).toEqual({ notificationsEnabled: false }); + }); + it("retries one unauthorized account read with a forced fresh token", async () => { fetchMock .mockResolvedValueOnce({ @@ -2659,7 +3625,7 @@ describe("createPushRelayClient", () => { }); await expect(client.getAttentionSnapshot()).rejects.toThrow( - /invalid Attention snapshot/i, + /invalid Activity snapshot/i, ); }); diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index 63719b6c7..79f3d2d5f 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -1,5 +1,4 @@ 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 { @@ -10,10 +9,16 @@ import { type AttentionEventKind, type AttentionItem, type AttentionPhase, + type AttentionPreferenceScope, type AttentionPreferences, type AttentionPresence, type AttentionSnapshot, + type AttentionTombstone, } from "../../../../desktop/src/shared/types/attention"; +import type { + SyncRosterChatStatus, + SyncRosterProject, +} from "../../../../desktop/src/shared/types/sync"; import type { PtyExitEvent, TerminalSessionStatus } from "../../../../desktop/src/shared/types/sessions"; import { canonicalSessionState } from "../../../../desktop/src/shared/sessionCanonicalState"; import type { PrNotificationKind } from "../../../../desktop/src/shared/types/prs"; @@ -26,10 +31,16 @@ import type { } from "../../../../desktop/src/shared/types/push"; import type { PushRegistrationStore } from "./pushRegistrationStore"; import type { + ActivityPublishResult, PushRelayAlertItem, PushRelayClient, PushRelayLiveActivityItem, } from "./pushRelayClient"; +import { PushRelayRequestError } from "./pushRelayClient"; +import { + activityPublishFingerprint, + withActivityFingerprints, +} from "./activityFingerprint"; export const AGENT_RUNS_ACTIVITY_ID = "agent-runs"; export const AGENT_RUNS_ATTRIBUTES_TYPE = "ADEAgentRunsAttributes"; @@ -79,8 +90,12 @@ 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; +export const ACTIVITY_ROSTER_MAX_ITEMS_PER_MACHINE = 300; +export const ACTIVITY_ROSTER_MIN_ITEMS_PER_MACHINE = 100; +export const ACTIVITY_PUBLISH_PAGE_ITEMS = 48; +export const ACTIVITY_RECONCILE_INTERVAL_MS = 30 * 60_000; +const ACTIVITY_ROSTER_CACHE_MS = 10_000; +const LEGACY_ATTENTION_PUBLISH_MAX_ITEMS = 64; const DEFAULT_FLUSH_DEBOUNCE_MS = 2_000; const DEFAULT_PROMPT_FLUSH_MS = 150; const PUBLISH_RETRY_MS = 30_000; @@ -112,6 +127,8 @@ export type AgentRunState = { itemId: string | null; startedAt: number; lastActiveAt: number; + /** Immutable while `phase` is unchanged. */ + statusSinceAt: number; metaResolved: boolean; }; @@ -151,6 +168,7 @@ export type PrLiveActivityState = { repoOwner: string | null; repoName: string | null; updatedAt: number; + statusSinceAt: number; }; type PendingAlert = { @@ -191,6 +209,9 @@ export type PushPublisherDeps = { deviceId?: string | null; } | null; getAccountOwnerId?: () => string | null; + activityRosterProvider?: { + buildSnapshot(): Promise; + } | null; /** Test seams. */ now?: () => number; flushDebounceMs?: number; @@ -472,15 +493,56 @@ 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 rosterAttentionPhase(status: SyncRosterChatStatus): AttentionPhase { + switch (status) { + case "awaiting": + return "needs_you"; + case "failed": + return "failed"; + case "running": + return "running"; + case "idle": + return "stale"; + case "ended": + return "completed"; + } +} + +function rosterActivityTier(status: SyncRosterChatStatus): "signal" | "ambient" | "idle" { + switch (status) { + case "awaiting": + case "failed": + return "signal"; + case "running": + return "ambient"; + case "idle": + case "ended": + return "idle"; + } +} + +function prActivityTier(phase: AttentionPhase): "signal" | "ambient" { + return phase === "checks_failing" + || phase === "changes_requested" + || phase === "review_requested" + || phase === "merge_ready" + ? "signal" + : "ambient"; +} + +function validTimestampMs( + value: string | null | undefined, + fallback: number, +): number { + const parsed = typeof value === "string" ? Date.parse(value) : Number.NaN; + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + function agentAttentionEventKind(phase: AgentRunPhase): AttentionEventKind { if (phase === "waiting_for_approval" || phase === "waiting_for_input") return "agent_needs_you"; if (phase === "failed") return "agent_failed"; @@ -520,11 +582,32 @@ export function createPushPublisherService(deps: PushPublisherDeps) { const runs = new Map(); const recentRuns = new Map(); const prActivities = new Map(); + const rosterPhaseAnchors = new Map(); const lastMachineSnapshotItems = new Map(); let lastMachineSnapshotAccountOwnerId: string | null | undefined; let pendingAlerts: PendingAlert[] = []; const lastAlertFingerprintByKey = new Map>(); - let lastAttentionFingerprint: string | null = null; + const lastPublishedFingerprintById = new Map(); + const initialAccountOwnerId = deps.getAccountOwnerId?.()?.trim() || null; + const persistedPublishedRevisions = + deps.store.getLastPublishedActivityRevisions?.() ?? null; + const lastPublishedRevisionById = new Map( + Object.entries(persistedPublishedRevisions?.revisions ?? {}), + ); + const lastOverflowRevisionById = new Map(); + let activityProtocol = deps.store.getActivityProtocol?.() ?? null; + let activityRosterProvider = deps.activityRosterProvider ?? null; + let rosterCache: { at: number; projects: SyncRosterProject[] } | null = null; + let rosterEpoch = 0; + let reconcilePending = true; + let lastReconcileAt = 0; + let lastActivityAccountOwnerId: string | null | undefined = + persistedPublishedRevisions?.accountOwnerId ?? initialAccountOwnerId; + let activityRosterCap = ACTIVITY_ROSTER_MAX_ITEMS_PER_MACHINE; + let lastLegacyAttentionFingerprint: string | null = null; let lastAttentionPublishedAt = 0; /** Last Live Activity content confirmed per phone. Absence means start. */ const liveActivityFingerprintByDevice = new Map(); @@ -547,6 +630,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { let attentionHeartbeatTimer: NodeJS.Timeout | null = null; let flushFireAt = 0; let flushing = false; + let scheduledFlushIncludesActivity = false; let finalAttentionSnapshotPending = false; let finalAttentionSnapshotQueued = false; let disposed = false; @@ -568,6 +652,13 @@ export function createPushPublisherService(deps: PushPublisherDeps) { deps.logger.warn(message, { error: error instanceof Error ? error.message : String(error) }); }; + const persistLastPublishedRevisions = (): void => { + deps.store.setLastPublishedActivityRevisions?.({ + accountOwnerId: lastActivityAccountOwnerId ?? null, + revisions: Object.fromEntries(lastPublishedRevisionById), + }); + }; + const isGated = (): boolean => { if (!deps.store.hasRegisteredDevices()) return true; if (!deps.store.getStatusSnapshot().enabled) return true; @@ -591,6 +682,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { itemId: null, startedAt: ts, lastActiveAt: ts, + statusSinceAt: ts, metaResolved: false, }; runs.set(sessionId, run); @@ -603,6 +695,12 @@ export function createPushPublisherService(deps: PushPublisherDeps) { run.lastActiveAt = Math.max(now(), run.lastActiveAt + 1); }; + const setRunPhase = (run: AgentRunState, phase: AgentRunPhase): void => { + if (run.phase === phase) return; + run.phase = phase; + run.statusSinceAt = run.lastActiveAt; + }; + const runSubject = (run: AgentRunState): string => run.agent?.trim() || run.title?.trim() || "Agent"; const laneTitleLine = (run: AgentRunState): string => { @@ -610,16 +708,37 @@ export function createPushPublisherService(deps: PushPublisherDeps) { return parts.length > 0 ? parts.join(" · ") : run.title?.trim() || "Agent run"; }; - const buildAttentionItems = (nowMs: number): AttentionItem[] => { + const loadActivityRoster = async (nowMs: number): Promise => { + if (!activityRosterProvider) return []; + if (rosterCache && nowMs - rosterCache.at < ACTIVITY_ROSTER_CACHE_MS) { + return rosterCache.projects; + } + try { + const projects = await activityRosterProvider.buildSnapshot(); + rosterCache = { at: nowMs, projects }; + return projects; + } catch (error) { + logWarn("attention.activity_roster_build_failed", error); + const projects = rosterCache?.projects ?? []; + rosterCache = { at: nowMs, projects }; + return projects; + } + }; + + const buildAttentionItems = async ( + nowMs: number, + includeRoster: boolean, + ): Promise => { const { machineKey } = deps.store.getOrCreateIdentity(); const accountMachineIdentity = deps.getAccountMachineIdentity?.() ?? null; + const nowIso = new Date(nowMs).toISOString(); const machine = { machineKey, accountMachineKey: accountMachineIdentity?.machineKey ?? null, deviceId: accountMachineIdentity?.deviceId ?? null, name: deps.machineName, online: true, - lastSeenAt: null, + lastSeenAt: nowIso, }; const attentionRuns = new Map([ ...recentRuns, @@ -668,10 +787,12 @@ export function createPushPublisherService(deps: PushPublisherDeps) { payload: { sessionId: run.sessionId }, }); } - const withoutFingerprint: Omit = { + const item: AttentionItem = { contractVersion: ATTENTION_CONTRACT_VERSION, id: `agent:${machineKey}:${run.sessionId}`, revision: run.lastActiveAt, + fingerprint: "", + activityTier: phase === "needs_you" || phase === "failed" ? "signal" : "ambient", kind: "agent", eventKind, phase, @@ -715,16 +836,121 @@ export function createPushPublisherService(deps: PushPublisherDeps) { actions, occurredAt: new Date(run.startedAt).toISOString(), updatedAt: new Date(run.lastActiveAt).toISOString(), + statusSince: new Date(run.statusSinceAt).toISOString(), seenAt: null, dismissedAt: null, expiresAt, }; - return { - ...withoutFingerprint, - fingerprint: fingerprintAttentionItem(withoutFingerprint), - }; + return withActivityFingerprints(item); }); + const rosterItems = includeRoster + ? (await loadActivityRoster(nowMs)).flatMap((project): AttentionItem[] => { + const laneNames = new Map(project.lanes.map((lane) => [lane.id, lane.name])); + return project.chats.map((chat): AttentionItem => { + const phase = rosterAttentionPhase(chat.status); + const activityTier = rosterActivityTier(chat.status); + const revision = validTimestampMs(chat.lastActivityAt, nowMs); + const activityAt = new Date(revision).toISOString(); + const id = `agent:${machineKey}:${chat.id}`; + const existingAnchor = rosterPhaseAnchors.get(id); + const statusSinceAt = existingAnchor?.status === chat.status + ? existingAnchor.statusSinceAt + : Math.max(revision, (existingAnchor?.statusSinceAt ?? -1) + 1); + rosterPhaseAnchors.set(id, { status: chat.status, statusSinceAt }); + const provider = providerDisplayName(chat.provider ?? chat.toolType); + const subject = provider ?? chat.title?.trim() ?? "Agent"; + const preview = sanitizeAttentionPreview( + chat.attentionMessage?.trim() + || chat.statusNote?.trim() + || chat.preview?.trim() + || chat.title?.trim() + || laneNames.get(chat.laneId) + || "ADE session", + ); + const actions: AttentionItem["actions"] = [ + { id: "open", kind: "open", label: "Open" }, + ]; + if (phase === "needs_you") { + actions.unshift({ + id: "answer", + kind: "answer", + label: "Answer", + payload: { sessionId: chat.id }, + }); + } + return withActivityFingerprints({ + contractVersion: ATTENTION_CONTRACT_VERSION, + id, + revision, + fingerprint: "", + activityTier, + kind: "agent", + eventKind: phase === "needs_you" + ? "agent_needs_you" + : phase === "failed" + ? "agent_failed" + : phase === "completed" + ? "agent_completed" + : "agent_running", + phase, + machine, + project: { + projectId: project.projectId, + name: project.displayName, + rootPath: project.rootPath ?? null, + }, + laneId: chat.laneId, + laneName: laneNames.get(chat.laneId) ?? null, + provider, + model: chat.model ?? null, + title: phase === "needs_you" + ? `${subject} needs you` + : phase === "failed" + ? `${subject} failed` + : phase === "completed" + ? `${subject} is done` + : phase === "stale" + ? `${subject} is idle` + : `${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 is done." + : phase === "stale" + ? "An ADE agent session is idle." + : "An ADE agent is working.", + detail: chat.statusNote ? sanitizeAttentionPreview(chat.statusNote, 1_000) : null, + recentActivity: [], + planProgress: null, + destination: { + kind: "session", + sessionId: chat.id, + itemId: null, + }, + actions, + occurredAt: activityAt, + updatedAt: activityAt, + statusSince: new Date(statusSinceAt).toISOString(), + seenAt: null, + dismissedAt: null, + expiresAt: activityTier === "idle" + ? null + : new Date(revision + (phase === "running" ? RUNNING_TTL_MS : ATTENTION_RECENT_TTL_MS)).toISOString(), + }); + }); + }) + : []; + if (includeRoster) { + const rosterItemIds = new Set(rosterItems.map((item) => item.id)); + for (const id of rosterPhaseAnchors.keys()) { + if (!rosterItemIds.has(id)) rosterPhaseAnchors.delete(id); + } + } + const prItems = [...prActivities.values()].map((pr): AttentionItem => { const scopeKey = pr.scopeKey; const scope = scopes.get(scopeKey); @@ -746,10 +972,12 @@ export function createPushPublisherService(deps: PushPublisherDeps) { payload: { prId: pr.prId, prNumber: pr.prNumber }, }); } - const withoutFingerprint: Omit = { + const item: AttentionItem = { contractVersion: ATTENTION_CONTRACT_VERSION, id: `pull-request:${machineKey}:${pr.id}`, revision: pr.updatedAt, + fingerprint: "", + activityTier: prActivityTier(mapped.phase), kind: "pull_request", eventKind: mapped.eventKind, phase: mapped.phase, @@ -780,36 +1008,71 @@ export function createPushPublisherService(deps: PushPublisherDeps) { actions, occurredAt: new Date(pr.updatedAt).toISOString(), updatedAt: new Date(pr.updatedAt).toISOString(), + statusSince: new Date(pr.statusSinceAt).toISOString(), seenAt: null, dismissedAt: null, expiresAt: new Date(pr.updatedAt + ATTENTION_RECENT_TTL_MS).toISOString(), }; - return { - ...withoutFingerprint, - fingerprint: fingerprintAttentionItem(withoutFingerprint), - }; + return withActivityFingerprints(item); }); - return [...runItems, ...prItems]; - }; - const mergeMachineAcknowledgments = (items: AttentionItem[]): AttentionItem[] => - items.map((item) => { - const accountOwnerId = deps.getAccountOwnerId?.()?.trim() || null; - const acknowledgment = deps.store.getAttentionAcknowledgment?.( - item.id, - accountOwnerId, + const agentItems = new Map(); + for (const item of rosterItems) agentItems.set(item.id, item); + // Live state is authoritative on the shared terminal_sessions.id/sessionId + // namespace and therefore wins every collision with a roster row. + for (const item of runItems) agentItems.set(item.id, item); + const accountOwnerId = deps.getAccountOwnerId?.()?.trim() || null; + const remoteRevisionById = new Map( + (deps.store.listRemoteAttentionAcknowledgments?.(accountOwnerId) ?? []) + .map((acknowledgment) => [acknowledgment.itemId, acknowledgment.sourceRevision]), + ); + return [...agentItems.values(), ...prItems].map((item) => { + const revision = Math.max( + item.revision, + lastPublishedRevisionById.get(item.id) ?? 0, + remoteRevisionById.get(item.id) ?? 0, ); + return revision === item.revision ? item : { ...item, revision }; + }); + }; + + const mergeMachineAcknowledgments = (items: AttentionItem[]): AttentionItem[] => { + const accountOwnerId = deps.getAccountOwnerId?.()?.trim() || null; + const remoteById = new Map( + (deps.store.listRemoteAttentionAcknowledgments?.(accountOwnerId) ?? []) + .map((acknowledgment) => [acknowledgment.itemId, acknowledgment]), + ); + return items.map((item) => { + const local = deps.store.getAttentionAcknowledgment?.(item.id, accountOwnerId); + const remote = remoteById.get(item.id); + let seenAt = item.seenAt; + let dismissedAt = item.dismissedAt; if ( - !acknowledgment - || acknowledgment.accountOwnerId !== accountOwnerId - || acknowledgment.sourceRevision < item.revision - ) return item; - return { + local + && local.accountOwnerId === accountOwnerId + && local.sourceRevision >= item.revision + ) { + seenAt = local.seenAt; + dismissedAt = local.dismissedAt; + } + // The relay is canonical for account acknowledgments. A revision-current + // remote value replaces the local projection, including explicit nulls. + if ( + remote + && remote.accountOwnerId === accountOwnerId + && remote.sourceRevision >= item.revision + ) { + seenAt = remote.seenAt; + dismissedAt = remote.dismissedAt; + } + return withActivityFingerprints({ ...item, - seenAt: acknowledgment.seenAt, - dismissedAt: acknowledgment.dismissedAt, - }; + seenAt, + dismissedAt, + ...(dismissedAt ? { activityTier: "ambient" as const } : {}), + }); }); + }; const reconcileMachineAcknowledgments = async ( currentItems: readonly AttentionItem[], @@ -822,8 +1085,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { const current = currentById.get(acknowledgment.itemId); return Boolean( current - && acknowledgment.accountOwnerId === accountOwnerId - && acknowledgment.sourceRevision >= current.revision, + && acknowledgment.accountOwnerId === accountOwnerId, ); }); for (let offset = 0; offset < pending.length; offset += 64) { @@ -845,6 +1107,12 @@ export function createPushPublisherService(deps: PushPublisherDeps) { try { const result = await deps.relayClient.acknowledgeAttention({ itemIds: group.map((acknowledgment) => acknowledgment.itemId), + sourceRevisions: Object.fromEntries( + group.map((acknowledgment) => [ + acknowledgment.itemId, + currentById.get(acknowledgment.itemId)!.revision, + ]), + ), seenAt: group[0]!.seenAt, ...(group[0]!.dismissedAt ? { dismissedAt: group[0]!.dismissedAt } @@ -852,12 +1120,15 @@ export function createPushPublisherService(deps: PushPublisherDeps) { expectedAccountOwnerId: accountOwnerId, }); if (result) { + const applied = new Set(result.applied); deps.store.markAttentionAcknowledgmentsSynced?.( - group.map((acknowledgment) => ({ - itemId: acknowledgment.itemId, - accountOwnerId: acknowledgment.accountOwnerId, - updatedAt: acknowledgment.updatedAt, - })), + group + .filter((acknowledgment) => applied.has(acknowledgment.itemId)) + .map((acknowledgment) => ({ + itemId: acknowledgment.itemId, + accountOwnerId: acknowledgment.accountOwnerId, + updatedAt: acknowledgment.updatedAt, + })), ); } } catch (error) { @@ -876,8 +1147,9 @@ export function createPushPublisherService(deps: PushPublisherDeps) { lastAlertFingerprintByKey.delete(dedupeKey); }; - const scheduleFlush = (immediate: boolean): void => { + const scheduleFlush = (immediate: boolean, activityChanged = true): void => { if (disposed) return; + if (activityChanged) scheduledFlushIncludesActivity = true; const delay = immediate ? promptFlushMs : flushDebounceMs; const fireAt = now() + delay; // Keep the earliest scheduled flush — a prompt (immediate) is never pushed @@ -888,7 +1160,9 @@ export function createPushPublisherService(deps: PushPublisherDeps) { flushTimer = setTimeout(() => { flushTimer = null; flushFireAt = 0; - void runFlush(); + const presenceOnly = !scheduledFlushIncludesActivity; + scheduledFlushIncludesActivity = false; + void runFlush(presenceOnly); }, delay); }; @@ -972,7 +1246,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { || (record.settleOverride !== "active" && record.settledAt) ) ) { - run.phase = "completed"; + setRunPhase(run, "completed"); recentRuns.set(run.sessionId, { ...run }); runs.delete(run.sessionId); continue; @@ -1132,56 +1406,349 @@ export function createPushPublisherService(deps: PushPublisherDeps) { return { items, commit }; }; - const publishAttentionSnapshot = async ( + type ActivityPublishResponse = { + protocol: number; + result: ActivityPublishResult; + capShrunk: boolean; + }; + + const recordActivityPublishResponse = ( + result: ActivityPublishResult, 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 - // 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, - fingerprint: item.fingerprint, - }))); + allowCapShrink = true, + ): ActivityPublishResponse => { + const protocol = Number.isSafeInteger(result.protocol) && Number(result.protocol) >= 2 + ? Number(result.protocol) + : 1; + if (activityProtocol !== protocol) { + activityProtocol = protocol; + deps.store.setActivityProtocol?.(protocol); + } + const accountOwnerId = deps.getAccountOwnerId?.()?.trim() || null; + const acknowledgments = result.acks ?? []; + if (acknowledgments.length > 0) { + deps.store.recordRemoteAttentionAcknowledgments?.({ + accountOwnerId, + acknowledgments, + updatedAt: new Date(nowMs).toISOString(), + }); + } + const previousCap = activityRosterCap; + if (allowCapShrink && protocol >= 2 && result.itemsTruncated === true) { + activityRosterCap = Math.max( + ACTIVITY_ROSTER_MIN_ITEMS_PER_MACHINE, + Math.floor(activityRosterCap * 0.9), + ); + reconcilePending = true; + } + lastAttentionPublishedAt = nowMs; + return { + protocol, + result, + capShrunk: activityRosterCap < previousCap, + }; + }; + + const selectActivityRoster = (items: AttentionItem[]): { + selected: AttentionItem[]; + overflow: AttentionItem[]; + } => { + const ordered = sortAttentionItems(items); + const foreground = ordered.filter((item) => item.activityTier !== "idle"); + const idle = ordered + .filter((item) => item.activityTier === "idle") + .sort((left, right) => { + const time = Date.parse(right.updatedAt) - Date.parse(left.updatedAt); + return Number.isFinite(time) && time !== 0 ? time : left.id.localeCompare(right.id); + }); + const all = [...foreground, ...idle]; + return { + selected: all.slice(0, activityRosterCap), + overflow: all.slice(activityRosterCap), + }; + }; + + const activityTombstone = ( + id: string, + sourceRevision: number, + nowMs: number, + ): AttentionTombstone => ({ + id, + revision: Math.max(nowMs, sourceRevision + 1), + deletedAt: new Date(nowMs).toISOString(), + }); + + const publishLegacyAttention = async ( + nowMs: number, + force: boolean, + ): Promise<"published" | "unchanged" | "unavailable" | "protocol2"> => { + const items = mergeMachineAcknowledgments( + sortAttentionItems(await buildAttentionItems(nowMs, false)) + .slice(0, LEGACY_ATTENTION_PUBLISH_MAX_ITEMS), + ); + const fingerprint = JSON.stringify( + items.map((item) => [item.id, activityPublishFingerprint(item)]), + ); if ( - fingerprint === lastAttentionFingerprint + !force + && fingerprint === lastLegacyAttentionFingerprint && nowMs - lastAttentionPublishedAt < ATTENTION_HEARTBEAT_MS ) { await reconcileMachineAcknowledgments(items); return "unchanged"; } - try { - const result = await deps.relayClient.publishAttention({ + const result = await deps.relayClient.publishAttention?.({ + machineName: deps.machineName, + fullSnapshot: true, + items, + }); + if (!result) return "unavailable"; + const response = recordActivityPublishResponse(result, nowMs, false); + for (const item of items) { + lastPublishedRevisionById.set(item.id, item.revision); + } + persistLastPublishedRevisions(); + lastLegacyAttentionFingerprint = fingerprint; + await reconcileMachineAcknowledgments(items); + if (response.protocol >= 2) { + reconcilePending = true; + return "protocol2"; + } + reconcilePending = false; + return result.unchanged === true || result.suppressed === true + ? "unchanged" + : "published"; + }; + + const publishProtocol2Reconcile = async ( + nowMs: number, + items: AttentionItem[], + overflow: AttentionItem[], + ): Promise<"published" | "unchanged" | "unavailable" | "legacy"> => { + rosterEpoch = deps.store.nextActivityRosterEpoch?.() ?? rosterEpoch + 1; + const tombstones = overflow.map((item) => + activityTombstone(item.id, item.revision, nowMs)); + const itemPages: AttentionItem[][] = []; + const tombstonePages: AttentionTombstone[][] = []; + for (let offset = 0; offset < items.length; offset += ACTIVITY_PUBLISH_PAGE_ITEMS) { + itemPages.push(items.slice(offset, offset + ACTIVITY_PUBLISH_PAGE_ITEMS)); + } + for (let offset = 0; offset < tombstones.length; offset += ACTIVITY_PUBLISH_PAGE_ITEMS) { + tombstonePages.push(tombstones.slice(offset, offset + ACTIVITY_PUBLISH_PAGE_ITEMS)); + } + const pageCount = Math.max(1, itemPages.length, tombstonePages.length); + let capShrunk = false; + let unchanged = true; + for (let page = 0; page < pageCount; page += 1) { + const result = await deps.relayClient.publishAttention!({ machineName: deps.machineName, - fullSnapshot: true, - items, + mode: "reconcile", + rosterEpoch, + page, + final: page === pageCount - 1, + items: itemPages[page] ?? [], + tombstones: tombstonePages[page] ?? [], }); - if (result) { - lastAttentionFingerprint = fingerprint; - lastAttentionPublishedAt = nowMs; + if (!result) return "unavailable"; + const response = recordActivityPublishResponse(result, nowMs, page === 0); + if (response.protocol < 2) return "legacy"; + for (const item of itemPages[page] ?? []) { + lastPublishedRevisionById.set(item.id, item.revision); + } + persistLastPublishedRevisions(); + capShrunk = capShrunk || response.capShrunk; + unchanged = unchanged && (result.unchanged === true || result.suppressed === true); + } + lastPublishedFingerprintById.clear(); + lastPublishedRevisionById.clear(); + lastOverflowRevisionById.clear(); + for (const item of items) { + lastPublishedFingerprintById.set(item.id, activityPublishFingerprint(item)); + lastPublishedRevisionById.set(item.id, item.revision); + } + for (const item of overflow) { + lastOverflowRevisionById.set(item.id, item.revision); + } + persistLastPublishedRevisions(); + lastReconcileAt = nowMs; + reconcilePending = capShrunk; + await reconcileMachineAcknowledgments(items); + return unchanged ? "unchanged" : "published"; + }; + + const publishProtocol2Delta = async ( + nowMs: number, + items: AttentionItem[], + overflow: AttentionItem[], + presenceOnly: boolean, + ): Promise<"published" | "unchanged" | "unavailable" | "legacy"> => { + const selectedIds = new Set(items.map((item) => item.id)); + const overflowById = new Map(overflow.map((item) => [item.id, item])); + const changed = items.filter((item) => + lastPublishedFingerprintById.get(item.id) !== activityPublishFingerprint(item)); + const droppedTombstones = [...lastPublishedFingerprintById.keys()] + .filter((id) => !selectedIds.has(id)) + .map((id) => activityTombstone( + id, + lastPublishedRevisionById.get(id) ?? 0, + nowMs, + )); + const overflowTombstones = overflow + .filter((item) => lastOverflowRevisionById.get(item.id) !== item.revision) + .map((item) => activityTombstone( + item.id, + Math.max(item.revision, lastPublishedRevisionById.get(item.id) ?? 0), + nowMs, + )); + const tombstones = [...new Map( + [...droppedTombstones, ...overflowTombstones] + .map((tombstone) => [tombstone.id, tombstone]), + ).values()]; + if (changed.length === 0 && tombstones.length === 0) { + if (!presenceOnly && nowMs - lastAttentionPublishedAt < ATTENTION_HEARTBEAT_MS) { await reconcileMachineAcknowledgments(items); - return result.unchanged === true || result.suppressed === true - ? "unchanged" - : "published"; + return "unchanged"; } - return "unavailable"; + const result = await deps.relayClient.publishAttention!({ + machineName: deps.machineName, + mode: "presence", + rosterEpoch, + items: [], + tombstones: [], + }); + if (!result) return "unavailable"; + const response = recordActivityPublishResponse(result, nowMs); + return response.protocol >= 2 ? "unchanged" : "legacy"; + } + + // A normal delta is bounded by the machine cap, but a burst can still + // exceed one wire page. Page explicit deltas too; unlike reconcile, no + // page/final fields are needed because every page is independently safe. + const pageCount = Math.max( + Math.ceil(changed.length / ACTIVITY_PUBLISH_PAGE_ITEMS), + Math.ceil(tombstones.length / ACTIVITY_PUBLISH_PAGE_ITEMS), + ); + let unchanged = true; + for (let page = 0; page < pageCount; page += 1) { + const pageItems = changed.slice( + page * ACTIVITY_PUBLISH_PAGE_ITEMS, + (page + 1) * ACTIVITY_PUBLISH_PAGE_ITEMS, + ); + const pageTombstones = tombstones.slice( + page * ACTIVITY_PUBLISH_PAGE_ITEMS, + (page + 1) * ACTIVITY_PUBLISH_PAGE_ITEMS, + ); + const result = await deps.relayClient.publishAttention!({ + machineName: deps.machineName, + mode: "delta", + rosterEpoch, + items: pageItems, + tombstones: pageTombstones, + }); + if (!result) return "unavailable"; + const response = recordActivityPublishResponse(result, nowMs, page === 0); + if (response.protocol < 2) return "legacy"; + unchanged = unchanged && (result.unchanged === true || result.suppressed === true); + for (const item of pageItems) { + lastPublishedFingerprintById.set(item.id, activityPublishFingerprint(item)); + lastPublishedRevisionById.set(item.id, item.revision); + lastOverflowRevisionById.delete(item.id); + } + for (const tombstone of pageTombstones) { + lastPublishedFingerprintById.delete(tombstone.id); + lastPublishedRevisionById.delete(tombstone.id); + const overflowItem = overflowById.get(tombstone.id); + if (overflowItem) lastOverflowRevisionById.set(tombstone.id, overflowItem.revision); + else lastOverflowRevisionById.delete(tombstone.id); + } + persistLastPublishedRevisions(); + } + await reconcileMachineAcknowledgments(items); + return unchanged ? "unchanged" : "published"; + }; + + const publishActivity = async ( + nowMs: number, + presenceOnly: boolean, + ): Promise<"published" | "unchanged" | "unavailable"> => { + if (typeof deps.relayClient.publishAttention !== "function") return "unavailable"; + const accountOwnerId = deps.getAccountOwnerId?.()?.trim() || null; + if (!accountOwnerId) return "unavailable"; + if (lastActivityAccountOwnerId !== accountOwnerId) { + lastPublishedFingerprintById.clear(); + lastPublishedRevisionById.clear(); + lastOverflowRevisionById.clear(); + lastActivityAccountOwnerId = accountOwnerId; + persistLastPublishedRevisions(); + reconcilePending = true; + } + if (lastReconcileAt > 0 && nowMs - lastReconcileAt >= ACTIVITY_RECONCILE_INTERVAL_MS) { + reconcilePending = true; + } + + try { + if (activityProtocol == null || activityProtocol < 2) { + const legacy = await publishLegacyAttention(nowMs, activityProtocol == null || reconcilePending); + if (legacy !== "protocol2") return legacy; + } + + // A pure presence heartbeat must never touch the all-project disk roster. + if (presenceOnly && !reconcilePending) { + const result = await deps.relayClient.publishAttention({ + machineName: deps.machineName, + mode: "presence", + rosterEpoch, + items: [], + tombstones: [], + }); + if (!result) return "unavailable"; + const response = recordActivityPublishResponse(result, nowMs); + if (response.protocol >= 2) return "unchanged"; + return await publishLegacyAttention(nowMs, true) === "published" + ? "published" + : "unchanged"; + } + + const built = mergeMachineAcknowledgments(await buildAttentionItems(nowMs, true)); + const { selected, overflow } = selectActivityRoster(built); + const protocolResult = reconcilePending + ? await publishProtocol2Reconcile(nowMs, selected, overflow) + : await publishProtocol2Delta(nowMs, selected, overflow, presenceOnly); + if (protocolResult === "unavailable") { + reconcilePending = true; + return "unavailable"; + } + if (protocolResult !== "legacy") return protocolResult; + lastPublishedFingerprintById.clear(); + lastPublishedRevisionById.clear(); + lastOverflowRevisionById.clear(); + persistLastPublishedRevisions(); + reconcilePending = true; + return await publishLegacyAttention(nowMs, true) === "published" + ? "published" + : "unchanged"; } catch (error) { + reconcilePending = true; + if ( + error instanceof PushRelayRequestError + && error.status >= 400 + && error.status < 500 + ) { + activityProtocol = null; + deps.store.setActivityProtocol?.(null); + } logWarn("attention.publish_failed", error); scheduleRetry(); return "unavailable"; } }; - const flush = async (): Promise => { + const flush = async (presenceOnly = false): Promise => { const nowMs = now(); pruneRuns(nowMs); prunePrActivities(nowMs); await resolveMissingMeta(); - const attentionPublishResult = await publishAttentionSnapshot(nowMs); + const attentionPublishResult = await publishActivity(nowMs, presenceOnly); const accountAttentionPublished = attentionPublishResult === "published"; const accountAttentionAvailable = attentionPublishResult !== "unavailable"; if (isGated()) { @@ -1450,19 +2017,19 @@ export function createPushPublisherService(deps: PushPublisherDeps) { flushTimer = setTimeout(() => { flushTimer = null; flushFireAt = 0; - void runFlush(); + void runFlush(false); }, PUBLISH_RETRY_MS); }; - const runFlush = async (): Promise => { + const runFlush = async (presenceOnly = false): Promise => { if (disposed) return; if (flushing) { - if (scopes.size > 0) scheduleFlush(false); + if (scopes.size > 0) scheduleFlush(false, !presenceOnly); return; } flushing = true; try { - await flush(); + await flush(presenceOnly); } catch (error) { logWarn("push.flush_failed", error); } finally { @@ -1482,7 +2049,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { // if another authoritative snapshot is still needed. if (flushing) return; finalAttentionSnapshotPending = false; - void runFlush(); + void runFlush(false); }); }; @@ -1503,7 +2070,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { switch (event.type) { case "approval_request": { - run.phase = "waiting_for_approval"; + setRunPhase(run, "waiting_for_approval"); run.detail = event.description ?? run.detail; run.itemId = event.itemId || null; enqueueAlert({ @@ -1521,7 +2088,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { break; } case "structured_question": { - run.phase = "waiting_for_input"; + setRunPhase(run, "waiting_for_input"); run.detail = event.question ?? run.detail; enqueueAlert({ sessionId, @@ -1537,7 +2104,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { } case "pending_input_resolved": { if (run.phase === "waiting_for_approval" || run.phase === "waiting_for_input") { - run.phase = "running"; + setRunPhase(run, "running"); } run.itemId = null; // Allow a later prompt in the same session to alert again. @@ -1548,28 +2115,28 @@ export function createPushPublisherService(deps: PushPublisherDeps) { case "status": { if (event.turnStatus !== "started") run.itemId = null; if (event.turnStatus === "started") { - if (isTerminalPhase(run.phase)) run.phase = "running"; + if (isTerminalPhase(run.phase)) setRunPhase(run, "running"); } else if (event.turnStatus === "failed") { - run.phase = "failed"; + setRunPhase(run, "failed"); enqueueFailedAlert(run); } else if (event.turnStatus === "completed" || event.turnStatus === "interrupted") { - run.phase = "completed"; + setRunPhase(run, "completed"); } break; } case "done": { if (event.status === "failed") { - run.phase = "failed"; + setRunPhase(run, "failed"); if (!run.model && event.model) run.model = event.model; enqueueFailedAlert(run); } else { - run.phase = "completed"; + setRunPhase(run, "completed"); } break; } default: { if (!isTerminalPhase(run.phase) && run.phase === "starting") { - run.phase = "running"; + setRunPhase(run, "running"); } break; } @@ -1612,9 +2179,9 @@ export function createPushPublisherService(deps: PushPublisherDeps) { ) ) { if (existing) { - existing.phase = "completed"; existing.itemId = null; markRunUpdated(existing); + setRunPhase(existing, "completed"); recentRuns.set(signal.sessionId, { ...existing }); runs.delete(signal.sessionId); pendingAlerts = pendingAlerts.filter( @@ -1655,7 +2222,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { if (existing && existing.kind === "chat") return; const run = ensureRun(signal.sessionId, scopeKey, "cli"); markRunUpdated(run); - run.phase = phase; + setRunPhase(run, phase); if (!run.lane) { run.lane = scopes.get(scopeKey)?.resolveLaneName?.(signal.laneId) ?? signal.laneId ?? null; } @@ -1676,8 +2243,8 @@ export function createPushPublisherService(deps: PushPublisherDeps) { // runtime signal stream never reports exit codes. const run = runs.get(event.sessionId); if (run && run.kind === "cli") { - run.phase = event.exitCode == null || event.exitCode === 0 ? "completed" : "failed"; markRunUpdated(run); + setRunPhase(run, event.exitCode == null || event.exitCode === 0 ? "completed" : "failed"); recentRuns.set(run.sessionId, { ...run }); scheduleFlush(false); } @@ -1761,9 +2328,10 @@ export function createPushPublisherService(deps: PushPublisherDeps) { ? (resolveLaneName?.(notification.laneId) ?? notification.laneId) : null; const activityId = prActivityId(scopeKey, notification); + const existingActivity = prActivities.get(activityId); const eventStamp = Math.max( now(), - (prActivities.get(activityId)?.updatedAt ?? -1) + 1, + (existingActivity?.updatedAt ?? -1) + 1, ); prActivities.set(activityId, { id: activityId, @@ -1776,6 +2344,9 @@ export function createPushPublisherService(deps: PushPublisherDeps) { repoOwner: notification.repoOwner?.trim() || null, repoName: notification.repoName?.trim() || null, updatedAt: eventStamp, + statusSinceAt: existingActivity?.phase === notification.kind + ? existingActivity.statusSinceAt + : eventStamp, }); schedulePrActivityExpiry(eventStamp); @@ -1893,6 +2464,18 @@ export function createPushPublisherService(deps: PushPublisherDeps) { if (disposed || warmed) return; warmed = true; void relayApnsConfigured().catch(() => {}); + reconcilePending = true; + scheduleFlush(true); + }, + + setActivityRosterProvider( + provider: PushPublisherDeps["activityRosterProvider"], + ): void { + if (activityRosterProvider === provider) return; + activityRosterProvider = provider ?? null; + rosterCache = null; + reconcilePending = true; + if (warmed && scopes.size > 0) scheduleFlush(true); }, /** @@ -1924,7 +2507,10 @@ export function createPushPublisherService(deps: PushPublisherDeps) { unsubscribes: scopeUnsubscribes, }); if (!attentionHeartbeatTimer) { - attentionHeartbeatTimer = setInterval(() => scheduleFlush(false), ATTENTION_HEARTBEAT_MS); + attentionHeartbeatTimer = setInterval( + () => scheduleFlush(false, false), + ATTENTION_HEARTBEAT_MS, + ); attentionHeartbeatTimer.unref?.(); } return () => detachScope(scopeKey); @@ -1948,9 +2534,9 @@ export function createPushPublisherService(deps: PushPublisherDeps) { run.lane = request.laneId ? scopes.get(scopeKey)?.resolveLaneName?.(request.laneId) ?? request.laneId : run.lane; - run.phase = "waiting_for_input"; run.detail = request.message; markRunUpdated(run); + setRunPhase(run, "waiting_for_input"); run.metaResolved = true; // An explicit ask supersedes any pending approval on the same session: // clear the stale approval item + its queued alert/dedupe so the next @@ -1978,9 +2564,9 @@ export function createPushPublisherService(deps: PushPublisherDeps) { const run = runs.get(sessionId); if (!run || (scopeKey != null && run.scopeKey !== scopeKey)) return; if (run.phase !== "waiting_for_input" && run.phase !== "waiting_for_approval") return; - run.phase = "running"; run.itemId = null; markRunUpdated(run); + setRunPhase(run, "running"); pendingAlerts = pendingAlerts.filter( (alert) => alert.dedupeKey !== `alert:${sessionId}:approval` @@ -1995,9 +2581,9 @@ export function createPushPublisherService(deps: PushPublisherDeps) { if (disposed || !sessionId) return; const run = runs.get(sessionId); if (!run || (scopeKey != null && run.scopeKey !== scopeKey)) return; - run.phase = "completed"; run.itemId = null; markRunUpdated(run); + setRunPhase(run, "completed"); recentRuns.set(sessionId, { ...run }); runs.delete(sessionId); pendingAlerts = pendingAlerts.filter( @@ -2090,10 +2676,10 @@ export function createPushPublisherService(deps: PushPublisherDeps) { async getMachineAttentionSnapshot(): Promise { const nowMs = now(); const { machineKey } = deps.store.getOrCreateIdentity(); - const items = mergeMachineAcknowledgments( - sortAttentionItems(buildAttentionItems(nowMs)) - .slice(0, ATTENTION_PUBLISH_MAX_ITEMS), + const built = mergeMachineAcknowledgments( + await buildAttentionItems(nowMs, activityProtocol != null && activityProtocol >= 2), ); + const items = selectActivityRoster(built).selected; const accountMachineIdentity = deps.getAccountMachineIdentity?.() ?? null; const accountOwnerId = deps.getAccountOwnerId?.()?.trim() || null; const machine = items[0]?.machine ?? { @@ -2102,7 +2688,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { deviceId: accountMachineIdentity?.deviceId ?? null, name: deps.machineName, online: true, - lastSeenAt: null, + lastSeenAt: new Date(nowMs).toISOString(), }; lastMachineSnapshotItems.clear(); for (const item of items) lastMachineSnapshotItems.set(item.id, item); @@ -2142,12 +2728,12 @@ export function createPushPublisherService(deps: PushPublisherDeps) { const expectedAccountOwnerId = args.expectedAccountOwnerId?.trim() || null; if (expectedAccountOwnerId !== currentAccountOwnerId) { throw new Error( - "The ADE account changed after this machine Attention snapshot loaded. Refresh and try again.", + "The ADE account changed after this machine Activity snapshot loaded. Refresh and try again.", ); } if (lastMachineSnapshotAccountOwnerId !== currentAccountOwnerId) { throw new Error( - "Refresh machine Attention after changing ADE accounts, then try again.", + "Refresh machine Activity after changing ADE accounts, then try again.", ); } const items = args.itemIds.flatMap((itemId) => { @@ -2156,26 +2742,26 @@ export function createPushPublisherService(deps: PushPublisherDeps) { }); if (items.length !== args.itemIds.length) { throw new Error( - "This machine can only acknowledge items from its latest Attention snapshot. Refresh and try again.", + "This machine can only acknowledge items from its latest Activity snapshot. Refresh and try again.", ); } const staleItem = items.find((item) => args.sourceRevisions[item.id] !== item.revision); if (staleItem) { throw new Error( - "This Attention item changed after it loaded. Refresh before acknowledging the newer state.", + "This Activity item changed after it loaded. Refresh before acknowledging the newer state.", ); } const updatedAt = new Date(now()).toISOString(); const seenAt = args.seenAt?.trim() || updatedAt; if (Number.isNaN(Date.parse(seenAt))) { - throw new Error("Attention seenAt must be an ISO timestamp."); + throw new Error("Activity seenAt must be an ISO timestamp."); } if ( typeof args.dismissedAt === "string" && Number.isNaN(Date.parse(args.dismissedAt)) ) { - throw new Error("Attention dismissedAt must be an ISO timestamp."); + throw new Error("Activity dismissedAt must be an ISO timestamp."); } deps.store.recordAttentionAcknowledgments?.({ items: items.map((item) => ({ id: item.id, revision: item.revision })), @@ -2208,6 +2794,18 @@ export function createPushPublisherService(deps: PushPublisherDeps) { await deps.relayClient.putAttentionPreferences?.(accountOwnerId, preferences); }, + async putAttentionMachinePreferences( + accountOwnerId: string, + machineKey: string, + preferences: Partial, + ): Promise { + await deps.relayClient.putActivityMachinePreferences?.( + accountOwnerId, + machineKey, + preferences, + ); + }, + dispose, /** diff --git a/apps/ade-cli/src/services/push/pushRegistrationStore.ts b/apps/ade-cli/src/services/push/pushRegistrationStore.ts index ff27eaf57..0f9816c50 100644 --- a/apps/ade-cli/src/services/push/pushRegistrationStore.ts +++ b/apps/ade-cli/src/services/push/pushRegistrationStore.ts @@ -23,6 +23,15 @@ export type StoredAttentionAcknowledgment = { pendingRelaySync: boolean; }; +export type StoredRemoteAttentionAcknowledgment = { + itemId: string; + accountOwnerId: string | null; + sourceRevision: number; + seenAt: string | null; + dismissedAt: string | null; + updatedAt: string; +}; + type PushRegistrationFile = { version: 1; /** Unguessable machine key claimed on the relay (32 hex chars). */ @@ -36,6 +45,16 @@ type PushRegistrationFile = { devices: Record; /** Durable machine-fallback inbox state, revision-fenced per Attention item. */ attentionAcknowledgments: Record; + /** Relay-owned acknowledgments flowing back down with protocol-2 publishes. */ + remoteAttentionAcknowledgments: Record; + /** Last detected relay protocol. `1` means the response omitted protocol. */ + activityProtocol: number | null; + /** Durable monotonic reconcile epoch; incremented before every new sweep. */ + activityRosterEpoch: number; + /** Account owner whose published source-revision clamps are stored below. */ + lastPublishedRevisionAccountOwnerId: string | null; + /** Durable per-item revision floor so live-to-roster fallback survives restart. */ + lastPublishedRevisionById: Record; lastPublishAt: string | null; lastPublishError: string | null; lastRelayContactAt: string | null; @@ -97,6 +116,11 @@ function createEmptyFile(): PushRegistrationFile { enabled: true, devices: {}, attentionAcknowledgments: {}, + remoteAttentionAcknowledgments: {}, + activityProtocol: null, + activityRosterEpoch: 0, + lastPublishedRevisionAccountOwnerId: null, + lastPublishedRevisionById: {}, lastPublishAt: null, lastPublishError: null, lastRelayContactAt: null, @@ -193,6 +217,54 @@ export function createPushRegistrationStore(args: PushRegistrationStoreArgs) { ]; }), ), + remoteAttentionAcknowledgments: Object.fromEntries( + Object.entries(parsed.remoteAttentionAcknowledgments ?? {}) + .filter((entry): entry is [string, StoredRemoteAttentionAcknowledgment] => { + const acknowledgment = entry[1]; + return Boolean( + acknowledgment + && typeof acknowledgment === "object" + && typeof acknowledgment.itemId === "string" + && acknowledgment.itemId.trim().length > 0 + && ( + acknowledgment.accountOwnerId === undefined + || acknowledgment.accountOwnerId === null + || typeof acknowledgment.accountOwnerId === "string" + ) + && Number.isFinite(acknowledgment.sourceRevision) + && (acknowledgment.seenAt === null || typeof acknowledgment.seenAt === "string") + && (acknowledgment.dismissedAt === null || typeof acknowledgment.dismissedAt === "string") + && typeof acknowledgment.updatedAt === "string", + ); + }) + .map(([, acknowledgment]) => { + const accountOwnerId = acknowledgment.accountOwnerId?.trim() || null; + return [ + attentionAcknowledgmentKey(accountOwnerId, acknowledgment.itemId), + { ...acknowledgment, accountOwnerId }, + ]; + }), + ), + activityProtocol: Number.isSafeInteger(parsed.activityProtocol) + && Number(parsed.activityProtocol) > 0 + ? Number(parsed.activityProtocol) + : null, + activityRosterEpoch: Number.isSafeInteger(parsed.activityRosterEpoch) + && Number(parsed.activityRosterEpoch) >= 0 + ? Number(parsed.activityRosterEpoch) + : 0, + lastPublishedRevisionAccountOwnerId: + typeof parsed.lastPublishedRevisionAccountOwnerId === "string" + ? parsed.lastPublishedRevisionAccountOwnerId.trim() || null + : null, + lastPublishedRevisionById: Object.fromEntries( + Object.entries(parsed.lastPublishedRevisionById ?? {}) + .filter(([itemId, revision]) => + itemId.trim().length > 0 + && Number.isSafeInteger(revision) + && Number(revision) >= 0) + .map(([itemId, revision]) => [itemId, Number(revision)]), + ), lastPublishAt: parsed.lastPublishAt ?? null, lastPublishError: parsed.lastPublishError ?? null, lastRelayContactAt: parsed.lastRelayContactAt ?? null, @@ -381,6 +453,111 @@ export function createPushRegistrationStore(args: PushRegistrationStoreArgs) { if (changed) write({ ...file, attentionAcknowledgments: next }); }, + recordRemoteAttentionAcknowledgments(args: { + accountOwnerId: string | null; + acknowledgments: Array<{ + itemId: string; + sourceRevision: number; + seenAt: string | null; + dismissedAt: string | null; + }>; + updatedAt: string; + }): void { + const file = load(); + const next = { ...file.remoteAttentionAcknowledgments }; + for (const acknowledgment of args.acknowledgments) { + const itemId = acknowledgment.itemId.trim(); + if (!itemId || !Number.isFinite(acknowledgment.sourceRevision)) continue; + const key = attentionAcknowledgmentKey(args.accountOwnerId, itemId); + const existing = next[key]; + if (existing && existing.sourceRevision > acknowledgment.sourceRevision) continue; + next[key] = { + itemId, + accountOwnerId: args.accountOwnerId, + sourceRevision: acknowledgment.sourceRevision, + seenAt: acknowledgment.seenAt, + dismissedAt: acknowledgment.dismissedAt, + updatedAt: args.updatedAt, + }; + } + const bounded = Object.fromEntries( + Object.entries(next) + .sort((left, right) => + Date.parse(right[1].updatedAt) - Date.parse(left[1].updatedAt)) + .slice(0, ATTENTION_ACK_MAX), + ); + write({ ...file, remoteAttentionAcknowledgments: bounded }); + }, + + listRemoteAttentionAcknowledgments( + accountOwnerId?: string | null, + ): StoredRemoteAttentionAcknowledgment[] { + return Object.values(load().remoteAttentionAcknowledgments) + .filter((acknowledgment) => + accountOwnerId === undefined || acknowledgment.accountOwnerId === accountOwnerId) + .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt)); + }, + + getActivityProtocol(): number | null { + return load().activityProtocol; + }, + + setActivityProtocol(protocol: number | null): void { + const file = load(); + const normalized = Number.isSafeInteger(protocol) && Number(protocol) > 0 + ? Number(protocol) + : null; + if (file.activityProtocol === normalized) return; + write({ ...file, activityProtocol: normalized }); + }, + + nextActivityRosterEpoch(): number { + const file = load(); + const next = Math.max(0, file.activityRosterEpoch) + 1; + write({ ...file, activityRosterEpoch: next }); + return next; + }, + + getLastPublishedActivityRevisions(): { + accountOwnerId: string | null; + revisions: Record; + } { + const file = load(); + return { + accountOwnerId: file.lastPublishedRevisionAccountOwnerId, + revisions: { ...file.lastPublishedRevisionById }, + }; + }, + + setLastPublishedActivityRevisions(args: { + accountOwnerId: string | null; + revisions: Record; + }): void { + const file = load(); + const revisions = Object.fromEntries( + Object.entries(args.revisions) + .filter(([itemId, revision]) => + itemId.trim().length > 0 + && Number.isSafeInteger(revision) + && Number(revision) >= 0) + .map(([itemId, revision]) => [itemId, Number(revision)]), + ); + const accountOwnerId = args.accountOwnerId?.trim() || null; + const currentEntries = Object.entries(file.lastPublishedRevisionById); + if ( + file.lastPublishedRevisionAccountOwnerId === accountOwnerId + && currentEntries.length === Object.keys(revisions).length + && currentEntries.every(([itemId, revision]) => revisions[itemId] === revision) + ) { + return; + } + write({ + ...file, + lastPublishedRevisionAccountOwnerId: accountOwnerId, + lastPublishedRevisionById: revisions, + }); + }, + hasRegisteredDevices(): boolean { // A device only counts once it has at least one deliverable token. return Object.values(load().devices).some( diff --git a/apps/ade-cli/src/services/push/pushRelayClient.ts b/apps/ade-cli/src/services/push/pushRelayClient.ts index 84f47a9ae..24d8432e0 100644 --- a/apps/ade-cli/src/services/push/pushRelayClient.ts +++ b/apps/ade-cli/src/services/push/pushRelayClient.ts @@ -2,9 +2,11 @@ import { createHash, createHmac } from "node:crypto"; import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; import type { AttentionItem, + AttentionPreferenceScope, AttentionPreferences, AttentionPresence, AttentionSnapshot, + AttentionTombstone, } from "../../../../desktop/src/shared/types/attention"; import type { PushDeviceRegistration } from "../../../../desktop/src/shared/types/push"; import type { PushRegistrationStore } from "./pushRegistrationStore"; @@ -62,13 +64,53 @@ export type PushRelayHealth = { apnsConfigured: boolean; }; -export type AttentionRelayPublishPayload = { +export type ActivityAcknowledgmentRelayResult = { + applied: string[]; + stale: string[]; +}; + +export type LegacyAttentionRelayPublishPayload = { machineName: string; fullSnapshot: true; items: AttentionItem[]; tombstones?: Array<{ id: string; revision: number }>; }; +export type ActivityPublishRequest = { + machineName: string; + mode: "delta" | "reconcile" | "presence"; + rosterEpoch: number; + page?: number; + final?: boolean; + items: AttentionItem[]; + tombstones: AttentionTombstone[]; + fullSnapshot?: never; +}; + +export type ActivityPublishAcknowledgment = { + itemId: string; + sourceRevision: number; + seenAt: string | null; + dismissedAt: string | null; +}; + +/** Decoded response contract for both legacy and protocol-2 Activity publishes. */ +export type ActivityPublishResult = { + ok: true; + protocol?: number; + revision?: number; + acks?: ActivityPublishAcknowledgment[]; + upserted?: number; + removed?: number; + unchanged?: boolean; + suppressed?: boolean; + itemsTruncated?: boolean; +}; + +export type AttentionRelayPublishPayload = + | LegacyAttentionRelayPublishPayload + | ActivityPublishRequest; + /** * Canonical string the relay commits every signed call to. Binding method, * path and body hash prevents replaying a captured signature against another @@ -247,12 +289,83 @@ export function createPushRelayClient(args: { throw new PushRelayRequestError( "getAttentionSnapshot", 502, - "relay returned an invalid Attention snapshot", + "relay returned an invalid Activity snapshot", ); } return body as unknown as AttentionSnapshot; }; + const requireActivityPublishResult = ( + response: RelayResponse, + ): ActivityPublishResult => { + const body = requireOk("publishAttention", response); + const validOptionalCount = (value: unknown): boolean => + value === undefined || (Number.isSafeInteger(value) && Number(value) >= 0); + const validOptionalBoolean = (value: unknown): boolean => + value === undefined || typeof value === "boolean"; + if ( + body.ok !== true + || ( + body.protocol !== undefined + && (!Number.isSafeInteger(body.protocol) || Number(body.protocol) <= 0) + ) + || !validOptionalCount(body.revision) + || !validOptionalCount(body.upserted) + || !validOptionalCount(body.removed) + || !validOptionalBoolean(body.unchanged) + || !validOptionalBoolean(body.suppressed) + || !validOptionalBoolean(body.itemsTruncated) + || (body.acks !== undefined && !Array.isArray(body.acks)) + ) { + throw new PushRelayRequestError( + "publishAttention", + 502, + "relay returned an invalid Activity publish result", + ); + } + const acknowledgments = (body.acks ?? []).map((value) => { + if (!isRecord(value)) return null; + const itemId = typeof value.itemId === "string" ? value.itemId.trim() : ""; + const sourceRevision = Number(value.sourceRevision); + const seenAt = value.seenAt; + const dismissedAt = value.dismissedAt; + if ( + !itemId + || !Number.isSafeInteger(sourceRevision) + || sourceRevision < 0 + || (seenAt !== null && typeof seenAt !== "string") + || (dismissedAt !== null && typeof dismissedAt !== "string") + || (typeof seenAt === "string" && Number.isNaN(Date.parse(seenAt))) + || (typeof dismissedAt === "string" && Number.isNaN(Date.parse(dismissedAt))) + ) { + return null; + } + return { itemId, sourceRevision, seenAt, dismissedAt }; + }); + if (acknowledgments.some((value) => value === null)) { + throw new PushRelayRequestError( + "publishAttention", + 502, + "relay returned an invalid Activity publish result", + ); + } + return { + ok: true, + ...(body.protocol !== undefined ? { protocol: Number(body.protocol) } : {}), + ...(body.revision !== undefined ? { revision: Number(body.revision) } : {}), + ...(body.acks !== undefined + ? { acks: acknowledgments as ActivityPublishAcknowledgment[] } + : {}), + ...(body.upserted !== undefined ? { upserted: Number(body.upserted) } : {}), + ...(body.removed !== undefined ? { removed: Number(body.removed) } : {}), + ...(body.unchanged !== undefined ? { unchanged: body.unchanged as boolean } : {}), + ...(body.suppressed !== undefined ? { suppressed: body.suppressed as boolean } : {}), + ...(body.itemsTruncated !== undefined + ? { itemsTruncated: body.itemsTruncated as boolean } + : {}), + }; + }; + const machinePath = (suffix: string): string => { const { machineKey } = args.store.getOrCreateIdentity(); return `/machines/${machineKey}${suffix}`; @@ -324,7 +437,7 @@ export function createPushRelayClient(args: { return requireOk("publish", response); }, - async publishAttention(payload: AttentionRelayPublishPayload): Promise | null> { + async publishAttention(payload: AttentionRelayPublishPayload): Promise { if (!args.getAccountAccessToken) return null; const expectedAccountUserId = args.getAccountUserId?.() ?? undefined; if (!expectedAccountUserId) return null; @@ -338,7 +451,7 @@ export function createPushRelayClient(args: { if (response.status === 401 && response.body?.error === "ADE account is not signed in") { return null; } - return requireOk("publishAttention", response); + return requireActivityPublishResult(response); }, async getAttentionSnapshot( @@ -365,10 +478,11 @@ export function createPushRelayClient(args: { async acknowledgeAttention(acknowledgment: { itemIds: string[]; + sourceRevisions?: Record; seenAt?: string; dismissedAt?: string | null; expectedAccountOwnerId?: string | null; - }): Promise | null> { + }): Promise { if (!args.getAccountAccessToken) return null; const currentAccountUserId = args.getAccountUserId?.()?.trim() || null; const expectedAccountUserId = acknowledgment.expectedAccountOwnerId === undefined @@ -376,23 +490,35 @@ export function createPushRelayClient(args: { : acknowledgment.expectedAccountOwnerId?.trim() || null; if (expectedAccountUserId !== currentAccountUserId) { throw new Error( - "The ADE account changed before the Attention acknowledgment could sync.", + "The ADE account changed before the Activity acknowledgment could sync.", ); } if (!currentAccountUserId) return null; - const { - expectedAccountOwnerId: _expectedAccountOwnerId, - ...relayAcknowledgment - } = acknowledgment; const response = await request("POST", "/attention/account/ack", { - body: relayAcknowledgment, + body: acknowledgment, accountAuthorized: true, expectedAccountUserId: expectedAccountUserId ?? undefined, }); if (response.status === 401 && response.body?.error === "ADE account is not signed in") { return null; } - return requireOk("acknowledgeAttention", response); + const body = requireOk("acknowledgeAttention", response); + if ( + !Array.isArray(body.applied) + || !body.applied.every((itemId) => typeof itemId === "string") + || !Array.isArray(body.stale) + || !body.stale.every((itemId) => typeof itemId === "string") + ) { + throw new PushRelayRequestError( + "acknowledgeAttention", + 502, + "relay returned an invalid Activity acknowledgment", + ); + } + return { + applied: body.applied, + stale: body.stale, + }; }, async reportAttentionPresence(presence: AttentionPresence): Promise { @@ -425,9 +551,13 @@ export function createPushRelayClient(args: { expectedAccountUserId: string, preferences: AttentionPreferences, ): Promise { - // Desktop edits account/project policy only. Omitting device overrides - // lets the relay preserve concurrent phone-owned settings atomically. - const { devices: _deviceOverrides, ...accountPreferences } = preferences; + // Desktop edits account/project policy only. Omitting device and machine + // overrides lets the relay preserve concurrent scope-owned settings. + const { + devices: _deviceOverrides, + machines: _machineOverrides, + ...accountPreferences + } = preferences; const response = await request("PUT", "/attention/account/preferences", { body: accountPreferences, accountAuthorized: true, @@ -437,6 +567,26 @@ export function createPushRelayClient(args: { requireOk("putAttentionPreferences", response); }, + async putActivityMachinePreferences( + expectedAccountUserId: string, + machineKey: string, + partial: Partial, + ): Promise { + const response = await request( + "PATCH", + `/attention/account/preferences/machines/${encodeURIComponent(machineKey)}`, + { + body: partial, + accountAuthorized: true, + expectedAccountUserId, + }, + ); + if (response.status === 401 && response.body?.error === "ADE account is not signed in") { + return; + } + requireOk("putActivityMachinePreferences", response); + }, + async health(): Promise { const response = await request("GET", "/health"); const body = response.body ?? {}; diff --git a/apps/ade-cli/src/services/sync/rosterBuilder.test.ts b/apps/ade-cli/src/services/sync/rosterBuilder.test.ts index cc4e48a21..cd736203e 100644 --- a/apps/ade-cli/src/services/sync/rosterBuilder.test.ts +++ b/apps/ade-cli/src/services/sync/rosterBuilder.test.ts @@ -229,6 +229,16 @@ describe("buildRosterSnapshot", () => { ]); }); + it("keeps terminal_sessions.id as the canonical publisher session id", async () => { + const projects = await buildRosterSnapshot({ projectRegistry, scopeRegistry: unbootedScopes }); + const row = projects[0]!.chats.find((chat) => chat.title === "Codex CLI"); + + // Activity publishes this row as agent::; using the + // parent chat_session_id here would prevent live/roster collision dedupe. + expect(row?.id).toBe("cli-codex"); + expect(row?.chatSessionId).toBeNull(); + }); + it("maps disk status truthfully (running→idle, awaiting, failed) when un-booted", async () => { const projects = await buildRosterSnapshot({ projectRegistry, scopeRegistry: unbootedScopes }); const byId = new Map(projects[0]!.chats.map((chat) => [chat.id, chat])); diff --git a/apps/ade-cli/src/tuiClient/__tests__/attentionPane.test.ts b/apps/ade-cli/src/tuiClient/__tests__/activityPane.test.ts similarity index 76% rename from apps/ade-cli/src/tuiClient/__tests__/attentionPane.test.ts rename to apps/ade-cli/src/tuiClient/__tests__/activityPane.test.ts index 910e1cc87..27a359cb7 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/attentionPane.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/activityPane.test.ts @@ -5,13 +5,14 @@ import type { } from "../../../../desktop/src/shared/types/attention"; import type { AdeCodeConnection } from "../types"; import { - acknowledgeAttentionItem, - attentionItemContext, - attentionItemDeepLink, - attentionPaneEntries, - buildAttentionPaneModel, - loadAttentionSnapshot, -} from "../attentionPane"; + acknowledgeActivityItem, + activityItemContext, + activityItemDeepLink, + activityItemElapsed, + activityPaneEntries, + buildActivityPaneModel, + loadActivitySnapshot, +} from "../activityPane"; function item(overrides: Partial = {}): AttentionItem { return { @@ -36,7 +37,7 @@ function item(overrides: Partial = {}): AttentionItem { laneId: "lane-1", laneName: "attention", title: "Codex is working", - preview: "Implementing account Attention", + preview: "Implementing account Activity", privacyPreview: "Agent is working", destination: { kind: "session", @@ -59,7 +60,7 @@ function snapshot(items: AttentionItem[]): AttentionSnapshot { scope: "account", availability: { state: "ready", - title: "Account Attention", + title: "Account Activity", message: "Live across your ADE account.", recovery: null, }, @@ -99,9 +100,9 @@ function asRequest( await implementation(method, params) as T; } -describe("account-wide Attention pane", () => { +describe("account-wide Activity pane", () => { it("groups waiting, failure, unreviewed, and live work without counting live as waiting", () => { - const model = buildAttentionPaneModel(snapshot([ + const model = buildActivityPaneModel(snapshot([ item({ id: "needs", phase: "needs_you", eventKind: "agent_needs_you" }), item({ id: "failed", phase: "failed", eventKind: "agent_failed" }), item({ id: "done", phase: "completed", eventKind: "agent_completed" }), @@ -120,7 +121,35 @@ describe("account-wide Attention pane", () => { expect(model.items.map((entry) => entry.id)).not.toContain("dismissed"); }); - it("reads Attention through the project-independent machine RPC", async () => { + it("files idle-tier roster history as recent instead of counting it as waiting", () => { + const model = buildActivityPaneModel(snapshot([ + item({ id: "needs", phase: "needs_you", eventKind: "agent_needs_you" }), + item({ + id: "ended", + phase: "completed", + eventKind: "agent_completed", + activityTier: "idle", + }), + item({ id: "idle", phase: "stale", activityTier: "idle" }), + ])); + + expect(model.groups.map((group) => group.label)).toEqual(["NEEDS YOU", "RECENT"]); + expect(model.groups.find((group) => group.label === "RECENT")?.items + .map((entry) => entry.id)).toEqual(["idle", "ended"]); + expect(model.waitingCount).toBe(1); + }); + + it("reports how long a row has held its phase, preferring the publisher's anchor", () => { + const now = Date.parse("2026-07-29T02:00:00.000Z"); + expect(activityItemElapsed( + item({ statusSince: "2026-07-29T00:00:00.000Z", updatedAt: "2026-07-29T01:59:00.000Z" }), + now, + )).toBe("2h ago"); + expect(activityItemElapsed(item({ updatedAt: "2026-07-29T01:30:00.000Z" }), now)) + .toBe("30m ago"); + }); + + it("reads Activity through the project-independent machine RPC", async () => { const accountSnapshot = snapshot([item()]); const request = vi.fn(async (method: string, params?: unknown) => { if (method === "account.call") return { result: { signedIn: true } }; @@ -129,7 +158,7 @@ describe("account-wide Attention pane", () => { return accountSnapshot; }); - await expect(loadAttentionSnapshot(connection(asRequest(request)))).resolves.toMatchObject({ + await expect(loadActivitySnapshot(connection(asRequest(request)))).resolves.toMatchObject({ scope: "account", streamId: "account-1", }); @@ -146,7 +175,7 @@ describe("account-wide Attention pane", () => { return { ...snapshot([item()]), scope: "machine" }; }); - const result = await loadAttentionSnapshot(connection(asRequest(request))); + const result = await loadActivitySnapshot(connection(asRequest(request))); expect(result).toMatchObject({ scope: "machine", availability: { @@ -159,7 +188,7 @@ describe("account-wide Attention pane", () => { it("acknowledges machine fallback items through the machine-scoped contract", async () => { const request = vi.fn(async () => null); - await acknowledgeAttentionItem( + await acknowledgeActivityItem( connection(asRequest(request)), { id: "machine-item", revision: 7 }, "machine", @@ -181,10 +210,10 @@ describe("account-wide Attention pane", () => { it("names an old signed-out host instead of fabricating an empty machine fallback", async () => { const request = vi.fn(async (method: string) => { if (method === "account.call") return { result: { signedIn: false } }; - throw new Error("Unsupported Attention method: attention.call"); + throw new Error("Unknown Activity action: getMachineSnapshot"); }); - await expect(loadAttentionSnapshot( + await expect(loadActivitySnapshot( connection(asRequest(request)), { hostName: "Mac Studio" }, )).resolves.toMatchObject({ @@ -203,14 +232,14 @@ describe("account-wide Attention pane", () => { const request = vi.fn(async (method: string, params?: unknown) => { if (method === "account.call") return { result: { signedIn: true } }; if ((params as { action?: string })?.action === "getSnapshot") { - throw new Error("Unsupported Attention method: attention.call"); + throw new Error("Unsupported Activity method: attention.call"); } if ((params as { action?: string })?.action === "getMachineSnapshot") { return machine; } throw new Error(`unexpected ${method}`); }); - const result = await loadAttentionSnapshot(connection(asRequest(request)), { + const result = await loadActivitySnapshot(connection(asRequest(request)), { hostName: "Mac Studio", }); expect(result).toMatchObject({ @@ -227,7 +256,7 @@ describe("account-wide Attention pane", () => { it("never falls back through the selected-project action namespace", async () => { const request = vi.fn(async (method: string) => { if (method === "account.call") return { result: { signedIn: true } }; - throw new Error("Account Attention snapshot failed: unauthorized"); + throw new Error("Account Activity snapshot failed: unauthorized"); }); const selectedProjectActionCalls = vi.fn(); const selectedProjectAction: AdeCodeConnection["action"] = async ( @@ -238,7 +267,7 @@ describe("account-wide Attention pane", () => { selectedProjectActionCalls(domain, action, args); throw new Error("selected-project action must not run"); }; - const result = await loadAttentionSnapshot( + const result = await loadActivitySnapshot( connection(asRequest(request), selectedProjectAction), ); @@ -260,17 +289,17 @@ describe("account-wide Attention pane", () => { lastSeenAt: "2026-07-28T20:00:00.000Z", }, }); - expect(attentionItemDeepLink(target)).toBe( + expect(activityItemDeepLink(target)).toBe( "ade://session/session-1?item=message-1&accountMachineKey=account-machine-2&projectId=project-1", ); - expect(attentionItemContext(target)).toBe("ADE · attention · MacBook Pro"); + expect(activityItemContext(target)).toBe("ADE · attention · MacBook Pro"); }); it("keeps the keyboard selection visible in a bounded pane window", () => { const items = Array.from({ length: 20 }, (_, index) => item({ id: `item-${index}`, phase: index < 10 ? "needs_you" : "running" })); - const model = buildAttentionPaneModel(snapshot(items)); - const window = attentionPaneEntries(model, 18, 8); + const model = buildActivityPaneModel(snapshot(items)); + const window = activityPaneEntries(model, 18, 8); expect(window.entries.some((entry) => entry.kind === "item" && entry.itemIndex === 18)).toBe(true); expect(window.hiddenBefore).toBeGreaterThan(0); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/attentionPaneView.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/activityPaneView.test.tsx similarity index 59% rename from apps/ade-cli/src/tuiClient/__tests__/attentionPaneView.test.tsx rename to apps/ade-cli/src/tuiClient/__tests__/activityPaneView.test.tsx index c32715682..6d8236a86 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/attentionPaneView.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/activityPaneView.test.tsx @@ -1,8 +1,8 @@ import React from "react"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "ink-testing-library"; import type { AttentionItem } from "../../../../desktop/src/shared/types/attention"; -import { buildAttentionPaneModel } from "../attentionPane"; +import { buildActivityPaneModel } from "../activityPane"; import { RightPane } from "../components/RightPane"; function attentionItem(): AttentionItem { @@ -30,15 +30,25 @@ function attentionItem(): AttentionItem { actions: [], occurredAt: "2026-07-29T00:00:00.000Z", updatedAt: "2026-07-29T00:00:00.000Z", + statusSince: "2026-07-29T00:00:00.000Z", seenAt: null, dismissedAt: null, expiresAt: null, }; } -describe("AttentionPane", () => { +describe("ActivityPane", () => { + // Rows carry their age, so the frame is only reproducible against a fixed now. + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-29T02:00:00.000Z")); + }); + afterEach(() => { + vi.useRealTimers(); + }); + it("renders scope, urgency, ownership, offline honesty, and keyboard help", () => { - const model = buildAttentionPaneModel({ + const model = buildActivityPaneModel({ contractVersion: 1, scope: "machine", availability: { @@ -55,21 +65,50 @@ describe("AttentionPane", () => { }); const view = render( , ).lastFrame() ?? ""; - expect(view).toContain("ATTENTION"); + expect(view).toContain("ACTIVITY"); expect(view).toContain("THIS MACHINE"); expect(view).toContain("Showing this machine while you retry."); expect(view).toContain("NEEDS YOU"); expect(view).toContain("Codex needs approval"); - expect(view).toContain("ADE · account-attention · Mac Studio"); + expect(view).toContain("ADE · account-attention · Mac Studio · 2h ago"); expect(view).toContain("offline, last known"); expect(view).toContain("Enter opens exact destination"); expect(view).toContain("R refresh"); }); + + it("keeps the age when the pane is too narrow for the whole project trail", () => { + const model = buildActivityPaneModel({ + contractVersion: 1, + scope: "account", + availability: { + state: "ready", + title: "Account Activity", + message: "Live across your ADE account.", + recovery: null, + }, + streamId: "account-1", + revision: 1, + generatedAt: "2026-07-29T00:00:00.000Z", + items: [attentionItem()], + tombstones: [], + }); + const view = render( + , + ).lastFrame() ?? ""; + + expect(view).toContain("2h ago"); + expect(view).not.toContain("account-attention · Mac Studio"); + }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index 1bd0edbef..159d661df 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -3,17 +3,23 @@ import { commandPlacement, parseCommand, paletteCommands } from "../commands"; import { buildLinearToolRequest, parseLinearArgs } from "../linearCommands"; describe("commands", () => { - it("routes account Attention to the keyboard-accessible right pane", () => { - const parsed = parseCommand("/attention"); - expect(parsed?.name).toBe("/attention"); + it("routes account Activity to the keyboard-accessible right pane", () => { + const parsed = parseCommand("/activity"); + expect(parsed?.name).toBe("/activity"); expect(parsed ? commandPlacement(parsed) : null).toBe("right"); - expect(paletteCommands("/att")).toContainEqual(expect.objectContaining({ - name: "/attention", + expect(paletteCommands("/act")).toContainEqual(expect.objectContaining({ + name: "/activity", source: "ade", description: "Show account-wide work that needs you", })); }); + it("keeps the old Attention command as a non-advertised alias", () => { + const parsed = parseCommand("/attention"); + expect(parsed?.name).toBe("/activity"); + expect(paletteCommands("/attention")).toEqual([]); + }); + it("parses multi-word ADE commands before generic slash commands", () => { const parsed = parseCommand("/linear pull ADE-123"); expect(parsed?.name).toBe("/linear pull"); diff --git a/apps/ade-cli/src/tuiClient/attentionPane.ts b/apps/ade-cli/src/tuiClient/activityPane.ts similarity index 75% rename from apps/ade-cli/src/tuiClient/attentionPane.ts rename to apps/ade-cli/src/tuiClient/activityPane.ts index 65a078fc7..76ebd6366 100644 --- a/apps/ade-cli/src/tuiClient/attentionPane.ts +++ b/apps/ade-cli/src/tuiClient/activityPane.ts @@ -4,27 +4,29 @@ import type { } from "../../../desktop/src/shared/types/attention"; import { ATTENTION_CONTRACT_VERSION, + activityItemTier, attentionDestinationDeepLink, sortAttentionItems, } from "../../../desktop/src/shared/types/attention"; +import { formatRelativePastTime } from "./relativeTime"; import type { AdeCodeConnection } from "./types"; -export type AttentionPaneGroupId = +export type ActivityPaneGroupId = | "needs-you" | "failing" | "done" | "live" | "recent"; -export type AttentionPaneGroup = { - id: AttentionPaneGroupId; +export type ActivityPaneGroup = { + id: ActivityPaneGroupId; label: string; items: AttentionItem[]; }; -export type AttentionPaneModel = { +export type ActivityPaneModel = { snapshot: AttentionSnapshot; - groups: AttentionPaneGroup[]; + groups: ActivityPaneGroup[]; items: AttentionItem[]; title: string; message: string; @@ -33,7 +35,7 @@ export type AttentionPaneModel = { liveCount: number; }; -export type AttentionPaneEntry = +export type ActivityPaneEntry = | { kind: "heading"; key: string; label: string } | { kind: "item"; key: string; item: AttentionItem; itemIndex: number }; @@ -64,8 +66,8 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function isUnsupportedAttentionError(error: unknown): boolean { - return /unknown (?:ade )?action|unknown attention action|method not found|unsupported.*attention|attention\.call.*not (?:available|found)/i +function isUnsupportedActivityError(error: unknown): boolean { + return /unknown (?:ade )?action|unknown (?:attention|activity) action|method not found|unsupported.*attention|attention\.call.*not (?:available|found)/i .test(errorMessage(error)); } @@ -111,12 +113,12 @@ async function machineFallback( } /** - * Reads account Attention from the machine-global RPC rather than from the + * Reads account Activity from the machine-global RPC rather than from the * TUI's selected project action scope. A signed-out or temporarily unavailable * account falls back to this connected machine without pretending that the * result is account-wide. */ -export async function loadAttentionSnapshot( +export async function loadActivitySnapshot( connection: AdeCodeConnection, options: { hostName?: string | null } = {}, ): Promise { @@ -132,19 +134,19 @@ export async function loadAttentionSnapshot( }); } catch (error) { const hostName = options.hostName?.trim() || "this ADE host"; - if (isUnsupportedAttentionError(error)) { + if (isUnsupportedActivityError(error)) { return emptySnapshot({ state: "incompatible", title: `Update ${hostName}`, message: - "This host cannot provide machine Attention yet. Update ADE, restart its brain, then retry.", + "This host cannot provide machine Activity yet. Update ADE, restart its brain, then retry.", recovery: "update_host", hostName, }); } return emptySnapshot({ state: "unavailable", - title: "Machine Attention is unavailable", + title: "Machine Activity is unavailable", message: `ADE Code could not read work from ${hostName}. Reconnect to the host, then retry.`, recovery: "retry", hostName, @@ -163,19 +165,19 @@ export async function loadAttentionSnapshot( scope: snapshot.scope ?? "account", availability: snapshot.availability ?? { state: "ready", - title: "Account Attention", + title: "Account Activity", message: "Live across your ADE account.", recovery: null, }, }; } catch (error) { const hostName = options.hostName?.trim() || "this ADE host"; - if (isUnsupportedAttentionError(error)) { + if (isUnsupportedActivityError(error)) { try { return await machineFallback(connection, { state: "incompatible", title: `Update ${hostName}`, - message: "This host cannot read account-wide Attention yet. Update ADE, then restart its brain. Local work remains available.", + message: "This host cannot read account-wide Activity yet. Update ADE, then restart its brain. Local work remains available.", recovery: "update_host", hostName, }); @@ -184,7 +186,7 @@ export async function loadAttentionSnapshot( state: "incompatible", title: `Update ${hostName}`, message: - "This host cannot provide Attention yet. Update ADE, restart its brain, then retry.", + "This host cannot provide Activity yet. Update ADE, restart its brain, then retry.", recovery: "update_host", hostName, }); @@ -201,7 +203,7 @@ export async function loadAttentionSnapshot( } catch { return emptySnapshot({ state: "unavailable", - title: "Attention is unavailable", + title: "Activity is unavailable", message: "ADE Code could not read the account stream or this host. Reconnect to the host, then retry.", recovery: "retry", @@ -211,7 +213,7 @@ export async function loadAttentionSnapshot( } } -export async function acknowledgeAttentionItem( +export async function acknowledgeActivityItem( connection: AdeCodeConnection, item: Pick, scope: AttentionSnapshot["scope"] = "account", @@ -226,7 +228,13 @@ export async function acknowledgeAttentionItem( }); } -function groupForItem(item: AttentionItem): AttentionPaneGroupId { +function groupForItem(item: AttentionItem): ActivityPaneGroupId { + // Disk-only roster rows are quiet history: an ended chat still carries phase + // `completed` with no seenAt, which would otherwise file every session the + // account has ever finished under DONE, UNREVIEWED and count it as waiting. + // Desktop files the same rows as the ambient tail — see `activitySectionId` + // in apps/desktop/src/renderer/components/activity/activityPriority.ts. + if (activityItemTier(item) === "idle") return "recent"; if (item.phase === "needs_you" || item.phase === "review_requested" || item.phase === "merge_ready") { return "needs-you"; } @@ -247,7 +255,7 @@ function groupForItem(item: AttentionItem): AttentionPaneGroupId { return "recent"; } -const GROUP_LABELS: Record = { +const GROUP_LABELS: Record = { "needs-you": "NEEDS YOU", failing: "FAILING OR BLOCKED", done: "DONE, UNREVIEWED", @@ -255,20 +263,20 @@ const GROUP_LABELS: Record = { recent: "RECENT", }; -export function buildAttentionPaneModel(snapshot: AttentionSnapshot): AttentionPaneModel { +export function buildActivityPaneModel(snapshot: AttentionSnapshot): ActivityPaneModel { const visible = sortAttentionItems( snapshot.items.filter((item) => item.dismissedAt === null), ); - const buckets = new Map(); + const buckets = new Map(); for (const item of visible) { const group = groupForItem(item); const bucket = buckets.get(group) ?? []; bucket.push(item); buckets.set(group, bucket); } - const order: AttentionPaneGroupId[] = ["needs-you", "failing", "done", "live", "recent"]; + const order: ActivityPaneGroupId[] = ["needs-you", "failing", "done", "live", "recent"]; const groups = order - .map((id): AttentionPaneGroup => ({ + .map((id): ActivityPaneGroup => ({ id, label: GROUP_LABELS[id], items: buckets.get(id) ?? [], @@ -281,7 +289,7 @@ export function buildAttentionPaneModel(snapshot: AttentionSnapshot): AttentionP const liveCount = groups.find((group) => group.id === "live")?.items.length ?? 0; const availability = snapshot.availability ?? { state: snapshot.scope === "machine" ? "degraded" as const : "ready" as const, - title: snapshot.scope === "machine" ? "This machine only" : "Account Attention", + title: snapshot.scope === "machine" ? "This machine only" : "Account Activity", message: snapshot.scope === "machine" ? "Account sync is unavailable. Showing connected-machine work." : "Live across your ADE account.", @@ -300,22 +308,32 @@ export function buildAttentionPaneModel(snapshot: AttentionSnapshot): AttentionP }; } -export function attentionItemDeepLink(item: AttentionItem): string { +export function activityItemDeepLink(item: AttentionItem): string { return attentionDestinationDeepLink(item.destination, item); } -export function attentionItemContext(item: AttentionItem): string { +/** + * How long the row has held its current phase. `statusSince` is the publisher's + * phase anchor, so a long-running agent reads as "2h ago" for the phase rather + * than for its last token; publishers older than this build omit it and + * `updatedAt` is the honest fallback. + */ +export function activityItemElapsed(item: AttentionItem, nowMs = Date.now()): string { + return formatRelativePastTime(item.statusSince ?? item.updatedAt, nowMs); +} + +export function activityItemContext(item: AttentionItem): string { return [item.project.name, item.laneName, item.machine.name] .filter((value): value is string => Boolean(value?.trim())) .join(" · "); } -export function attentionPaneEntries( - model: AttentionPaneModel, +export function activityPaneEntries( + model: ActivityPaneModel, selectedIndex: number, maxRows = 20, -): { entries: AttentionPaneEntry[]; hiddenBefore: number; hiddenAfter: number } { - const all: AttentionPaneEntry[] = []; +): { entries: ActivityPaneEntry[]; hiddenBefore: number; hiddenAfter: number } { + const all: ActivityPaneEntry[] = []; let itemIndex = 0; for (const group of model.groups) { all.push({ kind: "heading", key: `heading:${group.id}`, label: group.label }); diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 5698bd86d..4b263c3f0 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -350,11 +350,11 @@ import { claudeHomePath, defaultKeybindingsPath, dispatchKeybinding, openKeybind import { buildDeeplinkForRow, buildWebClientUrlForRow, type DeeplinkRow } from "./deeplinkRow"; import { copyToClipboard } from "../lib/clipboard"; import { - acknowledgeAttentionItem, - attentionItemDeepLink, - buildAttentionPaneModel, - loadAttentionSnapshot, -} from "./attentionPane"; + acknowledgeActivityItem, + activityItemDeepLink, + buildActivityPaneModel, + loadActivitySnapshot, +} from "./activityPane"; import { deletePromptSmartLinkBackward, deletePromptSmartLinkForward, @@ -893,7 +893,7 @@ function openExternalUrl(url: string, notice: (message: string, tone?: LocalNoti return true; } -async function openAttentionDeepLink( +async function openActivityDeepLink( url: string, notice: (message: string, tone?: LocalNotice["tone"]) => void, ): Promise { @@ -9501,13 +9501,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, toggleRightChatsClosedGroup, ]); - const activateAttentionItem = useCallback(async (index: number): Promise => { + const activateActivityItem = useCallback(async (index: number): Promise => { const pane = rightPaneRef.current; - if (pane.kind !== "attention") return; + if (pane.kind !== "activity") return; const item = pane.model.items[index]; if (!item) return; - const deepLink = attentionItemDeepLink(item); - if (!await openAttentionDeepLink(deepLink, addNotice)) { + const deepLink = activityItemDeepLink(item); + if (!await openActivityDeepLink(deepLink, addNotice)) { addNotice("ADE could not open this destination on the current platform. The item remains unreviewed.", "error"); return; } @@ -9521,20 +9521,20 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, entry.id === item.id ? { ...entry, seenAt } : entry), }; setRightPane({ - kind: "attention", - model: buildAttentionPaneModel(updatedSnapshot), + kind: "activity", + model: buildActivityPaneModel(updatedSnapshot), }); const conn = connectionRef.current; if (!conn) return; try { - await acknowledgeAttentionItem( + await acknowledgeActivityItem( conn, item, pane.model.snapshot.scope, pane.model.snapshot.accountOwnerId ?? null, ); } catch { - addNotice("The destination opened, but ADE could not sync the seen state. Retry Attention to reconcile it.", "error"); + addNotice("The destination opened, but ADE could not sync the seen state. Retry Activity to reconcile it.", "error"); } }, [addNotice]); @@ -9636,41 +9636,41 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, setPaneFocus("details"); }, [renderHelpPane, setPaneFocus]); - const refreshAttentionPane = useCallback(async (options: { announce?: boolean } = {}) => { + const refreshActivityPane = useCallback(async (options: { announce?: boolean } = {}) => { const conn = connectionRef.current; if (!conn) { setRightPane({ kind: "details", - title: "Attention", + title: "Activity", body: "ADE is still connecting. Retry when the runtime is ready.", }); return; } - const snapshot = await loadAttentionSnapshot(conn, { + const snapshot = await loadActivitySnapshot(conn, { hostName: project.remoteLabel, }); - const model = buildAttentionPaneModel(snapshot); + const model = buildActivityPaneModel(snapshot); setRightSelectionIndex((index) => Math.max(0, Math.min(index, Math.max(0, model.items.length - 1)))); - setRightPane({ kind: "attention", model }); + setRightPane({ kind: "activity", model }); setRightOpen(true); if (options.announce) { addNotice( snapshot.scope === "machine" - ? "Attention refreshed from this connected machine." - : "Account Attention refreshed.", + ? "Activity refreshed from this connected machine." + : "Account Activity refreshed.", snapshot.availability?.state === "ready" ? "success" : "info", ); } }, [addNotice, project.remoteLabel]); useEffect(() => { - if (rightPane.kind !== "attention" || !connection) return; + if (rightPane.kind !== "activity" || !connection) return; const timer = setInterval(() => { - void refreshAttentionPane(); + void refreshActivityPane(); }, 10_000); timer.unref?.(); return () => clearInterval(timer); - }, [connection, refreshAttentionPane, rightPane.kind]); + }, [connection, refreshActivityPane, rightPane.kind]); const runRightCommand = useCallback(async (name: string, args: string) => { const conn = connectionRef.current; @@ -9701,10 +9701,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, }); return; } - if (name === "/attention") { + if (name === "/activity") { setRightPane({ kind: "details", - title: "Attention", + title: "Activity", body: "ADE is still connecting. Retry when the runtime is ready.", }); return; @@ -9757,9 +9757,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, renderHelpPane("", 0, helpRecentsRef.current); return; } - if (name === "/attention") { + if (name === "/activity") { setRightSelectionIndex(0); - await refreshAttentionPane(); + await refreshActivityPane(); return; } if (name === "/keybindings") { @@ -11293,7 +11293,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, addNotice(result.message ?? "Desktop route unavailable from this runtime.", "error"); } } - }, [activeSession?.provider, addNotice, applyLocalModelArg, applySessionSnooze, clearOlderHistoryCursor, displaySessions, loadProviderModels, modelState.provider, openSnoozeDurationPalette, pendingSteers, preferServiceRepair, project, refreshAiSetupStatus, refreshAttentionPane, refreshState, remoteLaunch, requestAppExit, scheduleModelStateCommit, sendClaudeModelCommandToTerminal, setChatScrollOffset, socketPath]); + }, [activeSession?.provider, addNotice, applyLocalModelArg, applySessionSnooze, clearOlderHistoryCursor, displaySessions, loadProviderModels, modelState.provider, openSnoozeDurationPalette, pendingSteers, preferServiceRepair, project, refreshAiSetupStatus, refreshActivityPane, refreshState, remoteLaunch, requestAppExit, scheduleModelStateCommit, sendClaudeModelCommandToTerminal, setChatScrollOffset, socketPath]); const submitRightForm = useCallback(async ( form: Extract, @@ -15096,7 +15096,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, } } - if (pane === "details" && rightOpen && rightPane.kind === "attention") { + if (pane === "details" && rightOpen && rightPane.kind === "activity") { const itemCount = rightPane.model.items.length; if (key.upArrow) { setRightSelectionIndex((index) => (index <= 0 ? Math.max(0, itemCount - 1) : index - 1)); @@ -15115,11 +15115,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, return; } if (key.return && itemCount > 0) { - void activateAttentionItem(rightSelectionIndex); + void activateActivityItem(rightSelectionIndex); return; } if (input.toLowerCase() === "r" && !key.ctrl && !key.meta) { - void refreshAttentionPane({ announce: true }); + void refreshActivityPane({ announce: true }); return; } } diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index b697c3559..728b2aa38 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -70,8 +70,8 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ // The bare group name is registered so submitting it prints usage instead of // leaking "/session" into the chat as a message. { name: "/session", description: "Run a session lifecycle command", placement: "right", argumentHint: "", category: "Chats" }, - { name: "/session snooze", description: "Snooze a session out of the attention list until a deadline", placement: "right", argumentHint: "[session-id] [30m|1h|4h|1d]", category: "Chats" }, - { name: "/session wake", description: "Wake a snoozed session back into the attention list", placement: "right", argumentHint: "[session-id]", category: "Chats" }, + { name: "/session snooze", description: "Snooze a session out of the Activity list until a deadline", placement: "right", argumentHint: "[session-id] [30m|1h|4h|1d]", category: "Chats" }, + { name: "/session wake", description: "Wake a snoozed session back into the Activity list", placement: "right", argumentHint: "[session-id]", category: "Chats" }, { name: "/session settle", description: "Mark a session settled", placement: "right", argumentHint: "[session-id] [outcome]", category: "Chats" }, { name: "/session unsettle", description: "Remove a session's settled state", placement: "right", argumentHint: "[session-id]", category: "Chats" }, { name: "/session keep-active", description: "Pin a session active against a later settle", placement: "right", argumentHint: "[session-id]", category: "Chats" }, @@ -79,7 +79,7 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/output-style", description: "List or select the active Claude output style", placement: "right", argumentHint: "[style]", providers: ["claude"], category: "Model" }, { name: "/plugin", description: "List, reload, or manage Claude plugins", placement: "right", argumentHint: "[reload|native args]", providers: ["claude"], category: "Model" }, { name: "/status", description: "Show project, lane, and runtime state", placement: "right", category: "Nav" }, - { name: "/attention", description: "Show account-wide work that needs you", placement: "right", category: "Nav" }, + { name: "/activity", description: "Show account-wide work that needs you", placement: "right", category: "Nav" }, { name: "/context", description: "Show chat context usage", placement: "right", category: "Nav" }, { name: "/agents", description: "List Claude agents from user and project config", placement: "right", providers: ["claude"], category: "Nav" }, { name: "/info", description: "Open active chat info, plan, goal, and agents", placement: "right", category: "Nav" }, @@ -163,6 +163,10 @@ export type ParsedCommand = { userCommand: AgentChatSlashCommand | null; }; +const LEGACY_LOCAL_COMMAND_ALIASES: Readonly> = { + "/attention": "/activity", +}; + function normalizeSlashName(value: string): string { return value.trim().replace(/\s+/g, " "); } @@ -172,8 +176,11 @@ function slashCommandKey(value: string): string { } export function parseCommand(input: string, userCommands: AgentChatSlashCommand[] = []): ParsedCommand | null { - const trimmed = input.trim(); + let trimmed = input.trim(); if (!trimmed.startsWith("/")) return null; + const [legacyName = ""] = trimmed.split(/\s+/, 1); + const replacement = LEGACY_LOCAL_COMMAND_ALIASES[slashCommandKey(legacyName)]; + if (replacement) trimmed = `${replacement}${trimmed.slice(legacyName.length)}`; const [first = ""] = trimmed.split(/\s+/, 1); const firstKey = slashCommandKey(first); const candidates = [...BUILTIN_COMMANDS] diff --git a/apps/ade-cli/src/tuiClient/components/AttentionPaneView.tsx b/apps/ade-cli/src/tuiClient/components/ActivityPaneView.tsx similarity index 78% rename from apps/ade-cli/src/tuiClient/components/AttentionPaneView.tsx rename to apps/ade-cli/src/tuiClient/components/ActivityPaneView.tsx index 584165bb4..b50e612e5 100644 --- a/apps/ade-cli/src/tuiClient/components/AttentionPaneView.tsx +++ b/apps/ade-cli/src/tuiClient/components/ActivityPaneView.tsx @@ -3,9 +3,10 @@ import { Box, Text } from "ink"; import type { AttentionItem } from "../../../../desktop/src/shared/types/attention"; import { - attentionItemContext, - attentionPaneEntries, -} from "../attentionPane"; + activityItemContext, + activityItemElapsed, + activityPaneEntries, +} from "../activityPane"; import { theme } from "../theme"; import type { RightPaneContent } from "../types"; @@ -15,7 +16,7 @@ function endTruncate(value: string, max: number): string { return `${value.slice(0, max - 1)}…`; } -function attentionTone(item: AttentionItem): string { +function activityTone(item: AttentionItem): string { if (item.phase === "needs_you" || item.phase === "review_requested" || item.phase === "merge_ready") { return theme.color.attention; } @@ -34,7 +35,7 @@ function attentionTone(item: AttentionItem): string { return theme.color.t2; } -function attentionGlyph(item: AttentionItem): string { +function activityGlyph(item: AttentionItem): string { if (item.phase === "needs_you" || item.phase === "review_requested" || item.phase === "merge_ready") return "!"; if (item.phase === "failed" || item.phase === "checks_failing" || item.phase === "changes_requested") return "×"; if (item.phase === "blocked" || item.phase === "stale") return "◆"; @@ -43,12 +44,12 @@ function attentionGlyph(item: AttentionItem): string { return "·"; } -export function AttentionPaneView({ +export function ActivityPaneView({ content, selectedIndex, width, }: { - content: Extract; + content: Extract; selectedIndex: number; width: number; }) { @@ -59,7 +60,7 @@ export function AttentionPaneView({ : availability?.state === "signed_out" ? theme.color.attention : theme.color.error; - const window = attentionPaneEntries(model, selectedIndex, 11); + const window = activityPaneEntries(model, selectedIndex, 11); const inner = Math.max(18, width - 4); return ( @@ -83,18 +84,25 @@ export function AttentionPaneView({ ); } const selected = entry.itemIndex === selectedIndex; - const context = attentionItemContext(entry.item); + const context = activityItemContext(entry.item); + // The age is the one fact a row cannot imply, so it keeps its width and + // the project/lane/machine trail truncates around it. + const elapsed = activityItemElapsed(entry.item); + const metaWidth = Math.max(8, inner - 4); + const meta = context + ? `${endTruncate(context, Math.max(4, metaWidth - elapsed.length - 3))} · ${elapsed}` + : elapsed; return ( - {`${selected ? theme.rail : " "} ${attentionGlyph(entry.item)} ${endTruncate(entry.item.title, Math.max(8, inner - 4))}`} + {`${selected ? theme.rail : " "} ${activityGlyph(entry.item)} ${endTruncate(entry.item.title, Math.max(8, inner - 4))}`} - {` ${endTruncate(context, Math.max(8, inner - 4))}`} + {` ${endTruncate(meta, metaWidth)}`} {!entry.item.machine.online ? ( diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index 8e46f8467..ef375082c 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -74,7 +74,7 @@ import { type FeedbackFormState, type FeedbackType, } from "../feedbackForm"; -import { AttentionPaneView } from "./AttentionPaneView"; +import { ActivityPaneView } from "./ActivityPaneView"; // Cap per-file diff body so a pathological 50k-line file can't make the right // pane build a giant row array on every scroll. The window only shows @@ -1740,7 +1740,7 @@ export function rightPaneScrollableRowCount(content: RightPaneContent): number { case "status": // Flat key/value list — scrolls by row count. return content.rows.length; - case "attention": + case "activity": // Selection keeps the focused account item visible; the pane owns its // compact window rather than participating in generic line scrolling. return 0; @@ -2243,8 +2243,8 @@ function paneTitle(content: RightPaneContent): { title: string; hint?: string; b return { title: "HELP" }; case "status": return { title: "STATUS" }; - case "attention": - return { title: "ATTENTION", hint: content.model.snapshot.scope === "machine" ? "THIS MACHINE" : "ACCOUNT" }; + case "activity": + return { title: "ACTIVITY", hint: content.model.snapshot.scope === "machine" ? "THIS MACHINE" : "ACCOUNT" }; case "diff": return { title: content.title.toUpperCase() }; case "list": @@ -2337,8 +2337,8 @@ function RightPaneComponent({ {content.kind === "help" ? : null} - {content.kind === "attention" ? ( - + {content.kind === "activity" ? ( + ) : null} {content.kind === "status" ? ( diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index 3d7aabd4f..9af28a957 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -28,7 +28,7 @@ import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; import type { UsageProviderSource, UsageProviderState } from "../../../desktop/src/shared/types/usage"; import type { BufferedEvent } from "../eventBuffer"; import type { HelpGroup } from "./helpIndex"; -import type { AttentionPaneModel } from "./attentionPane"; +import type { ActivityPaneModel } from "./activityPane"; export type RuntimeMode = "attached" | "embedded"; @@ -258,7 +258,7 @@ export interface FeedbackContextMeta { export type RightPaneContent = | { kind: "empty" } | ModelPickerRightPaneContent - | { kind: "attention"; model: AttentionPaneModel } + | { kind: "activity"; model: ActivityPaneModel } | { kind: "help"; title: string; diff --git a/apps/desktop/native/ADEAttentionNotch/DESIGN_NOTES.md b/apps/desktop/native/ADEAttentionNotch/DESIGN_NOTES.md index 09d797ff8..c88874dbf 100644 --- a/apps/desktop/native/ADEAttentionNotch/DESIGN_NOTES.md +++ b/apps/desktop/native/ADEAttentionNotch/DESIGN_NOTES.md @@ -39,7 +39,7 @@ Two failure modes this replaced, both visible in production screenshots: tone. Colours, type scale and phase vocabulary mirror the renderer's Attention -surfaces (`index.css` tokens, `attentionPresentation.ts`, the +surfaces (`index.css` tokens, `activityPresentation.ts`, the `.attention-tone-*` palette) so a phase reads identically in the notch, the header control and the Attention center. `NotchSurfaceShape` and `NotchPanelController.interactivePath` are built from the same corner metrics diff --git a/apps/desktop/native/ADEAttentionNotch/Package.swift b/apps/desktop/native/ADEAttentionNotch/Package.swift index fcf452026..3b1925038 100644 --- a/apps/desktop/native/ADEAttentionNotch/Package.swift +++ b/apps/desktop/native/ADEAttentionNotch/Package.swift @@ -30,7 +30,7 @@ let package = Package( ), .testTarget( name: "ADEAttentionNotchCoreTests", - dependencies: ["ADEAttentionNotchCore"] + dependencies: ["ADEAttentionNotchCore", "ADEAttentionNotch"] ), ] ) diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchContextMenuController.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchContextMenuController.swift index 29e463ebd..be5b440c5 100644 --- a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchContextMenuController.swift +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchContextMenuController.swift @@ -24,7 +24,7 @@ final class NotchContextMenuController: NSObject { private func menu() -> NSMenu { let menu = NSMenu(title: "ADE Notch") menu.autoenablesItems = false - menu.addItem(item("Open Attention Center", action: #selector(openAttentionCenter))) + menu.addItem(item("Open Activity", action: #selector(openActivity))) menu.addItem(item("Refresh", action: #selector(refresh))) menu.addItem(.separator()) @@ -43,6 +43,18 @@ final class NotchContextMenuController: NSObject { let expanded = item("Allow expanded panel", action: #selector(toggleExpandedPanel)) expanded.state = model.settings.expandedPanelEnabled ? .on : .off menu.addItem(expanded) + let automaticReveal = item("Automatic reveal", action: #selector(toggleAutomaticReveal)) + automaticReveal.state = model.settings.automaticRevealEnabled ? .on : .off + // "Click only" already means nothing but a click opens anything, so the + // checkmark would claim a behaviour the mode overrides. + automaticReveal.isEnabled = model.settings.revealMode != .click + menu.addItem(automaticReveal) + let ticker = item("Live ticker", action: #selector(toggleTicker)) + ticker.state = model.settings.tickerEnabled ? .on : .off + // The ticker lives in the pinned strip, which only compact mode keeps + // on screen at rest. + ticker.isEnabled = model.settings.revealMode == .minimal + menu.addItem(ticker) menu.addItem(.separator()) menu.addItem(item("Hide ADE Notch…", action: #selector(confirmHide))) return menu @@ -54,8 +66,8 @@ final class NotchContextMenuController: NSObject { return item } - @objc private func openAttentionCenter() { - model.openAttentionCenter() + @objc private func openActivity() { + model.openActivity() } @objc private func refresh() { @@ -74,11 +86,19 @@ final class NotchContextMenuController: NSObject { model.applySettingsMenuAction(.toggleExpandedPanel) } + @objc private func toggleAutomaticReveal() { + model.applySettingsMenuAction(.toggleAutomaticReveal) + } + + @objc private func toggleTicker() { + model.applySettingsMenuAction(.toggleTicker) + } + @objc private func confirmHide() { let alert = NSAlert() alert.alertStyle = .informational alert.messageText = "Hide ADE Notch?" - alert.informativeText = "This removes the notch and menu-bar activity surface. You can turn it back on anytime in ADE Attention settings." + alert.informativeText = "This removes the notch and menu-bar activity surface. You can turn it back on anytime in ADE’s Activity settings." alert.addButton(withTitle: "Hide ADE Notch") alert.addButton(withTitle: "Cancel") guard alert.runModal() == .alertFirstButtonReturn else { return } diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchPanelController.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchPanelController.swift index 705e51d0b..b86a2ac74 100644 --- a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchPanelController.swift +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchPanelController.swift @@ -194,12 +194,6 @@ final class NotchPanelController { 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 diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchStatusItemController.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchStatusItemController.swift index 5a0351d08..154d1b459 100644 --- a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchStatusItemController.swift +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchStatusItemController.swift @@ -19,8 +19,8 @@ final class NotchStatusItemController { panelController.fallbackAnchorFrame = { [weak self] in self?.statusItemScreenFrame } - Publishers.CombineLatest3(model.$items, model.$interaction, model.$settings) - .sink { [weak self] _, _, _ in self?.refresh() } + Publishers.CombineLatest4(model.$items, model.$interaction, model.$settings, model.$counts) + .sink { [weak self] _, _, _, _ in self?.refresh() } .store(in: &cancellables) refresh() } @@ -43,7 +43,7 @@ final class NotchStatusItemController { button.image = statusIcon button.imagePosition = .imageOnly button.toolTip = statusToolTip - button.setAccessibilityLabel("ADE Attention Center, \(statusToolTip)") + button.setAccessibilityLabel("ADE Activity, \(statusToolTip)") } private var statusIcon: NSImage { @@ -56,7 +56,7 @@ final class NotchStatusItemController { // an invisible, unclickable gap. return NSImage( systemSymbolName: "app.dashed", - accessibilityDescription: "ADE Attention Center" + accessibilityDescription: "ADE Activity" ) ?? NSImage() } @@ -79,10 +79,14 @@ final class NotchStatusItemController { NSBezierPath(ovalIn: badgeRect).fill() image.unlockFocus() image.isTemplate = false - image.accessibilityDescription = "ADE Attention Center" + image.accessibilityDescription = "ADE Activity" return image } + /// One hue per section, same table as every other Activity surface: amber + /// is "your move" and nothing else, blue is work in progress, emerald is + /// finished cleanly. Read from the account's counts rather than one selected + /// row, so the badge describes the whole account. private var statusBadgeColor: NSColor { if let status = model.statusPresentation, status.isProblem { switch status.tone { @@ -91,23 +95,24 @@ final class NotchStatusItemController { default: return .systemPurple } } - if model.items.contains(where: \.isAttention) { return .systemOrange } - if model.items.contains(where: { $0.phase == "running" || $0.phase == "starting" }) { - return .systemBlue - } - if model.items.isEmpty { return .systemGray } - return .systemGreen + let counts = model.counts + if counts.needsYou > 0 { return .systemOrange } + if counts.working > 0 { return .systemBlue } + if counts.done > 0 { return .systemGreen } + return .systemGray } private var statusToolTip: String { if let status = model.statusPresentation, status.isProblem { return [status.title, status.hint].compactMap { $0 }.joined(separator: " ") } - guard let item = model.selectedItem else { - return model.statusPresentation?.message ?? "No active attention items" - } - let presentation = item.presentation(hideDetails: model.settings.hideDetails) - return "\(item.statusLabel): \(presentation.title)" + let counts = model.counts + var parts: [String] = [] + if counts.needsYou > 0 { parts.append("\(counts.needsYou) need\(counts.needsYou == 1 ? "s" : "") you") } + if counts.working > 0 { parts.append("\(counts.working) working") } + if counts.done > 0 { parts.append("\(counts.done) done") } + guard parts.isEmpty else { return parts.joined(separator: " · ") } + return model.statusPresentation?.message ?? "All agents idle" } private var statusItemScreenFrame: NSRect? { diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchSurfaceView.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchSurfaceView.swift index da312669c..912836154 100644 --- a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchSurfaceView.swift +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchSurfaceView.swift @@ -3,7 +3,7 @@ import SwiftUI import ADEAttentionNotchCore /// ADE design tokens, mirrored from `apps/desktop/src/renderer/index.css` and -/// the Attention center's tone system. Values are duplicated rather than +/// the Activity pane's tone system. Values are duplicated rather than /// derived because the helper is a separate process with no access to the /// renderer stylesheet; keep them in step with the CSS custom properties named /// in each comment. @@ -151,7 +151,7 @@ struct NotchSurfaceView: View { case .compact, .prehover: compactContent case .peek: - peekContent + toastContent case .expanded: expandedContent case .attention: @@ -173,20 +173,16 @@ struct NotchSurfaceView: View { } } - /// Split around the hardware cutout: identity on the left ear, live status - /// on the right. Nothing is ever drawn under the cutout itself. + /// Split around the hardware cutout: the agents at work on the left ear, + /// the account's counts on the right. Nothing is ever drawn under the + /// cutout itself. private func physicalCompactContent(notchWidth: Double) -> some View { let reserved = min(size.width - 120, notchWidth + 14) let earWidth = max(64, (size.width - reserved) / 2) return HStack(spacing: 0) { HStack(spacing: 7) { Spacer(minLength: 0) - Text(compactIdentityLabel) - .font(.system(size: ADE.fsXs, weight: .semibold)) - .foregroundStyle(ADE.fg) - .lineLimit(1) - .truncationMode(.tail) - ProviderMark(item: item, status: status, diameter: 18, active: isMarkActive, reducedMotion: reduceMotion) + compactIdentityCluster } .padding(.leading, 10) .padding(.trailing, 7) @@ -209,99 +205,181 @@ struct NotchSurfaceView: View { private var floatingCompactContent: some View { HStack(spacing: 8) { - ProviderMark(item: item, status: status, diameter: 18, active: isMarkActive, reducedMotion: reduceMotion) - // The identity is the only elastic element: it truncates so the - // status never collapses to an ellipsis. - Text(compactIdentityLabel) - .font(.system(size: ADE.fsSm, weight: .semibold)) - .foregroundStyle(ADE.fg) - .lineLimit(1) - .truncationMode(.tail) + compactIdentityCluster Spacer(minLength: 4) + if showsTicker { + NotchTickerView(items: model.tickerItems, hideDetails: model.settings.hideDetails) + .frame(maxWidth: 150) + } compactStatusCluster } .padding(.horizontal, 13) .frame(height: CGFloat(size.height)) } - /// Status is short, fixed, and always fully legible. + /// Up to three agent marks. With N sessions running, one item's name and + /// elapsed time is a lie about the other N-1 — the marks say "these are the + /// agents at work" without claiming to be all of them. + private var compactIdentityCluster: some View { + let leading = model.leadingItems + return HStack(spacing: leading.isEmpty ? 7 : -4) { + if leading.isEmpty { + ProviderMark( + item: nil, + status: status, + diameter: 18, + active: false, + reducedMotion: reduceMotion + ) + Text(status?.compactLabel ?? "ADE") + .font(.system(size: ADE.fsXs, weight: .semibold)) + .foregroundStyle(ADE.fg) + .lineLimit(1) + .truncationMode(.tail) + } else { + ForEach(leading) { leadingItem in + ProviderMark( + item: leadingItem, + status: nil, + diameter: 16, + active: leadingItem.isAttention, + reducedMotion: reduceMotion + ) + .overlay { + RoundedRectangle(cornerRadius: 16 * 0.3, style: .continuous) + .stroke(hasPhysicalNotch ? Color.black : ADE.bg, lineWidth: 1.4) + } + } + } + } + .accessibilityHidden(true) + } + + /// The account's shape, not one row's: `● 5` live and `⚠ 2 need you`. Short, + /// fixed, and always fully legible. private var compactStatusCluster: some View { - HStack(spacing: 5) { + let counts = model.counts + let liveCount = counts.working + counts.needsYou + return HStack(spacing: 6) { if status?.isProblem == true, item != nil { // Items are still showing, but they may be stale. Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 7.5, weight: .bold)) .foregroundStyle(notchToneColor(status?.tone ?? .amber)) } - Circle() - .fill(toneColor) - .frame(width: 5, height: 5) - .shadow(color: toneColor.opacity(0.6), radius: reduceMotion ? 0 : 2) - Text(compactStatusLabel) - .font(.system(size: ADE.fs2xs, weight: .semibold)) - .foregroundStyle(toneColor) - .lineLimit(1) - .truncationMode(.tail) - if let item { - ElapsedTimeLabel(isoDate: item.occurredAt) - .fixedSize() + if liveCount == 0 { + Text(compactStatusLabel) + .font(.system(size: ADE.fs2xs, weight: .semibold)) + .foregroundStyle(toneColor) + .lineLimit(1) + .truncationMode(.tail) + } else { + CountChip( + symbol: "circle.fill", + symbolSize: 5, + text: "\(liveCount)", + tone: notchToneColor(.blue), + pulses: !reduceMotion && counts.working > 0 + ) + if counts.needsYou > 0 { + CountChip( + symbol: "exclamationmark.triangle.fill", + symbolSize: 8, + text: "\(counts.needsYou) need\(counts.needsYou == 1 ? "s" : "") you", + tone: notchToneColor(.amber), + pulses: false + ) + } } } .layoutPriority(1) + .accessibilityElement(children: .ignore) + .accessibilityLabel(countsAccessibilityLabel) } - // MARK: - Peek + // MARK: - Toast + // + // This is the old peek layout. Hover no longer opens it — a hover that grew + // into a card competed with the toast it looked identical to — so the 316×76 + // geometry now belongs to events, and to the short card a click opens when + // the tall panel is off. - private var peekContent: some View { - HStack(spacing: 11) { - ProviderMark(item: item, status: status, diameter: 26, active: isMarkActive, reducedMotion: reduceMotion) - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 8) { - Text(peekTitle) - .font(.system(size: ADE.fsMd, weight: .semibold)) - .foregroundStyle(ADE.fg) - .lineLimit(1) - Spacer(minLength: 4) - Text(peekStatusLabel) - .font(.system(size: ADE.fs2xs, weight: .bold)) - .foregroundStyle(toneColor) - .lineLimit(1) - } - if let progress = itemPresentation?.planProgress, progress.total > 0 { - PlanProgressBar(progress: progress, tone: toneColor) - } else { - Text(peekSubtitle) - .font(.system(size: ADE.fsXs, weight: .medium)) - .foregroundStyle(ADE.secondaryFg) - .lineLimit(1) + @ViewBuilder + private var toastContent: some View { + if let toast = model.toastPresentation { + let tone = notchToneColor(toast.resolvedTone) + HStack(spacing: 11) { + ToastGlyph(treatment: toast.treatment, tone: tone, reducedMotion: reduceMotion) + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 8) { + Text(toast.title) + .font(.system(size: ADE.fsMd, weight: .semibold)) + .foregroundStyle(ADE.fg) + .lineLimit(1) + Spacer(minLength: 4) + Text(toastStatusLabel(for: toast)) + .font(.system(size: ADE.fs2xs, weight: .bold)) + .foregroundStyle(tone) + .lineLimit(1) + } + if let progress = itemPresentation?.planProgress, + progress.total > 0, + model.activeToast == nil { + PlanProgressBar(progress: progress, tone: tone) + } else if let subtitle = toast.subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: ADE.fsXs, weight: .medium)) + .foregroundStyle(ADE.secondaryFg) + .lineLimit(1) + } } } + .padding(.horizontal, 15) + .padding(.top, 9) + } + } + + /// The phase the toast is about, or the treatment's own word when it is not + /// tied to a row that is still on screen. + private func toastStatusLabel(for toast: AttentionToast) -> String { + if let itemId = toast.itemId, + let match = model.items.first(where: { $0.id == itemId }) { + return match.statusLabel + } + switch toast.treatment { + case .celebration: return "Merged" + case .success: return "Done" + case .alert: return "Needs you" + case .info: return status?.compactLabel ?? "Update" } - .padding(.horizontal, 15) - .padding(.top, 9) } // MARK: - Expanded + /// A scrolling list of every row the frame carried, filed under the same + /// three headings as the desktop popover. The pager it replaced showed one + /// card at a time, which was unusable the moment the feed went account-wide. private var expandedContent: some View { VStack(spacing: 0) { expandedHeader Rectangle().fill(ADE.hairline).frame(height: 0.8) // Only a banner when items are still on screen and may be stale; // with no items the body below already carries the same copy. - if let status, status.isProblem, item != nil { + if let status, status.isProblem, !model.items.isEmpty { StatusBanner(status: status) Rectangle().fill(ADE.hairline).frame(height: 0.8) } - if let item { - expandedItemBody(item: item) - Spacer(minLength: 4) - actionBar - .padding(.horizontal, 15) - .padding(.bottom, 14) - } else if let status { - StatusBody(status: status) + if model.items.isEmpty { + if let status { + StatusBody(status: status) + } else { + AllClearBody() + } + } else { + expandedList } + Rectangle().fill(ADE.hairline).frame(height: 0.8) + expandedFooter } } @@ -309,7 +387,7 @@ struct NotchSurfaceView: View { HStack(spacing: 10) { AttentionGlyph(tone: surfaceTone) VStack(alignment: .leading, spacing: 2) { - Text("ADE Attention Center") + Text("Activity") .font(.system(size: ADE.fsMd, weight: .semibold)) .foregroundStyle(ADE.fg) Text(accountScopeLabel) @@ -317,91 +395,80 @@ struct NotchSurfaceView: View { .foregroundStyle(ADE.mutedFg) } Spacer(minLength: 8) - if model.items.count > 1 { - navigationControls + Button { + model.openSettings() + } label: { + Image(systemName: "gearshape") } + .buttonStyle(NotchIconButtonStyle()) + .accessibilityLabel("Activity settings") + .accessibilityHint("Opens Activity settings in ADE") } .padding(.horizontal, 16) .padding(.top, 12) .padding(.bottom, 12) } - private func expandedItemBody(item: AttentionItem) -> some View { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .top, spacing: 10) { - VStack(alignment: .leading, spacing: 3) { - Text(itemPresentation?.title ?? "ADE attention") - .font(.system(size: ADE.fsLg, weight: .semibold)) - .foregroundStyle(ADE.fg) - .lineLimit(2) - Text(itemPresentation?.scopeLabel ?? "Account-wide activity") - .font(.system(size: ADE.fsXs, weight: .medium)) - .foregroundStyle(ADE.mutedFg) - .lineLimit(1) - } - Spacer(minLength: 8) - PhasePill(label: item.statusLabel, tone: surfaceTone) + private var expandedList: some View { + let sections = model.sections + return ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { + expandedSection("Needs you", tone: .amber, items: sections.needsYou) + expandedSection("Working", tone: .blue, items: sections.working) + expandedSection("Done", tone: .emerald, items: sections.done) } + .padding(.bottom, 6) + } + .scrollIndicators(.automatic) + .frame(maxHeight: .infinity) + } - Text(model.visiblePreview) - .font(.system(size: ADE.fsSm, weight: .regular)) - .foregroundStyle(ADE.secondaryFg) - .lineLimit(2) - .frame(maxWidth: .infinity, alignment: .leading) - - if let progress = itemPresentation?.planProgress, progress.total > 0 { - VStack(alignment: .leading, spacing: 5) { - HStack(spacing: 8) { - Text(progress.current ?? "Plan progress") - .lineLimit(1) - Spacer(minLength: 4) - Text("\(progress.completed)/\(progress.total)") - .monospacedDigit() - } - .font(.system(size: ADE.fs2xs, weight: .medium)) - .foregroundStyle(ADE.mutedFg) - PlanProgressBar(progress: progress, tone: toneColor) - } - } else if let activity = itemPresentation?.recentActivity, !activity.isEmpty { - VStack(alignment: .leading, spacing: 4) { - ForEach(Array(activity.prefix(2).enumerated()), id: \.offset) { _, line in - HStack(alignment: .firstTextBaseline, spacing: 7) { - Circle() - .fill(toneColor.opacity(0.75)) - .frame(width: 3.5, height: 3.5) - Text(line) - .font(.system(size: ADE.fsXs, weight: .regular)) - .foregroundStyle(ADE.mutedFg) - .lineLimit(1) - } - } + @ViewBuilder + private func expandedSection( + _ label: String, + tone: NotchStatusTone, + items: [AttentionItem] + ) -> some View { + if !items.isEmpty { + Section { + ForEach(items) { sectionItem in + NotchActivityRow( + item: sectionItem, + hideDetails: model.settings.hideDetails, + selected: sectionItem.id == model.selectedItem?.id, + reducedMotion: reduceMotion, + onOpen: { model.open(sectionItem) }, + onDismiss: { model.dismiss(sectionItem) }, + onFocus: { model.focus(sectionItem) } + ) } + } header: { + SectionHeader(label: label, count: items.count, tone: tone) } } - .padding(.horizontal, 16) - .padding(.top, 13) } - private var actionBar: some View { + private var expandedFooter: some View { HStack(spacing: 8) { - if model.items.count > 1 { - Text("\(model.interaction.selectedIndex + 1) of \(model.items.count)") + if model.overflowCount > 0 { + Text("+\(model.overflowCount) more") .font(.system(size: ADE.fs2xs, weight: .medium)) .foregroundStyle(ADE.mutedFg) .monospacedDigit() - .padding(.leading, 2) } Spacer(minLength: 4) secondaryActionButtons Button { - model.openSelected() + model.openActivity() } label: { - Label("Open in ADE", systemImage: "arrow.up.forward") + Label("Open all in ADE", systemImage: "arrow.up.forward") .labelStyle(.titleAndIcon) } .buttonStyle(NotchButtonStyle(prominent: true)) - .accessibilityHint("Opens the exact agent or pull request in ADE") + .accessibilityHint("Opens Activity in ADE") } + .padding(.horizontal, 15) + .padding(.vertical, 11) } /// `model.navigationActions` already drops a plain `open`, which the @@ -417,42 +484,26 @@ struct NotchSurfaceView: View { } } - private var navigationControls: some View { - HStack(spacing: 4) { - 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()) - } - // MARK: - Attention / celebration private var attentionContent: some View { - VStack(alignment: .leading, spacing: 9) { + let toast = model.toastPresentation + let tone = toast.map { notchToneColor($0.resolvedTone) } ?? toneColor + return VStack(alignment: .leading, spacing: 9) { HStack(spacing: 10) { ProviderMark(item: item, status: status, diameter: 26, active: true, reducedMotion: reduceMotion) VStack(alignment: .leading, spacing: 2) { - Text(item?.statusLabel ?? status?.title ?? "Needs you") + Text(toast.map(toastStatusLabel(for:)) ?? item?.statusLabel ?? "Needs you") .font(.system(size: ADE.fs2xs, weight: .bold)) - .foregroundStyle(toneColor) - Text(itemPresentation?.title ?? "ADE needs your attention") + .foregroundStyle(tone) + Text(toast?.title ?? itemPresentation?.title ?? "Needs you") .font(.system(size: ADE.fsSm + 1, weight: .semibold)) .foregroundStyle(ADE.fg) .lineLimit(1) } Spacer(minLength: 4) } - Text(model.visiblePreview) + Text(toast?.subtitle ?? model.visiblePreview) .font(.system(size: ADE.fsXs, weight: .regular)) .foregroundStyle(ADE.secondaryFg) .lineLimit(2) @@ -482,10 +533,10 @@ struct NotchSurfaceView: View { .font(.system(size: 26, weight: .semibold)) .symbolRenderingMode(.palette) .foregroundStyle(ADE.bg, notchToneColor(.emerald)) - Text("Merged") + Text(model.activeToast.map(toastStatusLabel(for:)) ?? "Merged") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(ADE.fg) - Text(itemPresentation?.celebrationTitle ?? "Pull request merged") + Text(model.toastPresentation?.title ?? itemPresentation?.celebrationTitle ?? "Pull request merged") .font(.system(size: ADE.fsXs, weight: .medium)) .foregroundStyle(ADE.mutedFg) .lineLimit(1) @@ -566,47 +617,54 @@ struct NotchSurfaceView: View { item?.isAttention == true || state == .prehover || state == .peek } - // MARK: - Copy - - private var compactIdentityLabel: String { - itemPresentation?.compactIdentity ?? "ADE" + /// The pinned strip is the only mode that keeps a bar on screen at rest, so + /// it is the only one with anywhere to run a ticker. + private var showsTicker: Bool { + model.settings.tickerEnabled + && model.settings.revealMode == .minimal + && !reduceMotion + && !model.tickerItems.isEmpty } + // MARK: - Copy + /// The canonical phase vocabulary from the renderer; no shortened synonyms. + /// Only used when the account has nothing live to count. private var compactStatusLabel: String { - item?.statusLabel ?? status?.compactLabel ?? "Ready" - } - - private var peekTitle: String { - itemPresentation?.title ?? status?.title ?? "ADE Attention Center" + if model.counts.done > 0 { return "\(model.counts.done) done" } + return status?.compactLabel ?? "All clear" } - private var peekSubtitle: String { - item == nil ? (status?.message ?? "ADE is ready") : model.visiblePreview - } - - private var peekStatusLabel: String { - item?.statusLabel ?? status?.compactLabel ?? "Ready" + private var countsAccessibilityLabel: String { + let counts = model.counts + var parts: [String] = [] + if counts.needsYou > 0 { + parts.append("\(counts.needsYou) need\(counts.needsYou == 1 ? "s" : "") you") + } + if counts.working > 0 { parts.append("\(counts.working) working") } + if counts.done > 0 { parts.append("\(counts.done) done") } + return parts.isEmpty ? "All agents idle" : parts.joined(separator: ", ") } private var accountScopeLabel: String { if let status, model.items.isEmpty { - return status.isProblem ? "Account attention unavailable" : "Account-wide activity" + return status.isProblem ? "Activity unavailable" : "Account-wide activity" } if model.settings.hideDetails { return "Account-wide activity" } - return attentionScopeSummary( - itemCount: model.items.count, - projectCount: Set(model.items.map(\.project.projectId)).count, - machineCount: Set(model.items.map(\.machine.machineKey)).count - ) + let counts = model.counts + return [ + attentionPluralized(counts.total, "session"), + "\(counts.machinesOnline)/\(counts.machinesTotal) machines online", + ].joined(separator: " · ") } private var accessibilitySummary: String { + if state == .expanded { return "Activity. \(countsAccessibilityLabel)" } if let presentation = itemPresentation { return presentation.accessibilitySummary } if let status { return "\(status.title). \(status.message)" } - return "ADE Attention Center" + return "ADE Activity" } private var accessibilityHint: String { @@ -614,8 +672,8 @@ struct NotchSurfaceView: View { return "Press Escape to close" } return model.settings.expandedPanelEnabled - ? "Click to expand ADE Attention Center" - : "Click to preview ADE Attention Center" + ? "Click to open Activity" + : "Click to preview Activity" } } @@ -787,6 +845,292 @@ private struct ProviderMark: View { } } +/// The Swift mirror of the renderer's compact `ActivityCard`: provider mark, +/// status dot + label + elapsed, title, lane, machine. Same anatomy and the +/// same one-hue-one-meaning table, so a row reads identically in the notch and +/// in the desktop popover. +private struct NotchActivityRow: View { + let item: AttentionItem + let hideDetails: Bool + let selected: Bool + let reducedMotion: Bool + let onOpen: () -> Void + let onDismiss: () -> Void + let onFocus: () -> Void + + @State private var hovering = false + + var body: some View { + let presentation = item.presentation(hideDetails: hideDetails) + let tone = notchStatusColor(for: item.phase) + Button(action: onOpen) { + HStack(alignment: .top, spacing: 9) { + ProviderMark( + item: item, + status: nil, + diameter: 20, + active: item.isAttention && !reducedMotion, + reducedMotion: reducedMotion + ) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(presentation.title) + .font(.system(size: ADE.fsSm, weight: .medium)) + .foregroundStyle(ADE.fg) + .lineLimit(1) + Spacer(minLength: 4) + Circle() + .fill(tone) + .frame(width: 4.5, height: 4.5) + Text(item.statusLabel) + .font(.system(size: ADE.fs2xs, weight: .semibold)) + .foregroundStyle(tone) + .lineLimit(1) + // `statusSince` is immutable for the life of a phase; + // `occurredAt` is the honest approximation while a + // publisher predates it. + ElapsedTimeLabel(isoDate: item.elapsedAnchor) + .fixedSize() + } + HStack(spacing: 6) { + Text(laneLabel) + .font(.system(size: ADE.fsXs, weight: .medium)) + .foregroundStyle(ADE.mutedFg) + .lineLimit(1) + if !presentation.preview.isEmpty { + Text("·").foregroundStyle(ADE.mutedFg.opacity(0.5)) + Text(presentation.preview) + .font(.system(size: ADE.fsXs, weight: .regular)) + .italic() + .foregroundStyle(ADE.secondaryFg.opacity(0.85)) + .lineLimit(1) + } + Spacer(minLength: 4) + MachineChip(machine: item.machine, hideDetails: hideDetails) + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 7) + .frame(maxWidth: .infinity, alignment: .leading) + .background(rowBackground) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + // An offline machine's rows are last-known state, not observed state. + .opacity(item.machine.online ? 1 : 0.55) + .onHover { inside in + hovering = inside + if inside { onFocus() } + } + .overlay(alignment: .trailing) { + if hovering { + Button(action: onDismiss) { + Image(systemName: "xmark") + } + .buttonStyle(NotchIconButtonStyle()) + .padding(.trailing, 6) + .accessibilityLabel("Dismiss \(presentation.title)") + } + } + .contextMenu { + Button("Open in ADE", action: onOpen) + Button("Dismiss", action: onDismiss) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(presentation.accessibilitySummary) + .accessibilityAddTraits(.isButton) + } + + private var laneLabel: String { + if hideDetails { return "Private" } + let lane = item.laneName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return lane.isEmpty ? item.project.name : lane + } + + @ViewBuilder + private var rowBackground: some View { + if selected || hovering { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(.white.opacity(hovering ? 0.06 : 0.035)) + .padding(.horizontal, 8) + } + } +} + +/// Neutral by design: amber means "your move" everywhere in Activity, so a +/// machine chip may never borrow it for identity. +private struct MachineChip: View { + let machine: AttentionMachine + let hideDetails: Bool + + var body: some View { + if hideDetails { + EmptyView() + } else { + HStack(spacing: 3) { + Image(systemName: portable ? "laptopcomputer" : "desktopcomputer") + .font(.system(size: 8, weight: .medium)) + Text(machine.name) + .font(.system(size: ADE.fs2xs, weight: .medium)) + .lineLimit(1) + } + .foregroundStyle(ADE.mutedFg.opacity(machine.online ? 0.75 : 0.4)) + .padding(.horizontal, 5) + .padding(.vertical, 1.5) + .background(.white.opacity(0.04), in: Capsule()) + } + } + + /// A read of the name, not a hardware fact — decoration either way. + private var portable: Bool { + machine.name.range( + of: "macbook|laptop|air|book", + options: [.regularExpression, .caseInsensitive] + ) != nil + } +} + +private struct SectionHeader: View { + let label: String + let count: Int + let tone: NotchStatusTone + + var body: some View { + HStack(spacing: 6) { + Text(label.uppercased()) + .font(.system(size: 8.5, weight: .heavy)) + .tracking(0.6) + .foregroundStyle(notchToneColor(tone)) + Text("\(count)") + .font(.system(size: 8.5, weight: .bold)) + .monospacedDigit() + .foregroundStyle(ADE.mutedFg) + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .padding(.top, 9) + .padding(.bottom, 5) + .frame(maxWidth: .infinity, alignment: .leading) + .background(ADE.bg.opacity(0.94)) + } +} + +/// The pinned strip's ticker: what each live agent is doing, one at a time. +/// Gated on the ticker setting and on reduced motion by its caller — a +/// cross-fading strip is exactly the kind of ambient movement that setting is +/// about. +private struct NotchTickerView: View { + let items: [AttentionItem] + let hideDetails: Bool + + private static let intervalSeconds: Double = 4 + + var body: some View { + TimelineView(.periodic(from: .now, by: Self.intervalSeconds)) { timeline in + if let current = item(at: timeline.date) { + Text(current.presentation(hideDetails: hideDetails).preview) + .font(.system(size: ADE.fs2xs, weight: .medium)) + .foregroundStyle(ADE.mutedFg) + .lineLimit(1) + .truncationMode(.tail) + .id(current.id) + .transition(.opacity) + .animation(.easeInOut(duration: 0.35), value: current.id) + } + } + .accessibilityHidden(true) + } + + private func item(at date: Date) -> AttentionItem? { + guard !items.isEmpty else { return nil } + let step = Int(date.timeIntervalSinceReferenceDate / Self.intervalSeconds) + return items[((step % items.count) + items.count) % items.count] + } +} + +/// `● 5` / `⚠ 2 need you` — the account's shape in the space of a phase label. +private struct CountChip: View { + let symbol: String + let symbolSize: CGFloat + let text: String + let tone: Color + let pulses: Bool + + var body: some View { + HStack(spacing: 3.5) { + TimelineView(.animation(minimumInterval: 1 / 20, paused: !pulses)) { timeline in + let pulse = pulses + ? (sin(timeline.date.timeIntervalSinceReferenceDate * 3.2) + 1) / 2 + : 0 + Image(systemName: symbol) + .font(.system(size: symbolSize, weight: .bold)) + .foregroundStyle(tone) + .shadow(color: tone.opacity(0.6), radius: 1 + pulse * 2) + } + Text(text) + .font(.system(size: ADE.fs2xs, weight: .semibold)) + .foregroundStyle(tone) + .monospacedDigit() + .lineLimit(1) + } + } +} + +private struct ToastGlyph: View { + let treatment: NotchToastTreatment + let tone: Color + let reducedMotion: Bool + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(tone.opacity(0.16)) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(tone.opacity(0.32), lineWidth: 0.8) + } + Image(systemName: symbolName) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(tone) + } + .frame(width: 26, height: 26) + .accessibilityHidden(true) + } + + private var symbolName: String { + switch treatment { + case .celebration: return "checkmark.seal.fill" + case .success: return "checkmark.circle.fill" + case .alert: return "exclamationmark.triangle.fill" + case .info: return "bell.fill" + } + } +} + +/// Nothing wrong, nothing running — said plainly rather than left blank, so an +/// empty panel never reads as a broken one. +private struct AllClearBody: View { + var body: some View { + VStack(spacing: 8) { + Spacer(minLength: 0) + Image(systemName: "moon.zzz") + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(ADE.mutedFg.opacity(0.7)) + Text("All agents idle.") + .font(.system(size: ADE.fsSm + 1, weight: .semibold)) + .foregroundStyle(ADE.fg) + Text("Nothing is running anywhere on your account.") + .font(.system(size: ADE.fsXs, weight: .regular)) + .foregroundStyle(ADE.secondaryFg) + .multilineTextAlignment(.center) + Spacer(minLength: 0) + } + .padding(.horizontal, 26) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + /// ADE's mark for the panel header: the accent gradient tile the app uses for /// its own identity, tinted by the current tone. private struct AttentionGlyph: View { diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchViewModel.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchViewModel.swift index dc0cd3963..5c56a6143 100644 --- a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchViewModel.swift +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchViewModel.swift @@ -3,30 +3,35 @@ import Combine import Foundation import ADEAttentionNotchCore +/// Transients are host-driven since the Activity revamp. +/// +/// The helper used to synthesise its own alerts by diffing item fingerprints, +/// which fired on every cosmetic republish. The renderer now owns that decision +/// (`useActivitySync`'s toast emitter) because only it can see the account's +/// delivery policy, the per-item 10-minute cooldown, and the global rate limit. +/// The machinery below is unchanged; the trigger moved. @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() @Published private(set) var availability: AttentionAvailability? + @Published private(set) var counts = AttentionCounts() + /// The event currently being shown as a transient, if any. Cleared when the + /// transient settles so a later hover cannot resurrect stale news. + @Published private(set) var activeToast: AttentionToast? 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? + private var deferredTransient: AttentionToast? private var snapshotCursor = AttentionSnapshotCursor() var selectedItem: AttentionItem? { @@ -34,6 +39,24 @@ final class NotchViewModel: ObservableObject { return items[interaction.selectedIndex] } + /// The panel's three sections, filed exactly as the desktop popover files + /// them. Recomputed from `items` rather than cached: the list is capped at + /// the host's 48-row projection, so this is a trivial pass. + var sections: NotchActivitySections { notchActivitySections(items) } + + /// Rows the hover strip's avatars are drawn from: the highest-priority + /// work, which is what someone glancing at the notch is looking for. + var leadingItems: [AttentionItem] { Array(sections.live.prefix(3)) } + + /// What the pinned ticker cycles. Empty means the strip stays still. + var tickerItems: [AttentionItem] { + guard settings.tickerEnabled, settings.revealMode == .minimal else { return [] } + return Array(sections.live.prefix(8)) + } + + /// Rows the account has that this frame did not carry. + var overflowCount: Int { counts.overflow(shownItemCount: items.count) } + /// How far the user lets the surface grow, and what opens it. var policy: NotchPresentationPolicy { NotchPresentationPolicy(settings: settings) } @@ -73,6 +96,49 @@ final class NotchViewModel: ObservableObject { notchSecondaryActions(selectedItem?.actions ?? []) } + /// What the transient card shows. A live toast wins; otherwise this is the + /// short card a click opens in compact mode, so the layout is never empty. + var toastPresentation: AttentionToast? { + if let activeToast { + guard !settings.hideDetails else { + return AttentionToast( + itemId: activeToast.itemId, + eventKind: activeToast.eventKind, + treatment: activeToast.treatment, + title: activeToast.itemId.flatMap { id in + items.first(where: { $0.id == id })? + .presentation(hideDetails: true).title + } ?? "ADE update", + subtitle: activeToast.itemId.flatMap { id in + items.first(where: { $0.id == id })?.privacyPreview + }, + tone: activeToast.tone, + durationMs: activeToast.durationMs + ) + } + return activeToast + } + guard let item = selectedItem else { + guard let status = statusPresentation else { return nil } + return AttentionToast( + eventKind: "status", + treatment: status.isProblem ? .alert : .info, + title: status.title, + subtitle: status.message, + tone: status.tone.rawValue + ) + } + let presentation = item.presentation(hideDetails: settings.hideDetails) + return AttentionToast( + itemId: item.id, + eventKind: item.eventKind, + treatment: item.isAttention ? .alert : .info, + title: presentation.title, + subtitle: presentation.preview, + tone: notchStatusTone(for: item.phase).rawValue + ) + } + func handle(_ input: NotchInput) { switch input { case .snapshot(let snapshot): @@ -82,6 +148,8 @@ final class NotchViewModel: ObservableObject { setVisible(settings.enabled && hostVisibilityRequested) applyPresentationPolicy() requestReanchor() + case .toast(let toast): + present(toast) case .visibility(let visible): hostVisibilityRequested = visible setVisible(settings.enabled && visible) @@ -89,6 +157,8 @@ final class NotchViewModel: ObservableObject { requestReanchor() case .quit: requestQuit() + case .ignored: + break } } @@ -96,11 +166,14 @@ final class NotchViewModel: ObservableObject { let acceptance = snapshotCursor.accept(snapshot) guard acceptance != .rejectedStale else { return } if case .accepted(resetPresentationState: true) = acceptance { - fingerprintsById.removeAll() + // An account switch: news from the previous account may not be + // waiting to interrupt the new one. deferredTransient = nil + activeToast = nil transientTask?.cancel() } availability = snapshot.availability + counts = snapshot.resolvedCounts() let focusedItemId = pointerInside ? hoveredItemId : selectedItem?.id var deduplicated: [String: AttentionItem] = [:] for item in snapshot.items where item.contractVersion == 1 { @@ -110,9 +183,6 @@ final class NotchViewModel: ObservableObject { 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 @@ -124,45 +194,36 @@ final class NotchViewModel: ObservableObject { } interaction = next - guard !sorted.isEmpty else { - transientTask?.cancel() - deferredTransient = nil - hoveredItemId = nil - // Draining to zero is not a reason to yank the surface out from - // under the pointer or out of a panel the user opened: those states - // now render the empty/error copy instead. - if interaction.presentation == .attention || interaction.presentation == .celebration { - var settled = interaction - settled.finishTransient(pointerInside: pointerInside, policy: policy) - interaction = settled - } - return + guard sorted.isEmpty else { return } + transientTask?.cancel() + deferredTransient = nil + activeToast = nil + hoveredItemId = nil + // Draining to zero is not a reason to yank the surface out from under + // the pointer or out of a panel the user opened: those states render + // the empty/error copy instead. + if interaction.presentation == .attention || interaction.presentation == .celebration { + var settled = interaction + settled.finishTransient(pointerInside: pointerInside, policy: policy) + interaction = settled } + } - 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() + /// Shows one event. Celebrations honour the account's celebrations setting; + /// everything else rides the alert layout. A toast that arrives while the + /// pointer is on the surface waits rather than yanking the content out from + /// under it. + func present(_ toast: AttentionToast) { + if toast.treatment == .celebration, !settings.celebrationsEnabled { return } + if pointerInside { + deferredTransient = toast return } - - if let attention = changed.first(where: \.isAttention) { - if pointerInside { - deferredTransient = .attention(itemId: attention.id) - return - } - selectItem(id: attention.id) - beginAttention() - } + if let itemId = toast.itemId { selectItem(id: itemId) } + begin(toast) } func pointerChanged(isInside: Bool) { - peekTask?.cancel() closeTask?.cancel() if isInside { @@ -170,18 +231,8 @@ final class NotchViewModel: ObservableObject { pointerInside = true hoveredItemId = selectedItem?.id var next = interaction - let token = next.pointerEntered(hasItems: hasPresentableContent, policy: policy) + next.pointerEntered(hasItems: hasPresentableContent, policy: policy) interaction = next - // Nothing to schedule when the pointer is not allowed to open the - // peek: the delayed task would only ever be a no-op. - guard policy.allowsHoverReveal else { return } - 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 @@ -198,8 +249,8 @@ final class NotchViewModel: ObservableObject { } func toggleExpanded() { - peekTask?.cancel() transientTask?.cancel() + activeToast = nil var next = interaction next.explicitToggle(hasItems: hasPresentableContent, policy: policy) interaction = next @@ -212,17 +263,20 @@ final class NotchViewModel: ObservableObject { interaction = next } - func navigate(delta: Int) { - var next = interaction - next.navigate(delta: delta, itemCount: items.count) - interaction = next - if pointerInside { - hoveredItemId = selectedItem?.id - } + /// Focus a row the pointer is over, so "Open in ADE" and the tooltip agree + /// with what the user is looking at. The pager it replaced is gone: the + /// panel is a scrolling list now, not one card at a time. + func focus(_ item: AttentionItem) { + selectItem(id: item.id) + if pointerInside { hoveredItemId = item.id } } func openSelected() { guard let item = selectedItem else { return } + open(item) + } + + func open(_ item: AttentionItem) { emit(NotchOutput( type: "open", itemId: item.id, @@ -231,6 +285,20 @@ final class NotchViewModel: ObservableObject { )) } + /// Asks the host to file the row away. The helper never mutates the feed + /// itself — the next snapshot is what removes the row. + func dismiss(_ item: AttentionItem) { + emit(NotchOutput( + type: "dismiss_item", + itemId: item.id, + destination: item.destination + )) + } + + func openSettings() { + emit(NotchOutput(type: "open_settings")) + } + func openFor(_ action: AttentionAction) { guard let item = selectedItem else { return } emit(NotchOutput( @@ -242,7 +310,9 @@ final class NotchViewModel: ObservableObject { )) } - func openAttentionCenter() { + /// The wire name stays `open_center`: the host routes on it and the surface + /// only renamed what it calls the destination. + func openActivity() { emit(NotchOutput(type: "open_center")) } @@ -260,60 +330,52 @@ final class NotchViewModel: ObservableObject { } private func setVisible(_ visible: Bool) { - peekTask?.cancel() closeTask?.cancel() transientTask?.cancel() pointerInside = false hoveredItemId = nil deferredTransient = nil + activeToast = nil var next = interaction next.setVisible(visible) interaction = next } - private func beginAttention() { + private func begin(_ toast: AttentionToast) { transientTask?.cancel() // The cue still fires in compact/manual modes: the user asked the // surface to stay small, not to stop telling them something needs them. if settings.soundsEnabled { - NSSound(named: "Glass")?.play() + NSSound(named: toast.treatment == .celebration ? "Hero" : "Glass")?.play() } guard policy.allowsAutomaticReveal else { return } + activeToast = toast var next = interaction - next.setAttention(policy: policy) - interaction = next - 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, policy: self.policy) - self.interaction = finished - } - } - - private func beginCelebration() { - transientTask?.cancel() - if settings.soundsEnabled { - NSSound(named: "Hero")?.play() + if toast.treatment == .celebration { + next.setCelebration(policy: policy) + } else { + next.setAttention(policy: policy) } - guard policy.allowsAutomaticReveal else { return } - var next = interaction - next.setCelebration(policy: policy) interaction = next + let durationMs = toast.resolvedDurationMs transientTask = Task { [weak self] in - try? await Task.sleep(for: .milliseconds(1_650)) + try? await Task.sleep(for: .milliseconds(durationMs)) guard !Task.isCancelled, let self else { return } var finished = self.interaction finished.finishTransient(pointerInside: self.pointerInside, policy: self.policy) self.interaction = finished + self.activeToast = nil } } /// Applies the current settings to whatever is already on screen. private func applyPresentationPolicy() { + // Turning automatic reveal off mid-toast has to collapse what is on + // screen; otherwise the setting looks broken until the next event. if !policy.allowsAutomaticReveal { transientTask?.cancel() deferredTransient = nil + activeToast = nil } var next = interaction next.applyPolicy(policy) @@ -330,20 +392,11 @@ final class NotchViewModel: ObservableObject { 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() + // The row it was about may have drained while the pointer sat there. + if let itemId = deferredTransient.itemId, + !items.contains(where: { $0.id == itemId }) { + return } - } - - private func isRecent(_ value: String, within seconds: TimeInterval) -> Bool { - guard let date = parseAttentionDate(value) else { return false } - return abs(date.timeIntervalSinceNow) <= seconds + present(deferredTransient) } } diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/ProtocolTransport.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/ProtocolTransport.swift index 6cacef2db..77428b02f 100644 --- a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/ProtocolTransport.swift +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/ProtocolTransport.swift @@ -39,7 +39,7 @@ final class StandardIOTransport { data.append(0x0A) FileHandle.standardOutput.write(data) } catch { - let message = "ADE Attention Notch could not encode output: \(error)\n" + let message = "ADE Notch could not encode output: \(error)\n" FileHandle.standardError.write(Data(message.utf8)) } } diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/AttentionModels.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/AttentionModels.swift index 97bc82fbb..ec8aa9d6d 100644 --- a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/AttentionModels.swift +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/AttentionModels.swift @@ -211,10 +211,38 @@ public struct AttentionItem: Codable, Equatable, Sendable, Identifiable { public let actions: [AttentionAction] public let occurredAt: String public let updatedAt: String + /// Immutable for the life of a phase, so the row's elapsed ticker survives + /// the cosmetic republishes that churn `updatedAt` every poll. Absent from + /// publishers older than the Activity revamp — fall back to `occurredAt`. + public let statusSince: String? + /// `"signal" | "ambient" | "idle"`, decoded as a plain string so a tier + /// this build has never heard of degrades instead of costing us the item. + /// The wire name stays `activityTier`; `tier` is the local shorthand. + public let activityTier: String? public let seenAt: String? public let dismissedAt: String? public let expiresAt: String? + public var tier: String? { activityTier } + + /// Idle rows are disk-only roster history: quiet, never alerting, always + /// filed under Done no matter what phase they preserved. + public var isIdleTier: Bool { activityTier == "idle" } + + /// Only signal-tier rows may interrupt. Legacy items without a tier fall + /// back to the phase test the surface has always used. + public var isSignalTier: Bool { + guard let activityTier else { return isAttention } + return activityTier == "signal" + } + + /// What "Working 3s" counts from. `updatedAt` is deliberately not a + /// candidate: it churns on every cosmetic republish, so a ticker anchored + /// to it would reset itself every poll. + public var elapsedAnchor: String { + statusSince?.notchNonEmpty ?? occurredAt + } + public init( contractVersion: Int = 1, id: String, @@ -239,6 +267,8 @@ public struct AttentionItem: Codable, Equatable, Sendable, Identifiable { actions: [AttentionAction] = [], occurredAt: String, updatedAt: String, + statusSince: String? = nil, + activityTier: String? = nil, seenAt: String? = nil, dismissedAt: String? = nil, expiresAt: String? = nil @@ -266,6 +296,8 @@ public struct AttentionItem: Codable, Equatable, Sendable, Identifiable { self.actions = actions self.occurredAt = occurredAt self.updatedAt = updatedAt + self.statusSince = statusSince + self.activityTier = activityTier self.seenAt = seenAt self.dismissedAt = dismissedAt self.expiresAt = expiresAt @@ -348,7 +380,7 @@ public struct AttentionItemPresentation: Equatable, Sendable { } /// Mirrors the renderer's `AttentionTone` union so a phase reads as the same -/// colour in the notch as it does in the Attention center and header control. +/// colour in the notch as it does in the Activity pane and header control. public enum NotchStatusTone: String, Equatable, Sendable { case blue case amber @@ -360,7 +392,7 @@ public enum NotchStatusTone: String, Equatable, Sendable { } /// Mirrors `PHASE_PRESENTATION` in -/// `apps/desktop/src/renderer/components/attention/attentionPresentation.ts`. +/// `apps/desktop/src/renderer/components/activity/activityPresentation.ts`. public func notchStatusTone(for phase: String?) -> NotchStatusTone { switch phase { case "starting", "running", "open": @@ -435,7 +467,7 @@ public func notchStatusPresentation( guard itemCount == 0 else { return nil } return NotchStatusPresentation( title: availability?.title.notchNonEmpty ?? "All clear", - message: availability?.message.notchNonEmpty ?? "Nothing needs your attention.", + message: availability?.message.notchNonEmpty ?? "Nothing needs you.", hint: nil, compactLabel: "All clear", tone: .emerald, @@ -454,7 +486,7 @@ private func problemPresentation( let symbolName: String switch availability.state { case .degraded: - fallbackTitle = "Attention is out of sync" + fallbackTitle = "Activity is out of sync" compactLabel = "Reconnecting" tone = .amber symbolName = "antenna.radiowaves.left.and.right.slash" @@ -464,7 +496,7 @@ private func problemPresentation( tone = .amber symbolName = "person.crop.circle.badge.exclamationmark" case .unavailable: - fallbackTitle = "Attention is unavailable" + fallbackTitle = "Activity is unavailable" compactLabel = "Unavailable" tone = .red symbolName = "exclamationmark.triangle.fill" @@ -474,7 +506,7 @@ private func problemPresentation( tone = .red symbolName = "arrow.up.circle" case .unknown, .ready: - fallbackTitle = "Attention status unknown" + fallbackTitle = "Activity status unknown" compactLabel = "Degraded" tone = .amber symbolName = "questionmark.circle" @@ -482,7 +514,7 @@ private func problemPresentation( let fallbackMessage = itemCount > 0 ? "Showing the last state ADE received." - : "ADE can't reach your account attention stream." + : "ADE can't reach your account Activity stream." return NotchStatusPresentation( title: availability.title.notchNonEmpty ?? fallbackTitle, @@ -506,7 +538,7 @@ private func recoveryHint( case .retry: return "Retry from ADE to reconnect." case .signIn: - return "Sign in to ADE to restore account attention." + return "Sign in to ADE to restore account Activity." case .updateHost: return host.map { "Update ADE on \($0)." } ?? "Update ADE to continue." case .restartHost: @@ -593,6 +625,137 @@ public struct AttentionAvailability: Codable, Equatable, Sendable { public var isProblem: Bool { state != .ready } } +/// The whole account's shape, sent alongside a bounded projection of its items. +/// +/// Load-bearing: the host publishes only the top-priority slice (48 rows) to +/// stay inside the pipe's byte budget, so "5 working · 2 need you · 61 total" +/// can only be honest if the totals travel separately from the rows. +public struct AttentionCounts: Codable, Equatable, Sendable { + public let needsYou: Int + public let working: Int + public let done: Int + public let total: Int + public let machinesOnline: Int + public let machinesTotal: Int + + public init( + needsYou: Int = 0, + working: Int = 0, + done: Int = 0, + total: Int = 0, + machinesOnline: Int = 0, + machinesTotal: Int = 0 + ) { + self.needsYou = needsYou + self.working = working + self.done = done + self.total = total + self.machinesOnline = machinesOnline + self.machinesTotal = machinesTotal + } + + private enum CodingKeys: String, CodingKey { + case needsYou, working, done, total, machinesOnline, machinesTotal + } + + /// Totally decoding, like every other advisory block: a host that learns to + /// send a seventh count, or forgets one, must not cost us the snapshot. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + func count(_ key: CodingKeys) -> Int { + max(0, ((try? container.decodeIfPresent(Int.self, forKey: key)) ?? nil) ?? 0) + } + needsYou = count(.needsYou) + working = count(.working) + done = count(.done) + total = count(.total) + machinesOnline = count(.machinesOnline) + machinesTotal = count(.machinesTotal) + } + + /// How many rows the account has that this frame did not carry. + public func overflow(shownItemCount: Int) -> Int { + max(0, total - shownItemCount) + } +} + +/// Per-kind delight for an event that just happened, rendered as a transient +/// rather than a row. `celebration` and `alert` drive the two presentations the +/// surface already knows how to animate; `success` and `info` ride the alert +/// machinery with a calmer tone. +public enum NotchToastTreatment: String, Codable, Equatable, Sendable, CaseIterable { + case celebration + case success + case alert + case info + + /// A treatment this build has never heard of reads as ordinary news rather + /// than throwing — a decode failure here would be reported to the host as a + /// protocol error and latch the helper into "needs an update". + public init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = NotchToastTreatment(rawValue: raw) ?? .info + } + + /// Only a merge earns the confetti; everything else uses the alert layout. + public var presentation: NotchPresentationState { + self == .celebration ? .celebration : .attention + } + + public var defaultTone: NotchStatusTone { + switch self { + case .celebration, .success: return .emerald + case .alert: return .amber + case .info: return .blue + } + } + + /// Matches the transient timers the surface already runs. + public var defaultDurationMs: Int { + self == .celebration ? 1_650 : 5_000 + } +} + +public struct AttentionToast: Codable, Equatable, Sendable { + public let itemId: String? + public let eventKind: String + public let treatment: NotchToastTreatment + public let title: String + public let subtitle: String? + public let tone: String? + public let durationMs: Int? + + public init( + itemId: String? = nil, + eventKind: String, + treatment: NotchToastTreatment, + title: String, + subtitle: String? = nil, + tone: String? = nil, + durationMs: Int? = nil + ) { + self.itemId = itemId + self.eventKind = eventKind + self.treatment = treatment + self.title = title + self.subtitle = subtitle + self.tone = tone + self.durationMs = durationMs + } + + /// Host-chosen hue when it sent one, otherwise the treatment's own. + public var resolvedTone: NotchStatusTone { + tone.flatMap { NotchStatusTone(rawValue: $0) } ?? treatment.defaultTone + } + + /// Clamped so a drifted host cannot pin the surface open, or flash it so + /// briefly that it reads as a glitch. + public var resolvedDurationMs: Int { + guard let durationMs else { return treatment.defaultDurationMs } + return max(800, min(15_000, durationMs)) + } +} + public struct AttentionSnapshot: Codable, Equatable, Sendable { public let contractVersion: Int /// Revisions are monotonic only within one stream. Account switches and @@ -602,6 +765,10 @@ public struct AttentionSnapshot: Codable, Equatable, Sendable { public let generatedAt: String public let items: [AttentionItem] public let availability: AttentionAvailability? + /// The account's real totals, independent of how many rows this frame + /// carried. Absent from hosts older than the Activity revamp, in which case + /// the surface counts what it can see. + public let counts: AttentionCounts? public init( contractVersion: Int = 1, @@ -609,7 +776,8 @@ public struct AttentionSnapshot: Codable, Equatable, Sendable { revision: Int, generatedAt: String, items: [AttentionItem], - availability: AttentionAvailability? = nil + availability: AttentionAvailability? = nil, + counts: AttentionCounts? = nil ) { self.contractVersion = contractVersion self.streamId = streamId @@ -617,10 +785,11 @@ public struct AttentionSnapshot: Codable, Equatable, Sendable { self.generatedAt = generatedAt self.items = items self.availability = availability + self.counts = counts } private enum CodingKeys: String, CodingKey { - case contractVersion, streamId, revision, generatedAt, items, availability + case contractVersion, streamId, revision, generatedAt, items, availability, counts } public init(from decoder: Decoder) throws { @@ -635,6 +804,28 @@ public struct AttentionSnapshot: Codable, Equatable, Sendable { items = try container.decode([AttentionItem].self, forKey: .items) // Availability is advisory chrome. Never fail a snapshot over it. availability = (try? container.decodeIfPresent(AttentionAvailability.self, forKey: .availability)) ?? nil + counts = (try? container.decodeIfPresent(AttentionCounts.self, forKey: .counts)) ?? nil + } + + /// The counts the host sent, or an honest tally of the rows on hand when it + /// sent none. Never invents an overflow it cannot see. + public func resolvedCounts() -> AttentionCounts { + if let counts { return counts } + let sections = notchActivitySections(items) + var online = Set() + var machines = Set() + for item in items where item.dismissedAt == nil { + machines.insert(item.machine.machineKey) + if item.machine.online { online.insert(item.machine.machineKey) } + } + return AttentionCounts( + needsYou: sections.needsYou.count, + working: sections.working.count, + done: sections.done.count, + total: sections.total, + machinesOnline: online.count, + machinesTotal: machines.count + ) } } @@ -715,6 +906,11 @@ public struct NotchSettings: Codable, Equatable, Sendable { public var hideDetails: Bool public var celebrationsEnabled: Bool public var soundsEnabled: Bool + /// Whether an event may briefly open the surface by itself. The reveal mode + /// still wins: "click only" means only when I ask, in every case. + public var automaticRevealEnabled: Bool + /// Whether the pinned strip cycles what each live agent is doing. + public var tickerEnabled: Bool public init( enabled: Bool = false, @@ -723,7 +919,9 @@ public struct NotchSettings: Codable, Equatable, Sendable { preferredDisplayId: UInt32? = nil, hideDetails: Bool = true, celebrationsEnabled: Bool = true, - soundsEnabled: Bool = false + soundsEnabled: Bool = false, + automaticRevealEnabled: Bool = true, + tickerEnabled: Bool = true ) { self.enabled = enabled self.revealMode = revealMode @@ -732,11 +930,14 @@ public struct NotchSettings: Codable, Equatable, Sendable { self.hideDetails = hideDetails self.celebrationsEnabled = celebrationsEnabled self.soundsEnabled = soundsEnabled + self.automaticRevealEnabled = automaticRevealEnabled + self.tickerEnabled = tickerEnabled } private enum CodingKeys: String, CodingKey { case enabled, revealMode, expandedPanelEnabled, preferredDisplayId case hideDetails, celebrationsEnabled, soundsEnabled + case automaticRevealEnabled, tickerEnabled } /// Decoding is total. A host that predates the presentation keys keeps the @@ -758,6 +959,11 @@ public struct NotchSettings: Codable, Equatable, Sendable { ?? defaults.celebrationsEnabled soundsEnabled = ((try? container.decodeIfPresent(Bool.self, forKey: .soundsEnabled)) ?? nil) ?? defaults.soundsEnabled + automaticRevealEnabled = + ((try? container.decodeIfPresent(Bool.self, forKey: .automaticRevealEnabled)) ?? nil) + ?? defaults.automaticRevealEnabled + tickerEnabled = ((try? container.decodeIfPresent(Bool.self, forKey: .tickerEnabled)) ?? nil) + ?? defaults.tickerEnabled } } @@ -767,6 +973,8 @@ public struct NotchSettings: Codable, Equatable, Sendable { public enum NotchSettingsMenuAction: Equatable, Sendable { case setRevealMode(NotchRevealMode) case toggleExpandedPanel + case toggleAutomaticReveal + case toggleTicker case hide } @@ -780,6 +988,10 @@ public func applyingNotchSettingsMenuAction( next.revealMode = revealMode case .toggleExpandedPanel: next.expandedPanelEnabled.toggle() + case .toggleAutomaticReveal: + next.automaticRevealEnabled.toggle() + case .toggleTicker: + next.tickerEnabled.toggle() case .hide: next.enabled = false } @@ -789,15 +1001,18 @@ public func applyingNotchSettingsMenuAction( public enum NotchInput: Equatable, Sendable { case snapshot(AttentionSnapshot) case settings(NotchSettings) + case toast(AttentionToast) case visibility(Bool) case reanchor case quit + case ignored } private struct CommandEnvelope: Decodable { let type: String let snapshot: AttentionSnapshot? let settings: NotchSettings? + let toast: AttentionToast? let visible: Bool? } @@ -812,12 +1027,15 @@ public enum NotchInputDecoder { case "settings": guard let settings = envelope.settings else { throw NotchProtocolError.missingPayload("settings") } return .settings(settings) + case "toast": + guard let toast = envelope.toast else { throw NotchProtocolError.missingPayload("toast") } + return .toast(toast) 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) + default: return .ignored } } return .snapshot(try decoder.decode(AttentionSnapshot.self, from: data)) @@ -826,7 +1044,6 @@ public enum NotchInputDecoder { public enum NotchProtocolError: Error, Equatable { case missingPayload(String) - case unknownCommand(String) } public struct NotchOutput: Encodable, Equatable, Sendable { diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchGeometry.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchGeometry.swift index 9ba4daa42..bc86b2b50 100644 --- a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchGeometry.swift +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchGeometry.swift @@ -19,7 +19,12 @@ public struct NotchRect: Equatable, Sendable { } public struct NotchDisplayGeometry: Equatable, Sendable { - public static let panelSize = NotchSize(width: 720, height: 460) + /// The transparent host every surface state is drawn into, top-aligned. + /// Sized for the tallest state (the scrollable expanded panel at 440pt of + /// surface below the menu-bar band) with room to spare, so growing a state + /// never needs a second window. Verified against a 13" MacBook's 1440×900 + /// by `NotchGeometryTests.testExpandedSurfaceFitsUnderA13InchMenuBar`. + public static let panelSize = NotchSize(width: 760, height: 640) public let displayId: UInt32 public let frame: NotchRect diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchInteractionState.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchInteractionState.swift index c2ee8b440..dc99a4378 100644 --- a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchInteractionState.swift +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchInteractionState.swift @@ -18,28 +18,46 @@ public enum NotchPresentationState: String, Codable, Equatable, Sendable { public struct NotchPresentationPolicy: Equatable, Sendable { public let revealMode: NotchRevealMode public let expandedPanelEnabled: Bool + public let automaticRevealEnabled: Bool + public let tickerEnabled: Bool public static let `default` = NotchPresentationPolicy() - public init(revealMode: NotchRevealMode = .hover, expandedPanelEnabled: Bool = true) { + public init( + revealMode: NotchRevealMode = .hover, + expandedPanelEnabled: Bool = true, + automaticRevealEnabled: Bool = true, + tickerEnabled: Bool = true + ) { self.revealMode = revealMode self.expandedPanelEnabled = expandedPanelEnabled + self.automaticRevealEnabled = automaticRevealEnabled + self.tickerEnabled = tickerEnabled } public init(settings: NotchSettings) { self.init( revealMode: settings.revealMode, - expandedPanelEnabled: settings.expandedPanelEnabled + expandedPanelEnabled: settings.expandedPanelEnabled, + automaticRevealEnabled: settings.automaticRevealEnabled, + tickerEnabled: settings.tickerEnabled ) } - /// Only hover mode lets the pointer alone open the peek. + /// Hover mode is the only one where the pointer alone changes the surface, + /// and since the Activity revamp it stops at prehover: the old peek-on-hover + /// is gone and its layout now belongs to event toasts. public var allowsHoverReveal: Bool { revealMode == .hover } - /// All three user-selectable modes are manual. In particular, "Reveal on - /// hover" is literal: a needs-you update may change notification/status - /// state, but it cannot make the hidden surface appear by itself. - public var allowsAutomaticReveal: Bool { false } + /// An event may open the surface by itself when the user allows it — except + /// in "click only", which is literal: nothing but a click opens anything. + public var allowsAutomaticReveal: Bool { + automaticRevealEnabled && revealMode != .click + } + + /// The ticker is a property of the pinned strip, so it only ever runs in the + /// mode that keeps a strip on screen at rest. + public var showsTicker: Bool { tickerEnabled && revealMode == .minimal } /// Compact mode never grows past a short peek. Other modes may open the /// tall panel unless the user disabled it globally. @@ -76,6 +94,9 @@ public struct NotchInteractionState: Equatable, Sendable { public init() {} + /// Hover stops here. Before the Activity revamp a 145ms timer promoted this + /// to `.peek`; the peek layout is now the toast's, and a hover that grew + /// into a card competed with the toast it looks identical to. @discardableResult public mutating func pointerEntered( hasItems: Bool, @@ -91,11 +112,6 @@ public struct NotchInteractionState: Equatable, Sendable { 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 @@ -138,22 +154,15 @@ public struct NotchInteractionState: Equatable, Sendable { presentation = .celebration } + /// A toast always settles back to the compact bar. `.peek` is the toast's + /// own layout now, so landing there would leave a card on screen with + /// nothing left to say. public mutating func finishTransient( pointerInside: Bool, policy: NotchPresentationPolicy = .default ) { generation &+= 1 - // Settling under a pointer that is not allowed to reveal anything has - // to land on compact, not on the peek hover never opened. - presentation = (pointerInside && policy.allowsHoverReveal) ? .peek : .compact - } - - public mutating func navigate(delta: Int, itemCount: Int) { - guard itemCount > 0 else { - selectedIndex = 0 - return - } - selectedIndex = (selectedIndex + delta % itemCount + itemCount) % itemCount + presentation = (pointerInside && policy.allowsHoverReveal) ? .prehover : .compact } public mutating func select(index: Int, itemCount: Int) { @@ -236,6 +245,57 @@ public func sortedAttentionItems(_ items: [AttentionItem]) -> [AttentionItem] { } } +/// The priority-flat three, mirroring `activityPriority.ts` in the renderer so +/// the panel files a row exactly where the desktop popover files it. +public struct NotchActivitySections: Equatable, Sendable { + public let needsYou: [AttentionItem] + public let working: [AttentionItem] + public let done: [AttentionItem] + + public init(needsYou: [AttentionItem], working: [AttentionItem], done: [AttentionItem]) { + self.needsYou = needsYou + self.working = working + self.done = done + } + + public var total: Int { needsYou.count + working.count + done.count } + public var isEmpty: Bool { total == 0 } + + /// Rows still doing something, in priority order — what the ticker cycles + /// and what the hover strip's live dot counts. + public var live: [AttentionItem] { needsYou + working } +} + +/// Mirrors `activitySectionId` in `activityPriority.ts`, including its rule that +/// an idle roster row is quiet history regardless of the phase it preserved. +public func notchActivitySectionId(for item: AttentionItem) -> String { + if item.isIdleTier { return "done" } + let priority = phasePriorities[item.phase] ?? 99 + if priority <= (phasePriorities["blocked"] ?? 2) { return "needs-you" } + if priority <= (phasePriorities["stale"] ?? 4) { return "working" } + return "done" +} + +public func notchActivitySections(_ items: [AttentionItem]) -> NotchActivitySections { + var needsYou: [AttentionItem] = [] + var working: [AttentionItem] = [] + var done: [AttentionItem] = [] + for item in sortedAttentionItems(items) { + switch notchActivitySectionId(for: item) { + case "needs-you": needsYou.append(item) + case "working": working.append(item) + default: done.append(item) + } + } + // Idle roster history is the ambient tail even when its preserved phase has + // a numerically higher priority than a fresh completed outcome. + return NotchActivitySections( + needsYou: needsYou, + working: working, + done: done.filter { !$0.isIdleTier } + done.filter(\.isIdleTier) + ) +} + /// Height of the menu-bar band the hardware notch lives in. The surface's top /// `band` points sit *inside* that strip, so compact ends exactly on the /// hardware notch's bottom edge and expanded content starts just below it. @@ -276,7 +336,7 @@ public func notchSurfaceSize( case .compact: return NotchSize(width: 272, height: 34) case .prehover: return NotchSize(width: 282, height: 38) case .peek: return NotchSize(width: 316, height: 76) - case .expanded: return NotchSize(width: 396, height: 232) + case .expanded: return NotchSize(width: 420, height: 440) case .attention: return NotchSize(width: 336, height: 130) case .celebration: return NotchSize(width: 352, height: 150) } @@ -298,7 +358,7 @@ public func notchSurfaceSize( case .peek: return NotchSize(width: compactWidth + 10, height: band + 62) case .expanded: - return NotchSize(width: max(400, compactWidth + 10), height: band + 232) + return NotchSize(width: max(420, compactWidth + 10), height: band + 440) case .attention: return NotchSize(width: max(384, compactWidth + 10), height: band + 126) case .celebration: diff --git a/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchGeometryTests.swift b/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchGeometryTests.swift index 4782f28a0..04bc33dfb 100644 --- a/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchGeometryTests.swift +++ b/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchGeometryTests.swift @@ -105,6 +105,53 @@ final class NotchGeometryTests: XCTestCase { XCTAssertEqual(frame.maxY, status.y - 4) } + /// The panel grew to 760×640 for the scrolling Activity list. The surface + /// is top-aligned inside it, so what has to fit above the display's bottom + /// edge is the 440pt surface, not the 640pt transparent host — verified + /// here on the smallest Mac ADE supports, a 1440×900 13". + func testExpandedSurfaceFitsUnderA13InchMenuBar() { + XCTAssertEqual(NotchDisplayGeometry.panelSize, NotchSize(width: 760, height: 640)) + + let display = NotchRect(x: 0, y: 0, width: 1_440, height: 900) + let status = NotchRect(x: 1_390, y: 875, width: 24, height: 24) + let surface = notchSurfaceSize(presentation: .expanded, physicalNotchWidth: nil) + XCTAssertEqual(surface, NotchSize(width: 420, height: 440)) + + let frame = menuBarAnchoredPanelFrame( + statusItemFrame: status, + displayFrame: display, + surfaceSize: surface + ) + // Top of the surface sits just under the menu bar… + XCTAssertEqual(frame.maxY, status.y - 4) + // …and its bottom edge stays comfortably on screen. + XCTAssertGreaterThan(frame.maxY - surface.height, display.y) + XCTAssertGreaterThanOrEqual(frame.midX - surface.width / 2, display.x + 8) + XCTAssertLessThanOrEqual(frame.midX + surface.width / 2, display.maxX - 8) + + // Same on a notched 13" built-in, where the panel hangs from the very + // top of the display rather than from a status item. + let notched = NotchDisplayGeometry( + displayId: 1, + frame: display, + visibleFrame: NotchRect(x: 0, y: 0, width: 1_440, height: 875), + safeAreaTop: 34, + auxiliaryLeft: NotchRect(x: 0, y: 866, width: 630, height: 34), + auxiliaryRight: NotchRect(x: 810, y: 866, width: 630, height: 34), + isBuiltIn: true + ) + XCTAssertTrue(notched.hasPhysicalNotch) + let notchedSurface = notchSurfaceSize( + presentation: .expanded, + physicalNotchWidth: notched.physicalNotchWidth, + safeAreaTop: 34 + ) + XCTAssertEqual(notchedSurface.height, 474) + XCTAssertGreaterThan(notched.frame.maxY - notchedSurface.height, display.y) + XCTAssertLessThanOrEqual(notchedSurface.width, NotchDisplayGeometry.panelSize.width) + XCTAssertLessThanOrEqual(notchedSurface.height, NotchDisplayGeometry.panelSize.height) + } + private func geometry( safeAreaTop: Double, left: NotchRect?, diff --git a/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchInteractionStateTests.swift b/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchInteractionStateTests.swift index 0130c49f4..072325d9e 100644 --- a/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchInteractionStateTests.swift +++ b/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchInteractionStateTests.swift @@ -2,14 +2,19 @@ import XCTest @testable import ADEAttentionNotchCore final class NotchInteractionStateTests: XCTestCase { - func testStaleHoverGenerationCannotOpenPeekAfterPointerExit() { + /// Hover stops at prehover. The 145ms promotion to `.peek` is gone: that + /// layout belongs to event toasts now, and a hover that grew into a card + /// competed with the toast it looked identical to. + func testHoverStopsAtPrehoverAndNeverOpensTheToastLayout() { var state = NotchInteractionState() - let hoverGeneration = state.pointerEntered(hasItems: true) + state.pointerEntered(hasItems: true) XCTAssertEqual(state.presentation, .prehover) - state.pointerExited() - state.applyPeek(generation: hoverGeneration, pointerInside: true) + // Nothing else the hover flow can do promotes it further. + state.pointerEntered(hasItems: true) + XCTAssertEqual(state.presentation, .prehover) + state.pointerExited() XCTAssertEqual(state.presentation, .compact) } @@ -45,9 +50,8 @@ final class NotchInteractionStateTests: XCTestCase { XCTAssertEqual(NotchPresentationPolicy(settings: NotchSettings()), .default) var state = NotchInteractionState() - let token = state.pointerEntered(hasItems: true, policy: .default) - state.applyPeek(generation: token, pointerInside: true) - XCTAssertEqual(state.presentation, .peek) + state.pointerEntered(hasItems: true, policy: .default) + XCTAssertEqual(state.presentation, .prehover) } func testHoverModeIsVisuallyDormantOnlyWhileResting() { @@ -97,10 +101,8 @@ final class NotchInteractionStateTests: XCTestCase { for mode in [NotchRevealMode.click, .minimal] { let policy = NotchPresentationPolicy(revealMode: mode) var state = NotchInteractionState() - let token = state.pointerEntered(hasItems: true, policy: policy) + state.pointerEntered(hasItems: true, policy: policy) XCTAssertEqual(state.presentation, .compact, "\(mode) grew on hover") - state.applyPeek(generation: token, pointerInside: true) - XCTAssertEqual(state.presentation, .compact, "\(mode) peeked on hover") } } @@ -116,16 +118,65 @@ final class NotchInteractionStateTests: XCTestCase { } } - /// Presentation choices never let an event override the user's reveal - /// preference; hover means the pointer is what reveals the surface. - func testEveryModeSuppressesAlertAndCelebrationGrowth() { + /// Automatic reveal is a setting now, but "click only" still outranks it: + /// that mode is literal, and nothing but a click may open anything. + func testAutomaticRevealHonoursTheSettingAndDefersToClickOnlyMode() { for mode in NotchRevealMode.allCases { - let policy = NotchPresentationPolicy(revealMode: mode) + let allowed = NotchPresentationPolicy(revealMode: mode, automaticRevealEnabled: true) + XCTAssertEqual(allowed.allowsAutomaticReveal, mode != .click, "\(mode)") + + var alerting = NotchInteractionState() + alerting.setAttention(policy: allowed) + XCTAssertEqual(alerting.presentation, mode == .click ? .compact : .attention, "\(mode)") + + var celebrating = NotchInteractionState() + celebrating.setCelebration(policy: allowed) + XCTAssertEqual(celebrating.presentation, mode == .click ? .compact : .celebration, "\(mode)") + + // Turned off, no mode may grow the surface on its own. + let off = NotchPresentationPolicy(revealMode: mode, automaticRevealEnabled: false) + XCTAssertFalse(off.allowsAutomaticReveal, "\(mode)") + var suppressed = NotchInteractionState() + suppressed.setAttention(policy: off) + XCTAssertEqual(suppressed.presentation, .compact, "\(mode)") + suppressed.setCelebration(policy: off) + XCTAssertEqual(suppressed.presentation, .compact, "\(mode)") + } + } + + /// Turning automatic reveal off while a toast is on screen has to collapse + /// it; otherwise the setting looks broken until the toast expires. + func testTurningOffAutomaticRevealCollapsesAToastAlreadyOnScreen() { + for treatment in [NotchToastTreatment.alert, .celebration] { var state = NotchInteractionState() - state.setAttention(policy: policy) - XCTAssertEqual(state.presentation, .compact) - state.setCelebration(policy: policy) - XCTAssertEqual(state.presentation, .compact) + let on = NotchPresentationPolicy(revealMode: .hover, automaticRevealEnabled: true) + if treatment == .celebration { + state.setCelebration(policy: on) + } else { + state.setAttention(policy: on) + } + XCTAssertEqual(state.presentation, treatment.presentation) + + state.applyPolicy(NotchPresentationPolicy( + revealMode: .hover, + automaticRevealEnabled: false + )) + XCTAssertEqual(state.presentation, .compact, "\(treatment)") + } + } + + /// The ticker belongs to the pinned strip, the one mode that keeps a bar on + /// screen at rest. + func testTickerOnlyRunsInThePinnedMode() { + for mode in NotchRevealMode.allCases { + XCTAssertEqual( + NotchPresentationPolicy(revealMode: mode, tickerEnabled: true).showsTicker, + mode == .minimal, + "\(mode)" + ) + XCTAssertFalse( + NotchPresentationPolicy(revealMode: mode, tickerEnabled: false).showsTicker + ) } } @@ -148,12 +199,11 @@ final class NotchInteractionStateTests: XCTestCase { /// A hover-opened peek is not "open": clicking through one has to latch the /// surface rather than dismiss it. - func testClickingThroughAHoverPeekLatchesInsteadOfClosing() { + func testClickingThroughAHoverLatchesInsteadOfClosing() { let policy = NotchPresentationPolicy(revealMode: .hover, expandedPanelEnabled: false) var state = NotchInteractionState() - let token = state.pointerEntered(hasItems: true, policy: policy) - state.applyPeek(generation: token, pointerInside: true) - XCTAssertEqual(state.presentation, .peek) + state.pointerEntered(hasItems: true, policy: policy) + XCTAssertEqual(state.presentation, .prehover) XCTAssertFalse(state.isExplicitlyInteractive) state.explicitToggle(hasItems: true, policy: policy) @@ -178,14 +228,16 @@ final class NotchInteractionStateTests: XCTestCase { /// mode had already put on screen. func testSwitchingModesCollapsesSurfacesTheNewModeForbids() { var hovering = NotchInteractionState() - let token = hovering.pointerEntered(hasItems: true, policy: .default) - hovering.applyPeek(generation: token, pointerInside: true) + hovering.pointerEntered(hasItems: true, policy: .default) hovering.applyPolicy(NotchPresentationPolicy(revealMode: .click)) XCTAssertEqual(hovering.presentation, .compact) var alerting = NotchInteractionState() alerting.setAttention(policy: .default) - alerting.applyPolicy(NotchPresentationPolicy(revealMode: .minimal)) + alerting.applyPolicy(NotchPresentationPolicy( + revealMode: .minimal, + automaticRevealEnabled: false + )) XCTAssertEqual(alerting.presentation, .compact) var manuallyExpanded = NotchInteractionState() @@ -195,17 +247,23 @@ final class NotchInteractionStateTests: XCTestCase { XCTAssertTrue(manuallyExpanded.isExplicitlyInteractive) } - /// Settling out of an alert under a pointer that is not allowed to reveal - /// anything has to land on compact, not on a peek hover never opened. - func testTransientsSettleToCompactWhenHoverCannotReveal() { - var state = NotchInteractionState() - state.setAttention(policy: NotchPresentationPolicy(revealMode: .click)) - state.finishTransient(pointerInside: true, policy: NotchPresentationPolicy(revealMode: .click)) - XCTAssertEqual(state.presentation, .compact) - - let hoverToken = state.pointerEntered(hasItems: true, policy: .default) - state.applyPeek(generation: hoverToken, pointerInside: true) - XCTAssertEqual(state.presentation, .peek) + /// A toast always settles back to the bar. `.peek` is the toast's own + /// layout now, so landing there would leave a card on screen with nothing + /// left to say — under a hovering pointer it lands on prehover instead. + func testTransientsNeverSettleOntoTheToastLayout() { + for (mode, pointerInside, expected) in [ + (NotchRevealMode.click, true, NotchPresentationState.compact), + (.hover, true, .prehover), + (.hover, false, .compact), + (.minimal, true, .compact), + ] { + var state = NotchInteractionState() + let policy = NotchPresentationPolicy(revealMode: mode) + state.setAttention(policy: policy) + state.finishTransient(pointerInside: pointerInside, policy: policy) + XCTAssertEqual(state.presentation, expected, "\(mode) inside=\(pointerInside)") + XCTAssertNotEqual(state.presentation, .peek) + } } /// Turning the notch off entirely stays the strongest setting: it outranks @@ -221,6 +279,7 @@ final class NotchInteractionStateTests: XCTestCase { XCTAssertEqual(state.presentation, .compact) state.explicitToggle(hasItems: true, policy: policy) state.setAttention(policy: policy) + state.setCelebration(policy: policy) XCTAssertEqual(state.presentation, .compact, "\(mode) reappeared while off") } } @@ -244,14 +303,90 @@ final class NotchInteractionStateTests: XCTestCase { } } - func testNavigationWrapsInBothDirections() { + /// The pager is gone — the panel scrolls — so selection is only ever set by + /// pointing at a row, and only has to stay inside the list. + func testSelectionIsClampedToTheListInsteadOfPaged() { 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) + state.select(index: -4, itemCount: 3) + XCTAssertEqual(state.selectedIndex, 0) + state.select(index: 2, itemCount: 3) + state.clampSelection(itemCount: 1) + XCTAssertEqual(state.selectedIndex, 0) + state.clampSelection(itemCount: 0) + XCTAssertEqual(state.selectedIndex, 0) + } + + // MARK: - Activity sections + + /// The panel files a row exactly where the desktop popover files it, + /// including the rule that idle roster history is the ambient tail of Done + /// no matter what phase it preserved. + func testSectionsMirrorTheRendererPriorityFlatThree() { + let needsYou = sectionFixture(id: "needs", phase: "needs_you") + let failed = sectionFixture(id: "failed", phase: "failed") + let running = sectionFixture(id: "running", phase: "running") + let completed = sectionFixture(id: "completed", phase: "completed") + let idleButRunning = sectionFixture(id: "idle", phase: "running", tier: "idle") + + let sections = notchActivitySections([completed, running, idleButRunning, failed, needsYou]) + XCTAssertEqual(sections.needsYou.map(\.id), ["needs", "failed"]) + XCTAssertEqual(sections.working.map(\.id), ["running"]) + XCTAssertEqual(sections.done.map(\.id), ["completed", "idle"]) + XCTAssertEqual(sections.live.map(\.id), ["needs", "failed", "running"]) + XCTAssertEqual(sections.total, 5) + } + + /// A host that predates the counts block must not make the surface claim an + /// overflow it cannot see. + func testCountsFallBackToTheRowsOnHandWhenTheHostSendsNone() { + let snapshot = AttentionSnapshot( + revision: 1, + generatedAt: "2026-08-01T12:00:00Z", + items: [ + sectionFixture(id: "needs", phase: "needs_you"), + sectionFixture(id: "running", phase: "running"), + ] + ) + let counts = snapshot.resolvedCounts() + XCTAssertEqual(counts.needsYou, 1) + XCTAssertEqual(counts.working, 1) + XCTAssertEqual(counts.total, 2) + XCTAssertEqual(counts.overflow(shownItemCount: 2), 0) + + // With counts, the totals are the account's, not the frame's. + let projected = AttentionSnapshot( + revision: 2, + generatedAt: "2026-08-01T12:00:01Z", + items: [sectionFixture(id: "needs", phase: "needs_you")], + counts: AttentionCounts(needsYou: 3, working: 9, done: 49, total: 61) + ) + XCTAssertEqual(projected.resolvedCounts().total, 61) + XCTAssertEqual(projected.resolvedCounts().overflow(shownItemCount: 1), 60) + } + + private func sectionFixture( + id: String, + phase: String, + tier: String? = nil + ) -> 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: "Work", + preview: "Working", + privacyPreview: "Agent update", + destination: AttentionDestination(kind: "session", sessionId: "session-\(id)"), + occurredAt: "2026-08-01T12:00:00Z", + updatedAt: "2026-08-01T12:00:00Z", + activityTier: tier + ) } func testPhysicalSurfaceReservesHardwareAndSideEars() { diff --git a/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchProtocolTests.swift b/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchProtocolTests.swift index 0cefce962..8321d43ef 100644 --- a/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchProtocolTests.swift +++ b/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchProtocolTests.swift @@ -1,5 +1,6 @@ import XCTest @testable import ADEAttentionNotchCore +@testable import ADEAttentionNotch final class NotchProtocolTests: XCTestCase { func testDecodesRawSnapshotAndEnvelope() throws { @@ -396,6 +397,7 @@ final class NotchProtocolTests: XCTestCase { let status = try XCTUnwrap(notchStatusPresentation(availability: nil, itemCount: 0)) XCTAssertFalse(status.isProblem) XCTAssertEqual(status.title, "All clear") + XCTAssertEqual(status.message, "Nothing needs you.") XCTAssertEqual(status.compactLabel, "All clear") XCTAssertEqual(status.tone, .emerald) @@ -422,7 +424,7 @@ final class NotchProtocolTests: XCTestCase { XCTAssertTrue(hostAuthored.isProblem) XCTAssertEqual(hostAuthored.title, "Signed out") XCTAssertEqual(hostAuthored.message, "Your ADE session expired.") - XCTAssertEqual(hostAuthored.hint, "Sign in to ADE to restore account attention.") + XCTAssertEqual(hostAuthored.hint, "Sign in to ADE to restore account Activity.") let blankCopy = try XCTUnwrap(notchStatusPresentation( availability: AttentionAvailability(state: .incompatible, recovery: .updateHost, hostName: "Studio"), @@ -466,10 +468,271 @@ final class NotchProtocolTests: XCTestCase { XCTAssertEqual(AttentionAction(id: "open", kind: "open", label: "Open").navigationLabel, "Open in ADE") } + // MARK: - Activity revamp protocol additions + + /// The elapsed anchor and the tier are additive. A publisher that has them + /// is decoded exactly; one that does not still lands, and the surface falls + /// back to `occurredAt` rather than to `updatedAt`, which churns on every + /// cosmetic republish. + func testItemDecodesStatusSinceAndTierAndDegradesWithoutThem() throws { + let modern = """ + {"contractVersion":1,"revision":7,"generatedAt":"2026-08-01T12:00:00Z","items":[ + {"contractVersion":1,"id":"a","revision":1,"fingerprint":"f","kind":"agent", + "eventKind":"agent_running","phase":"running", + "machine":{"machineKey":"m","name":"Studio","online":true,"lastSeenAt":null}, + "project":{"projectId":"p","name":"ADE"},"title":"T","preview":"P", + "privacyPreview":"Agent update", + "destination":{"kind":"session","sessionId":"s"},"actions":[], + "occurredAt":"2026-08-01T11:00:00Z","updatedAt":"2026-08-01T12:00:00Z", + "statusSince":"2026-08-01T11:30:00Z","activityTier":"ambient", + "seenAt":null,"dismissedAt":null,"expiresAt":null}]} + """ + guard case .snapshot(let snapshot) = try NotchInputDecoder.decode(line: modern) else { + return XCTFail("expected a snapshot") + } + let item = try XCTUnwrap(snapshot.items.first) + XCTAssertEqual(item.statusSince, "2026-08-01T11:30:00Z") + XCTAssertEqual(item.tier, "ambient") + XCTAssertEqual(item.elapsedAnchor, "2026-08-01T11:30:00Z") + XCTAssertFalse(item.isSignalTier) + XCTAssertFalse(item.isIdleTier) + + let legacy = fixtureItem() + XCTAssertNil(legacy.statusSince) + XCTAssertNil(legacy.tier) + XCTAssertEqual(legacy.elapsedAnchor, legacy.occurredAt) + // Without a tier the surface falls back to the phase test it has always + // used, so a mixed-version fleet still files rows consistently. + XCTAssertEqual(fixtureItem(phase: "needs_you").isSignalTier, true) + XCTAssertEqual(legacy.isSignalTier, false) + + // A tier this build has never heard of is not a signal and not idle. + let drifted = fixtureItem(tier: "telepathic") + XCTAssertFalse(drifted.isSignalTier) + XCTAssertFalse(drifted.isIdleTier) + } + + func testSnapshotDecodesCountsAndSurvivesWithoutThem() throws { + let withCounts = """ + {"contractVersion":1,"revision":3,"generatedAt":"2026-08-01T12:00:00Z","items":[], + "counts":{"needsYou":2,"working":5,"done":54,"total":61, + "machinesOnline":1,"machinesTotal":3}} + """ + guard case .snapshot(let snapshot) = try NotchInputDecoder.decode(line: withCounts) else { + return XCTFail("expected a snapshot") + } + let counts = try XCTUnwrap(snapshot.counts) + XCTAssertEqual(counts.needsYou, 2) + XCTAssertEqual(counts.working, 5) + XCTAssertEqual(counts.done, 54) + XCTAssertEqual(counts.total, 61) + XCTAssertEqual(counts.machinesOnline, 1) + XCTAssertEqual(counts.machinesTotal, 3) + // 61 rows exist; this frame carried 48 of them. + XCTAssertEqual(counts.overflow(shownItemCount: 48), 13) + + // Partial and malformed count blocks are advisory chrome like + // availability: they may never cost us the items that came with them. + let partial = """ + {"contractVersion":1,"revision":4,"generatedAt":"2026-08-01T12:00:00Z","items":[], + "counts":{"needsYou":1,"unknownFuture":9}} + """ + guard case .snapshot(let partialSnapshot) = try NotchInputDecoder.decode(line: partial) else { + return XCTFail("expected a snapshot") + } + XCTAssertEqual(partialSnapshot.counts?.needsYou, 1) + XCTAssertEqual(partialSnapshot.counts?.working, 0) + + let malformed = """ + {"contractVersion":1,"revision":5,"generatedAt":"2026-08-01T12:00:00Z","items":[],"counts":"broken"} + """ + guard case .snapshot(let malformedSnapshot) = try NotchInputDecoder.decode(line: malformed) else { + return XCTFail("expected a snapshot") + } + XCTAssertNil(malformedSnapshot.counts) + XCTAssertEqual(malformedSnapshot.revision, 5) + } + + /// A bare snapshot with none of the new keys is still the whole legacy + /// contract — this is the regression guard for hosts mid-rollout. + func testBareLegacySnapshotStillDecodes() throws { + let bare = """ + {"contractVersion":1,"revision":1,"generatedAt":"2026-08-01T12:00:00Z","items":[]} + """ + guard case .snapshot(let snapshot) = try NotchInputDecoder.decode(line: bare) else { + return XCTFail("expected a snapshot") + } + XCTAssertNil(snapshot.counts) + XCTAssertNil(snapshot.availability) + XCTAssertNil(snapshot.streamId) + XCTAssertTrue(snapshot.items.isEmpty) + XCTAssertEqual(snapshot.resolvedCounts(), AttentionCounts()) + } + + func testToastCommandDecodesEveryTreatment() throws { + for (raw, expected) in [ + ("celebration", NotchToastTreatment.celebration), + ("success", .success), + ("alert", .alert), + ("info", .info), + ] { + let line = """ + {"type":"toast","toast":{"itemId":"pr-1","eventKind":"pr_merged", + "treatment":"\(raw)","title":"Merged #42","subtitle":"ade/desktop", + "tone":"emerald","durationMs":2000}} + """ + guard case .toast(let toast) = try NotchInputDecoder.decode(line: line) else { + return XCTFail("expected a toast for \(raw)") + } + XCTAssertEqual(toast.treatment, expected) + XCTAssertEqual(toast.itemId, "pr-1") + XCTAssertEqual(toast.title, "Merged #42") + XCTAssertEqual(toast.subtitle, "ade/desktop") + XCTAssertEqual(toast.resolvedTone, .emerald) + XCTAssertEqual(toast.resolvedDurationMs, 2_000) + // Only a merge earns the confetti. + XCTAssertEqual( + toast.treatment.presentation, + expected == .celebration ? .celebration : .attention + ) + } + } + + /// A treatment this build has never heard of reads as ordinary news. It may + /// not throw: a decode failure is reported to the host as a protocol error + /// and latches the helper into "needs an update" for the rest of its life. + func testUnknownToastTreatmentDegradesToInfoInsteadOfFailing() throws { + let line = """ + {"type":"toast","toast":{"eventKind":"agent_needs_you","treatment":"telepathy", + "title":"Needs you"}} + """ + guard case .toast(let toast) = try NotchInputDecoder.decode(line: line) else { + return XCTFail("expected a toast") + } + XCTAssertEqual(toast.treatment, .info) + XCTAssertEqual(toast.treatment.presentation, .attention) + XCTAssertNil(toast.itemId) + XCTAssertEqual(toast.resolvedTone, .blue) + XCTAssertEqual(toast.resolvedDurationMs, 5_000) + } + + /// A drifted host may not pin the surface open, or flash it so briefly that + /// it reads as a glitch. + func testToastDurationIsClampedToASaneWindow() { + XCTAssertEqual(toastFixture(durationMs: 0).resolvedDurationMs, 800) + XCTAssertEqual(toastFixture(durationMs: -5_000).resolvedDurationMs, 800) + XCTAssertEqual(toastFixture(durationMs: 600_000).resolvedDurationMs, 15_000) + XCTAssertEqual(toastFixture(durationMs: 3_000).resolvedDurationMs, 3_000) + XCTAssertEqual(toastFixture(durationMs: nil).resolvedDurationMs, 5_000) + } + + func testToastCommandWithoutAPayloadIsRejected() { + XCTAssertThrowsError(try NotchInputDecoder.decode(line: #"{"type":"toast"}"#)) { error in + XCTAssertEqual(error as? NotchProtocolError, .missingPayload("toast")) + } + } + + func testUnknownCommandDecodesToIgnoredWithoutThrowing() throws { + XCTAssertEqual( + try NotchInputDecoder.decode( + line: #"{"type":"future_thing","payload":{"version":2}}"# + ), + .ignored + ) + } + + @MainActor + func testClickOnlyModeDoesNotLatchSuppressedToast() { + let model = NotchViewModel() + model.handle(.settings(NotchSettings( + enabled: true, + revealMode: .click, + soundsEnabled: false, + automaticRevealEnabled: true + ))) + + model.handle(.toast(toastFixture(durationMs: 3_000))) + + XCTAssertNil(model.activeToast) + XCTAssertEqual(model.interaction.presentation, .compact) + XCTAssertFalse(model.policy.allowsAutomaticReveal) + } + + func testAutomaticRevealAndTickerDefaultOnAndRoundTrip() throws { + let defaults = NotchSettings() + XCTAssertTrue(defaults.automaticRevealEnabled) + XCTAssertTrue(defaults.tickerEnabled) + + // A host built before these keys keeps the shipped behaviour. + let legacy = """ + {"type":"settings","settings":{"enabled":true,"revealMode":"hover", + "expandedPanelEnabled":true,"hideDetails":false, + "celebrationsEnabled":true,"soundsEnabled":false}} + """ + guard case .settings(let inherited) = try NotchInputDecoder.decode(line: legacy) else { + return XCTFail("expected settings") + } + XCTAssertTrue(inherited.automaticRevealEnabled) + XCTAssertTrue(inherited.tickerEnabled) + + let off = """ + {"type":"settings","settings":{"enabled":true,"revealMode":"minimal", + "expandedPanelEnabled":true,"hideDetails":false,"celebrationsEnabled":true, + "soundsEnabled":false,"automaticRevealEnabled":false,"tickerEnabled":false}} + """ + guard case .settings(let explicit) = try NotchInputDecoder.decode(line: off) else { + return XCTFail("expected settings") + } + XCTAssertFalse(explicit.automaticRevealEnabled) + XCTAssertFalse(explicit.tickerEnabled) + + // Both survive the round trip back to the host through the settings + // output, which is how the context menu's checkmarks are persisted. + let encoded = try JSONEncoder().encode(NotchOutput(type: "settings", settings: explicit)) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + let settings = try XCTUnwrap(object["settings"] as? [String: Any]) + XCTAssertEqual(settings["automaticRevealEnabled"] as? Bool, false) + XCTAssertEqual(settings["tickerEnabled"] as? Bool, false) + } + + func testContextMenuTogglesForAutomaticRevealAndTickerPreserveEverythingElse() { + let original = NotchSettings( + enabled: true, + revealMode: .minimal, + expandedPanelEnabled: false, + preferredDisplayId: 42, + hideDetails: false, + celebrationsEnabled: false, + soundsEnabled: true + ) + + let noReveal = applyingNotchSettingsMenuAction(.toggleAutomaticReveal, to: original) + XCTAssertFalse(noReveal.automaticRevealEnabled) + XCTAssertTrue(noReveal.tickerEnabled) + XCTAssertEqual(noReveal.revealMode, .minimal) + XCTAssertEqual(noReveal.preferredDisplayId, 42) + XCTAssertTrue(noReveal.soundsEnabled) + + let noTicker = applyingNotchSettingsMenuAction(.toggleTicker, to: noReveal) + XCTAssertFalse(noTicker.tickerEnabled) + XCTAssertFalse(noTicker.automaticRevealEnabled) + XCTAssertFalse(noTicker.hideDetails) + } + + private func toastFixture(durationMs: Int?) -> AttentionToast { + AttentionToast( + eventKind: "agent_needs_you", + treatment: .alert, + title: "Needs you", + durationMs: durationMs + ) + } + private func fixtureItem( id: String = "agent-1", phase: String = "running", - updatedAt: String = "2026-07-28T12:00:00Z" + updatedAt: String = "2026-07-28T12:00:00Z", + tier: String? = nil ) -> AttentionItem { AttentionItem( id: id, @@ -484,7 +747,8 @@ final class NotchProtocolTests: XCTestCase { privacyPreview: "Agent update", destination: AttentionDestination(kind: "session", sessionId: "session-1"), occurredAt: updatedAt, - updatedAt: updatedAt + updatedAt: updatedAt, + activityTier: tier ) } } diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 750a4b525..8b43d4027 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -121,6 +121,7 @@ import { import { attentionRemoteBindingMatches, attentionItemNavigationRequest, + createAttentionNotchToastDeduper, resolveAttentionNotchOutput, type AttentionNotchResolvedOutput, } from "./services/attention/attentionNotchRouter"; @@ -6717,6 +6718,7 @@ app.whenReady().then(async () => { installApplicationMenu(); let latestAttentionNotchSnapshot: AttentionSnapshot | null = null; + const shouldForwardAttentionNotchToast = createAttentionNotchToastDeduper(); let attentionIpcBridge: ReturnType | null = null; const attentionAccountAuthService = getSharedAccountAuthService(); const attentionRelayClient = createPushRelayClient({ @@ -6812,7 +6814,7 @@ app.whenReady().then(async () => { if (requiresRemoteMachine && !remoteWindow) { const win = await attentionWindow(); if (!win || win.isDestroyed() || !attentionIpcBridge) { - throw new Error("ADE could not open the remote Attention destination."); + throw new Error("ADE could not open the remote Activity destination."); } const binding = await attentionIpcBridge.openAttentionProject({ machineKey: accountMachineKey, @@ -6915,11 +6917,37 @@ app.whenReady().then(async () => { requestAttentionNotchRefresh(true); return; } + if (output.type === "open_settings") { + dispatchAppNavigationRequest?.({ + target: { kind: "settings", tab: "activity", anchor: null }, + source: "attention-notch", + }); + return; + } + if (output.type === "dismiss_item") { + void sendAttentionNotchAcknowledge({ + itemId: output.itemId, + mode: "dismiss", + }).catch((error: unknown) => { + getActiveContext().logger.warn("attention.notch_ack_route_failed", { + itemId: output.itemId, + error: error instanceof Error ? error.message : String(error), + }); + }); + return; + } if (output.type === "settings") { - attentionNotchHelper?.updateSettings(output.settings); + // A helper older than the presentation booleans omits them; both default + // on, so an absent field must read as enabled rather than undefined. + const settings: AttentionNotchSettings = { + ...output.settings, + automaticRevealEnabled: output.settings.automaticRevealEnabled !== false, + tickerEnabled: output.settings.tickerEnabled !== false, + }; + attentionNotchHelper?.updateSettings(settings); for (const win of BrowserWindow.getAllWindows()) { if (win.isDestroyed() || win.webContents.isDestroyed()) continue; - win.webContents.send(IPC.attentionNotchSettingsChanged, output.settings); + win.webContents.send(IPC.attentionNotchSettingsChanged, settings); } return; } @@ -7029,6 +7057,10 @@ app.whenReady().then(async () => { latestAttentionNotchSnapshot = snapshot; attentionNotchHelper?.publishSnapshot(snapshot); }, + publishAttentionNotchToast: (toast) => { + if (!shouldForwardAttentionNotchToast(toast)) return; + attentionNotchHelper?.publishToast(toast); + }, updateAttentionNotchSettings: (settings: AttentionNotchSettings) => { attentionNotchHelper?.updateSettings(settings); }, diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index a9073c3e0..c4a4fd9a6 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -26,6 +26,7 @@ import type { AutomationSaveDraftResult, } from "../../../shared/types/automations"; import type { + AttentionPreferenceScope, AttentionPreferences, AttentionPresence, } from "../../../shared/types/attention"; @@ -204,6 +205,7 @@ export const ADE_ACTION_CTO_ONLY: Partial }", + example: "ade --role cto actions run attention.putMachinePreferences --input-json '{\"accountOwnerId\":\"user_123\",\"machineKey\":\"machine:abc\",\"preferences\":{\"notificationsEnabled\":false}}' --json", + }, }, project_secret: { list: { @@ -1832,7 +1840,7 @@ function buildAttentionDomainService(runtime: AdeRuntime): OpaqueService | null 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."); + throw new Error("The ADE account changed before Activity preferences could be used."); } return accountOwnerId; }; @@ -1868,7 +1876,7 @@ function buildAttentionDomainService(runtime: AdeRuntime): OpaqueService | null }, reportPresence: (args?: AttentionPresence) => { if (!args || typeof args.deviceId !== "string") { - throw new Error("A valid Attention presence payload is required."); + throw new Error("A valid Activity presence payload is required."); } return publisher.reportAttentionPresence(args); }, @@ -1881,13 +1889,30 @@ function buildAttentionDomainService(runtime: AdeRuntime): OpaqueService | null preferences?: AttentionPreferences; }) => { if (!args?.preferences || typeof args.preferences !== "object") { - throw new Error("A valid Attention preferences payload is required."); + throw new Error("A valid Activity preferences payload is required."); } return publisher.putAttentionPreferences( requireCurrentAccountOwner(args.accountOwnerId), args.preferences, ); }, + putMachinePreferences: (args?: { + accountOwnerId?: unknown; + machineKey?: unknown; + preferences?: unknown; + }) => { + if (typeof args?.machineKey !== "string" || args.machineKey.length === 0) { + throw new Error("A machineKey is required."); + } + if (!args?.preferences || typeof args.preferences !== "object") { + throw new Error("A valid Activity machine preferences payload is required."); + } + return publisher.putAttentionMachinePreferences( + requireCurrentAccountOwner(args.accountOwnerId), + args.machineKey, + args.preferences as Partial, + ); + }, }; } diff --git a/apps/desktop/src/main/services/attention/attentionAccountCoordinator.test.ts b/apps/desktop/src/main/services/attention/attentionAccountCoordinator.test.ts index 8c551034e..0867a7d5d 100644 --- a/apps/desktop/src/main/services/attention/attentionAccountCoordinator.test.ts +++ b/apps/desktop/src/main/services/attention/attentionAccountCoordinator.test.ts @@ -6,7 +6,10 @@ import { DEFAULT_ATTENTION_PREFERENCES, type AttentionSnapshot, } from "../../../shared/types/attention"; -import { AttentionAccountCoordinator } from "./attentionAccountCoordinator"; +import { + ActivityAcknowledgmentStaleError, + AttentionAccountCoordinator, +} from "./attentionAccountCoordinator"; function snapshot( overrides: Partial = {}, @@ -62,6 +65,8 @@ describe("AttentionAccountCoordinator", () => { streamId: "account:owner-a", availability: { state: "ready", + title: "", + message: "", recovery: null, }, }); @@ -69,6 +74,217 @@ describe("AttentionAccountCoordinator", () => { expect(callAttention).not.toHaveBeenCalled(); }); + it("marks only the responding host online in a machine fallback", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-29T12:30:00.000Z")); + const callAttention = vi.fn(async () => snapshot({ + streamId: "machine:machine-local", + machines: [ + { + machineKey: "machine-local", + name: "This MacBook", + online: false, + lastSeenAt: "2026-07-29T11:00:00.000Z", + }, + { + machineKey: "machine-remote", + name: "Studio Mac", + online: true, + lastSeenAt: "2026-07-29T10:00:00.000Z", + }, + ], + items: [ + { + id: "local-item", + revision: 1, + machine: { + machineKey: "machine-local", + online: false, + lastSeenAt: "2026-07-29T11:00:00.000Z", + }, + } as never, + { + id: "remote-item", + revision: 1, + machine: { + machineKey: "machine-remote", + online: false, + lastSeenAt: "2026-07-29T10:00:00.000Z", + }, + } as never, + ], + })); + const coordinator = new AttentionAccountCoordinator({ + getLogger: logger, + getCurrentAccountOwnerId: () => null, + localRuntimeConnectionPool: { callAttention } as any, + }); + + const result = await coordinator.getSnapshot({}); + + expect(result.machines?.[0]).toMatchObject({ + machineKey: "machine-local", + online: true, + lastSeenAt: "2026-07-29T12:30:00.000Z", + }); + expect(result.machines?.[1]).toEqual({ + machineKey: "machine-remote", + name: "Studio Mac", + online: true, + lastSeenAt: "2026-07-29T10:00:00.000Z", + }); + expect(result.availability?.hostName).toBe("This MacBook"); + expect(result.items[0]?.machine).toMatchObject({ + machineKey: "machine-local", + online: true, + lastSeenAt: "2026-07-29T12:30:00.000Z", + }); + expect(result.items[1]?.machine).toEqual({ + machineKey: "machine-remote", + online: false, + lastSeenAt: "2026-07-29T10:00:00.000Z", + }); + vi.useRealTimers(); + }); + + it("does not stamp an arbitrary machine when a legacy stream omits its key", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-29T12:30:00.000Z")); + const legacy = snapshot({ + streamId: "legacy-stream", + machines: [{ + machineKey: "machine-first", + name: "First Mac", + online: false, + lastSeenAt: "2026-07-29T11:00:00.000Z", + }], + items: [{ + id: "legacy-item", + revision: 1, + machine: { + machineKey: "machine-first", + online: false, + lastSeenAt: "2026-07-29T11:00:00.000Z", + }, + } as never], + }); + const coordinator = new AttentionAccountCoordinator({ + getLogger: logger, + getCurrentAccountOwnerId: () => null, + localRuntimeConnectionPool: { + callAttention: vi.fn(async () => legacy), + } as any, + }); + + const result = await coordinator.getSnapshot({}); + + expect(result.machines).toEqual(legacy.machines); + expect(result.items[0]?.machine).toEqual(legacy.items[0]?.machine); + expect(result.availability?.hostName).toBe("this Mac"); + vi.useRealTimers(); + }); + + it("fences account acknowledgments with cached revisions and the loaded owner", async () => { + const acknowledgeAttention = vi.fn(async () => ({ + applied: ["attention-1"], + stale: [], + })); + const coordinator = new AttentionAccountCoordinator({ + getLogger: logger, + getCurrentAccountOwnerId: () => "owner-a", + accountAttentionClient: { + getAttentionSnapshot: vi.fn(async () => snapshot({ + scope: "account", + items: [{ id: "attention-1", revision: 8 } as never], + })), + acknowledgeAttention, + reportAttentionPresence: vi.fn(), + getAttentionPreferences: vi.fn(), + putAttentionPreferences: vi.fn(), + }, + }); + + await coordinator.getSnapshot({}); + await coordinator.acknowledge({ + itemIds: ["attention-1"], + sourceRevisions: { "attention-1": 999 }, + expectedAccountOwnerId: "owner-a", + seenAt: "2026-07-29T12:01:00.000Z", + }); + + expect(acknowledgeAttention).toHaveBeenCalledWith({ + itemIds: ["attention-1"], + sourceRevisions: { "attention-1": 8 }, + expectedAccountOwnerId: "owner-a", + seenAt: "2026-07-29T12:01:00.000Z", + }); + expect(acknowledgeAttention).toHaveBeenCalledTimes(1); + expect(coordinator).toBeInstanceOf(AttentionAccountCoordinator); + }); + + it("surfaces relay-stale account acknowledgments as a typed refresh error", async () => { + const coordinator = new AttentionAccountCoordinator({ + getLogger: logger, + getCurrentAccountOwnerId: () => "owner-a", + accountAttentionClient: { + getAttentionSnapshot: vi.fn(async () => snapshot({ + scope: "account", + items: [{ id: "attention-1", revision: 8 } as never], + })), + acknowledgeAttention: vi.fn(async () => ({ + applied: [], + stale: ["attention-1"], + })), + reportAttentionPresence: vi.fn(), + getAttentionPreferences: vi.fn(), + putAttentionPreferences: vi.fn(), + }, + }); + + await coordinator.getSnapshot({}); + const error = await coordinator.acknowledge({ + itemIds: ["attention-1"], + expectedAccountOwnerId: "owner-a", + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ActivityAcknowledgmentStaleError); + expect(error).toMatchObject({ + code: "activity_acknowledgment_stale", + staleItemIds: ["attention-1"], + }); + expect((error as Error).message).toMatch(/refresh Activity/i); + }); + + it("writes one machine preference scope through the account relay", async () => { + const putActivityMachinePreferences = vi.fn(async () => undefined); + const coordinator = new AttentionAccountCoordinator({ + getLogger: logger, + getCurrentAccountOwnerId: () => "owner-a", + accountAttentionClient: { + getAttentionSnapshot: vi.fn(), + acknowledgeAttention: vi.fn(), + reportAttentionPresence: vi.fn(), + getAttentionPreferences: vi.fn(), + putAttentionPreferences: vi.fn(), + putActivityMachinePreferences, + }, + }); + + await coordinator.putActivityMachinePreferences( + " machine-1 ", + { notificationsEnabled: false }, + "owner-a", + ); + + expect(putActivityMachinePreferences).toHaveBeenCalledWith( + "owner-a", + "machine-1", + { notificationsEnabled: false }, + ); + expect(putActivityMachinePreferences).toHaveBeenCalledTimes(1); + expect(coordinator).toBeInstanceOf(AttentionAccountCoordinator); + }); + it("sanitizes account auth failures and falls back to the local machine", async () => { const testLogger = logger(); const getAttentionSnapshot = vi.fn(async () => { diff --git a/apps/desktop/src/main/services/attention/attentionAccountCoordinator.ts b/apps/desktop/src/main/services/attention/attentionAccountCoordinator.ts index 1b625cf3f..77b462d9a 100644 --- a/apps/desktop/src/main/services/attention/attentionAccountCoordinator.ts +++ b/apps/desktop/src/main/services/attention/attentionAccountCoordinator.ts @@ -2,6 +2,7 @@ import type { PushRelayClient } from "../../../../../ade-cli/src/services/push/p import { PushRelayRequestError } from "../../../../../ade-cli/src/services/push/pushRelayClient"; import { DEFAULT_ATTENTION_PREFERENCES, + type AttentionPreferenceScope, type AttentionPreferences, type AttentionPresence, type AttentionSnapshot, @@ -16,7 +17,7 @@ type AccountAttentionClient = Pick< | "reportAttentionPresence" | "getAttentionPreferences" | "putAttentionPreferences" ->; +> & Partial>; type AttentionAccountCoordinatorOptions = { getLogger: () => Pick; @@ -46,6 +47,17 @@ type AttentionPreferenceUpdateRequest = AttentionPreferenceRequest & { preferences?: unknown; }; +export class ActivityAcknowledgmentStaleError extends Error { + readonly code = "activity_acknowledgment_stale" as const; + + constructor(readonly staleItemIds: string[]) { + super( + "One or more Activity items changed after they loaded. Refresh Activity, then try again.", + ); + this.name = "ActivityAcknowledgmentStaleError"; + } +} + function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } @@ -54,7 +66,7 @@ export class AttentionAccountCoordinator { private loggedRuntimeCompatibilityFailure = false; private lastSnapshotScope: AttentionSnapshot["scope"] | null = null; private lastSnapshotAccountOwnerId: string | null = null; - private readonly lastMachineItemRevisions = new Map(); + private readonly lastSnapshotItemRevisions = new Map(); constructor(private readonly options: AttentionAccountCoordinatorOptions) {} @@ -83,8 +95,8 @@ export class AttentionAccountCoordinator { accountOwnerId, availability: { state: "ready", - title: "Account Attention is live", - message: "Work from every signed-in ADE machine is available.", + title: "", + message: "", recovery: null, }, }; @@ -107,12 +119,32 @@ export class AttentionAccountCoordinator { "getMachineSnapshot", {}, ); - const machineName = snapshot.machines?.[0]?.name?.trim() || "this Mac"; + const generatedAt = new Date().toISOString(); + const hostMachineKey = snapshot.streamId?.startsWith("machine:") + ? snapshot.streamId.slice("machine:".length) + : null; + const keyedHostMachine = hostMachineKey + ? snapshot.machines?.find((machine) => machine.machineKey === hostMachineKey) + : null; + const hostMachine = hostMachineKey + ? keyedHostMachine ?? snapshot.machines?.[0] + : null; + const resolvedHostMachineKey = hostMachine?.machineKey ?? hostMachineKey; + const stampHostMachine = (machine: T): T => + machine.machineKey === resolvedHostMachineKey + ? { ...machine, online: true, lastSeenAt: generatedAt } + : machine; + const machineName = hostMachine?.name?.trim() || "this Mac"; const accountAvailability = accountFailure ? this.describeAccountFailure(accountFailure) : null; const machineSnapshot: AttentionSnapshot = { ...snapshot, + machines: snapshot.machines?.map(stampHostMachine), + items: snapshot.items.map((item) => + item.machine && typeof item.machine.machineKey === "string" + ? { ...item, machine: stampHostMachine(item.machine) } + : item), scope: "machine", accountOwnerId, availability: accountOwnerId @@ -131,7 +163,7 @@ export class AttentionAccountCoordinator { : { state: "signed_out", title: `Showing ${machineName}`, - message: "Sign in to combine Attention across every ADE machine.", + message: "Sign in to combine Activity across every ADE machine.", recovery: "sign_in", hostName: machineName, }, @@ -144,7 +176,7 @@ export class AttentionAccountCoordinator { throw new Error( accountFailure ? ( - "Account Attention could not connect, and this Mac cannot provide a fallback. " + "Account Activity could not connect, and this Mac cannot provide a fallback. " + compatibilityMessage ) : compatibilityMessage, @@ -162,7 +194,7 @@ export class AttentionAccountCoordinator { ); } throw new Error( - "Attention cannot reach this Mac's ADE brain. Restart ADE on this Mac, then try again.", + "Activity cannot reach this Mac's ADE brain. Restart ADE on this Mac, then try again.", ); } @@ -176,7 +208,7 @@ export class AttentionAccountCoordinator { .slice(0, 64) : []; if (itemIds.length === 0) { - throw new Error("At least one Attention item id is required."); + throw new Error("At least one Activity item id is required."); } const acknowledgment = { itemIds, @@ -188,7 +220,7 @@ export class AttentionAccountCoordinator { const currentAccountOwnerId = this.currentAccountOwnerId(); if (this.lastSnapshotAccountOwnerId !== currentAccountOwnerId) { throw new Error( - "The ADE account changed after Attention loaded. Refresh Attention, then try again.", + "The ADE account changed after Activity loaded. Refresh Activity, then try again.", ); } if (this.lastSnapshotScope === "machine") { @@ -199,7 +231,7 @@ export class AttentionAccountCoordinator { ) : {}; const staleItemIds = itemIds.filter((itemId) => - sourceRevisions[itemId] !== this.lastMachineItemRevisions.get(itemId)); + sourceRevisions[itemId] !== this.lastSnapshotItemRevisions.get(itemId)); if (staleItemIds.length > 0) { throw new Error( "This machine can only acknowledge the exact item revision that was loaded. Refresh and try again.", @@ -215,11 +247,11 @@ export class AttentionAccountCoordinator { || requestedAccountOwnerId !== this.lastSnapshotAccountOwnerId ) { throw new Error( - "The machine Attention account scope changed after this item loaded. Refresh and try again.", + "The machine Activity account scope changed after this item loaded. Refresh and try again.", ); } if (!this.options.localRuntimeConnectionPool) { - throw new Error("Machine Attention is unavailable until this Mac's ADE brain is ready."); + throw new Error("Machine Activity is unavailable until this Mac's ADE brain is ready."); } await this.options.localRuntimeConnectionPool.callAttention( "acknowledge", @@ -233,19 +265,52 @@ export class AttentionAccountCoordinator { return; } if (this.lastSnapshotScope !== "account") { - throw new Error("Refresh Attention before acknowledging this item."); + throw new Error("Refresh Activity before acknowledging this item."); } if (currentAccountOwnerId && this.options.accountAttentionClient) { - await this.options.accountAttentionClient.acknowledgeAttention(acknowledgment); + const requestedAccountOwnerId = + request.expectedAccountOwnerId === null + || typeof request.expectedAccountOwnerId === "string" + ? request.expectedAccountOwnerId?.trim() || null + : undefined; + if ( + requestedAccountOwnerId === undefined + || requestedAccountOwnerId !== this.lastSnapshotAccountOwnerId + ) { + throw new Error( + "The account Activity scope changed after this item loaded. Refresh and try again.", + ); + } + const sourceRevisions = Object.fromEntries( + itemIds.flatMap((itemId) => { + const revision = this.lastSnapshotItemRevisions.get(itemId); + return revision === undefined ? [] : [[itemId, revision]]; + }), + ); + const staleItemIds = itemIds.filter((itemId) => sourceRevisions[itemId] === undefined); + if (staleItemIds.length > 0) { + throw new ActivityAcknowledgmentStaleError(staleItemIds); + } + const result = await this.options.accountAttentionClient.acknowledgeAttention({ + ...acknowledgment, + sourceRevisions, + expectedAccountOwnerId: requestedAccountOwnerId, + }); + if (!result) { + throw new Error("Sign in again, refresh Activity, then try to acknowledge this item."); + } + if (result.stale.length > 0) { + throw new ActivityAcknowledgmentStaleError(result.stale); + } return; } - throw new Error("Sign in again, refresh Attention, then try to acknowledge this item."); + throw new Error("Sign in again, refresh Activity, then try to acknowledge this item."); } async reportPresence(input: unknown): Promise { 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."); + throw new Error("A valid Activity presence payload is required."); } if (this.currentAccountOwnerId() && this.options.accountAttentionClient) { await this.options.accountAttentionClient.reportAttentionPresence( @@ -278,7 +343,7 @@ export class AttentionAccountCoordinator { async putPreferences(input: unknown): Promise { const request = isRecord(input) ? input as AttentionPreferenceUpdateRequest : null; if (!request || !isRecord(request.preferences)) { - throw new Error("A valid Attention preferences payload is required."); + throw new Error("A valid Activity preferences payload is required."); } const accountOwnerId = this.requireCurrentAccountOwner(request.accountOwnerId); if (this.options.accountAttentionClient) { @@ -289,7 +354,7 @@ export class AttentionAccountCoordinator { return; } if (!this.options.localRuntimeConnectionPool) { - throw new Error("Account Attention is unavailable until this Mac's ADE brain is ready."); + throw new Error("Account Activity is unavailable until this Mac's ADE brain is ready."); } await this.options.localRuntimeConnectionPool.callAttention( "putPreferences", @@ -300,6 +365,34 @@ export class AttentionAccountCoordinator { ); } + async putActivityMachinePreferences( + machineKey: unknown, + partial: unknown, + expectedAccountOwnerId?: unknown, + ): Promise { + const normalizedMachineKey = typeof machineKey === "string" ? machineKey.trim() : ""; + if (!normalizedMachineKey || !isRecord(partial)) { + throw new Error("A valid Activity machine preference update is required."); + } + const accountOwnerId = expectedAccountOwnerId === undefined + ? this.currentAccountOwnerId() + : this.requireCurrentAccountOwner(expectedAccountOwnerId); + if (!accountOwnerId) { + throw new Error("Sign in before changing Activity machine preferences."); + } + if ( + !this.options.accountAttentionClient + || !this.options.accountAttentionClient.putActivityMachinePreferences + ) { + throw new Error("Account Activity is unavailable until this Mac's ADE brain is ready."); + } + await this.options.accountAttentionClient.putActivityMachinePreferences( + accountOwnerId, + normalizedMachineKey, + partial as Partial, + ); + } + private currentAccountOwnerId(): string | null { return this.options.getCurrentAccountOwnerId()?.trim() || null; } @@ -310,21 +403,19 @@ export class AttentionAccountCoordinator { ): void { this.lastSnapshotScope = snapshot.scope ?? null; this.lastSnapshotAccountOwnerId = accountOwnerId; - this.lastMachineItemRevisions.clear(); - if (snapshot.scope === "machine") { - for (const item of snapshot.items) { - this.lastMachineItemRevisions.set(item.id, item.revision); - } + this.lastSnapshotItemRevisions.clear(); + for (const item of snapshot.items) { + this.lastSnapshotItemRevisions.set(item.id, item.revision); } } private requireCurrentAccountOwner(value: unknown): string { const accountOwnerId = typeof value === "string" ? value.trim() : ""; if (!accountOwnerId) { - throw new Error("A valid Attention account owner is required."); + throw new Error("A valid Activity account owner is required."); } if (this.currentAccountOwnerId() !== accountOwnerId) { - throw new Error("The ADE account changed before Attention preferences could be used."); + throw new Error("The ADE account changed before Activity preferences could be used."); } return accountOwnerId; } @@ -345,7 +436,7 @@ export class AttentionAccountCoordinator { }); } return ( - "Account Attention requires a newer connected ADE brain. " + "Account Activity requires a newer connected ADE brain. " + "Update and restart ADE on the host machine so the notch can receive account-wide work." ); } @@ -358,20 +449,20 @@ export class AttentionAccountCoordinator { title: "Account session needs attention", message: "ADE could not verify your account after refreshing the session. " - + "Sign out and back in to restore account-wide Attention.", + + "Sign out and back in to restore account-wide Activity.", recovery: "sign_in", }; } if (error instanceof PushRelayRequestError && error.status === 503) { return { - title: "Account Attention is temporarily unavailable", + title: "Account Activity is temporarily unavailable", message: "ADE's account service is not ready. Machine-scoped work remains available while it recovers.", recovery: "retry", }; } return { - title: "Account Attention is reconnecting", + title: "Account Activity is reconnecting", message: "ADE cannot reach the account stream right now. Machine-scoped work remains available.", recovery: "retry", diff --git a/apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts b/apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts index 7f9ea7d96..ab56b52fd 100644 --- a/apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts +++ b/apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts @@ -76,6 +76,8 @@ describe("AttentionNotchHelper", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: true, @@ -136,6 +138,8 @@ describe("AttentionNotchHelper", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: true, @@ -158,6 +162,8 @@ describe("AttentionNotchHelper", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: true, @@ -187,6 +193,8 @@ describe("AttentionNotchHelper", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: true, @@ -258,6 +266,8 @@ describe("AttentionNotchHelper", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: true, @@ -288,6 +298,8 @@ describe("AttentionNotchHelper", () => { enabled: false, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: false, celebrationsEnabled: true, @@ -323,6 +335,8 @@ describe("AttentionNotchHelper", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: false, @@ -356,6 +370,8 @@ describe("AttentionNotchHelper", () => { enabled: false, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: false, @@ -393,6 +409,8 @@ describe("AttentionNotchHelper", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: true, @@ -402,6 +420,8 @@ describe("AttentionNotchHelper", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: false, celebrationsEnabled: true, @@ -460,6 +480,8 @@ describe("AttentionNotchHelper", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: false, @@ -479,6 +501,8 @@ describe("AttentionNotchHelper", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: false, celebrationsEnabled: false, @@ -488,6 +512,8 @@ describe("AttentionNotchHelper", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: false, @@ -513,4 +539,202 @@ describe("AttentionNotchHelper", () => { expect(JSON.parse(lines[4] ?? "{}").visible).toBe(false); helper.dispose(); }); + const enabledSettings = { + enabled: true as const, + revealMode: "hover" as const, + expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, + preferredDisplayId: null, + hideDetails: true, + celebrationsEnabled: false, + soundsEnabled: false, + }; + + const toast = { + itemId: "agent-1", + eventKind: "agent_needs_you" as const, + treatment: "alert" as const, + title: "Agent needs you", + subtitle: "Approve the command", + tone: null, + durationMs: null, + }; + + // The router now writes up to 192KB; if this buffer were still 256KB a + // legitimately large frame would be accepted and then silently dropped here. + it("parses an output line far larger than the old 256KB buffer", () => { + const child = fakeChild(); + spawnMock.mockReturnValue(child); + const onOutput = vi.fn(); + const helper = new AttentionNotchHelper({ + executablePath: "/tmp/notch", + logger, + onOutput, + platform: "darwin", + }); + helper.updateSettings(enabledSettings); + child.emit("spawn"); + + const line = JSON.stringify({ + type: "protocol_error", + message: "x".repeat(300 * 1024), + }); + expect(Buffer.byteLength(line, "utf8")).toBeGreaterThan(256 * 1024); + (child.stdout as PassThrough).write(`${line}\n`); + + expect(logger.warn).not.toHaveBeenCalledWith("attention.notch_helper_output_overflow"); + expect(onOutput).toHaveBeenCalledTimes(1); + expect(onOutput.mock.calls[0]?.[0]?.type).toBe("protocol_error"); + helper.dispose(); + }); + + it("accepts the two new output types and settings output with or without the new flags", () => { + const child = fakeChild(); + spawnMock.mockReturnValue(child); + const onOutput = vi.fn(); + const helper = new AttentionNotchHelper({ + executablePath: "/tmp/notch", + logger, + onOutput, + platform: "darwin", + }); + helper.updateSettings(enabledSettings); + child.emit("spawn"); + + (child.stdout as PassThrough).write([ + JSON.stringify({ type: "open_settings" }), + JSON.stringify({ + type: "dismiss_item", + itemId: "agent-1", + destination: { kind: "session", sessionId: "session-1" }, + }), + // A dismiss without an item is not routable and must be rejected. + JSON.stringify({ type: "dismiss_item" }), + JSON.stringify({ + type: "settings", + settings: { + enabled: true, + revealMode: "click", + expandedPanelEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: false, + preferredDisplayId: null, + hideDetails: true, + celebrationsEnabled: true, + soundsEnabled: false, + }, + }), + // Optional: a helper predating the flags still lands. + JSON.stringify({ + type: "settings", + settings: { + enabled: true, + revealMode: "click", + expandedPanelEnabled: false, + preferredDisplayId: null, + hideDetails: true, + celebrationsEnabled: true, + soundsEnabled: false, + }, + }), + JSON.stringify({ + type: "settings", + settings: { + enabled: true, + revealMode: "click", + expandedPanelEnabled: false, + tickerEnabled: "yes", + preferredDisplayId: null, + hideDetails: true, + celebrationsEnabled: true, + soundsEnabled: false, + }, + }), + "", + ].join("\n")); + + expect(onOutput.mock.calls.map((call) => call[0]?.type)).toEqual([ + "open_settings", + "dismiss_item", + "settings", + "settings", + ]); + expect(onOutput.mock.calls[1]?.[0]).toMatchObject({ itemId: "agent-1" }); + helper.dispose(); + }); + + it("writes a toast only when the helper is already running", () => { + const child = fakeChild(); + spawnMock.mockReturnValue(child); + 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")); + }); + const helper = new AttentionNotchHelper({ + executablePath: "/tmp/notch", + logger, + onOutput: vi.fn(), + platform: "darwin", + }); + + // No child yet: a toast must never be the thing that starts the surface, + // and must not be retained for replay. + helper.publishToast(toast); + expect(spawnMock).not.toHaveBeenCalled(); + expect(lines).toEqual([]); + + helper.updateSettings(enabledSettings); + child.emit("spawn"); + helper.publishToast(toast); + + const parsed = lines.map((line) => JSON.parse(line)); + expect(parsed.map((entry) => entry.type)).toEqual(["settings", "toast"]); + expect(parsed[1]?.toast).toMatchObject({ + itemId: "agent-1", + eventKind: "agent_needs_you", + treatment: "alert", + }); + helper.dispose(); + }); + + it("never collapses two queued toasts into one", () => { + const child = fakeChild(); + spawnMock.mockReturnValue(child); + 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")); + }); + const originalWrite = child.stdin.write.bind(child.stdin); + let writeCount = 0; + vi.spyOn(child.stdin, "write").mockImplementation(((...args: Parameters) => { + writeCount += 1; + const accepted = originalWrite(...args); + return writeCount === 1 ? false : accepted; + }) as typeof child.stdin.write); + + const helper = new AttentionNotchHelper({ + executablePath: "/tmp/notch", + logger, + onOutput: vi.fn(), + platform: "darwin", + }); + helper.updateSettings(enabledSettings); + child.emit("spawn"); + + helper.publishToast({ ...toast, itemId: "agent-1", title: "First" }); + helper.publishToast({ ...toast, itemId: "agent-2", title: "Second" }); + child.stdin.emit("drain"); + + const toasts = lines + .map((line) => JSON.parse(line)) + .filter((entry) => entry.type === "toast") + .map((entry) => entry.toast.title); + expect(toasts).toEqual(["First", "Second"]); + helper.dispose(); + }); }); diff --git a/apps/desktop/src/main/services/attention/attentionNotchHelper.ts b/apps/desktop/src/main/services/attention/attentionNotchHelper.ts index 9b3ae4c6a..fe4170f0b 100644 --- a/apps/desktop/src/main/services/attention/attentionNotchHelper.ts +++ b/apps/desktop/src/main/services/attention/attentionNotchHelper.ts @@ -6,11 +6,14 @@ import type { AttentionDestination, AttentionNotchHealth, AttentionNotchSettings, + AttentionNotchToast, AttentionSnapshot, } from "../../../shared/types/attention"; import type { Logger } from "../logging/logger"; -const MAX_HELPER_LINE_BYTES = 256 * 1024; +// Must stay above the router's snapshot write cap so a snapshot the router +// accepted can never overflow this buffer and be dropped on arrival. +const MAX_HELPER_LINE_BYTES = 512 * 1024; const MAX_RESTART_ATTEMPTS = 3; const GRACEFUL_SHUTDOWN_MS = 500; const DEFAULT_REFRESH_INTERVAL_MS = 15_000; @@ -49,11 +52,20 @@ export type AttentionNotchOutput = | { type: "settings"; settings: AttentionNotchSettings; + } + | { + type: "open_settings"; + } + | { + type: "dismiss_item"; + itemId: string; + destination?: AttentionDestination | null; }; type AttentionNotchInput = | { type: "settings"; settings: AttentionNotchSettings } | { type: "snapshot"; snapshot: AttentionSnapshot } + | { type: "toast"; toast: AttentionNotchToast } | { type: "visibility"; visible: boolean } | { type: "reanchor" } | { type: "quit" }; @@ -273,6 +285,16 @@ export class AttentionNotchHelper { } } + /** + * A toast is a one-shot event, so it never starts the helper and is never + * retained as latest state: replaying it after a restart would announce + * something that already happened, minutes late. + */ + publishToast(toast: AttentionNotchToast): void { + if (!this.child) return; + this.write({ type: "toast", toast }); + } + updateSettings(settings: AttentionNotchSettings): void { this.latestSettings = settings; if (!settings.enabled) { @@ -356,10 +378,14 @@ export class AttentionNotchHelper { } private enqueuePendingWrite(payload: AttentionNotchInput): void { - // Every helper command is state-setting/idempotent. Keep only the newest + // Every state-setting helper command is idempotent. Keep only the newest // value per type, append it after other controls to preserve causal order, // and retain a hard cap in case the protocol grows new command types. - this.pendingWrites = this.pendingWrites.filter((entry) => entry.type !== payload.type); + // Toasts are the exception: they are events, and collapsing two of them + // into one would drop an announcement rather than refresh it. + if (payload.type !== "toast") { + this.pendingWrites = this.pendingWrites.filter((entry) => entry.type !== payload.type); + } this.pendingWrites.push(payload); if (this.pendingWrites.length > MAX_PENDING_WRITES) { this.pendingWrites.splice(0, this.pendingWrites.length - MAX_PENDING_WRITES); @@ -479,7 +505,12 @@ function isAttentionNotchOutput(value: unknown): value is AttentionNotchOutput { ); } if (value.type === "protocol_error") return typeof value.message === "string"; - if (value.type === "open_center" || value.type === "refresh") return true; + if ( + value.type === "open_center" + || value.type === "refresh" + || value.type === "open_settings" + ) return true; + if (value.type === "dismiss_item") return typeof value.itemId === "string"; if (value.type === "settings") { if (!isRecord(value.settings)) return false; const settings = value.settings; @@ -491,6 +522,15 @@ function isAttentionNotchOutput(value: unknown): value is AttentionNotchOutput { || settings.revealMode === "click" ) && typeof settings.expandedPanelEnabled === "boolean" + // Optional: a helper built before these existed must still be accepted. + && ( + settings.automaticRevealEnabled === undefined + || typeof settings.automaticRevealEnabled === "boolean" + ) + && ( + settings.tickerEnabled === undefined + || typeof settings.tickerEnabled === "boolean" + ) && ( settings.preferredDisplayId == null || typeof settings.preferredDisplayId === "number" diff --git a/apps/desktop/src/main/services/attention/attentionNotchRouter.test.ts b/apps/desktop/src/main/services/attention/attentionNotchRouter.test.ts index ce144b07c..e117da200 100644 --- a/apps/desktop/src/main/services/attention/attentionNotchRouter.test.ts +++ b/apps/desktop/src/main/services/attention/attentionNotchRouter.test.ts @@ -3,8 +3,10 @@ import { describe, expect, it } from "vitest"; import { attentionRemoteBindingMatches, attentionItemNavigationRequest, + createAttentionNotchToastDeduper, parseAttentionNotchSettings, parseAttentionNotchSnapshot, + parseAttentionNotchToast, resolveAttentionNotchOutput, } from "./attentionNotchRouter"; import type { AttentionItem, AttentionSnapshot } from "../../../shared/types"; @@ -101,7 +103,24 @@ describe("Attention Notch routing", () => { }); it("accepts bounded canonical snapshots and settings", () => { - expect(parseAttentionNotchSnapshot(snapshot())).toEqual(snapshot()); + const activitySnapshot = { + ...snapshot(item({ + activityTier: "signal", + contentFingerprint: "content-v2", + alertFingerprint: "alert-v2", + statusSince: "2026-07-28T12:00:01.000Z", + })), + itemsTruncated: true, + } satisfies AttentionSnapshot; + expect(parseAttentionNotchSnapshot(activitySnapshot)).toEqual(activitySnapshot); + expect(parseAttentionNotchSnapshot({ + ...activitySnapshot, + itemsTruncated: "yes", + })).toBeNull(); + expect(parseAttentionNotchSnapshot({ + ...activitySnapshot, + items: [{ ...activitySnapshot.items[0], activityTier: "urgent" }], + })).toBeNull(); expect(parseAttentionNotchSettings({ enabled: true, revealMode: "click", @@ -110,10 +129,14 @@ describe("Attention Notch routing", () => { hideDetails: false, celebrationsEnabled: true, soundsEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: false, })).toEqual({ enabled: true, revealMode: "click", expandedPanelEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: false, preferredDisplayId: 12, hideDetails: false, celebrationsEnabled: true, @@ -134,6 +157,10 @@ describe("Attention Notch routing", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + // Both new presentation booleans default on, so an older payload keeps + // the shipped behaviour rather than silently going quiet. + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: false, celebrationsEnabled: true, @@ -141,6 +168,25 @@ describe("Attention Notch routing", () => { }); }); + it("rejects non-boolean automatic reveal or ticker flags", () => { + expect(parseAttentionNotchSettings({ + enabled: true, + preferredDisplayId: null, + hideDetails: false, + celebrationsEnabled: true, + soundsEnabled: false, + automaticRevealEnabled: "sometimes", + })).toBeNull(); + expect(parseAttentionNotchSettings({ + enabled: true, + preferredDisplayId: null, + hideDetails: false, + celebrationsEnabled: true, + soundsEnabled: false, + tickerEnabled: 1, + })).toBeNull(); + }); + it("rejects an invented notch reveal mode", () => { expect(parseAttentionNotchSettings({ enabled: true, @@ -234,6 +280,155 @@ describe("Attention Notch routing", () => { }); }); + it("accepts a well-formed counts block and rejects a malformed one", () => { + const counts = { + needsYou: 2, + working: 5, + done: 1, + total: 61, + machinesOnline: 1, + machinesTotal: 3, + }; + expect(parseAttentionNotchSnapshot({ ...snapshot(), counts })).not.toBeNull(); + // Absent stays valid: publishers older than the counts block still land. + expect(parseAttentionNotchSnapshot(snapshot())).not.toBeNull(); + expect(parseAttentionNotchSnapshot({ + ...snapshot(), + counts: { ...counts, total: -1 }, + })).toBeNull(); + expect(parseAttentionNotchSnapshot({ + ...snapshot(), + counts: { ...counts, working: 1.5 }, + })).toBeNull(); + expect(parseAttentionNotchSnapshot({ + ...snapshot(), + counts: { ...counts, machinesTotal: undefined }, + })).toBeNull(); + }); + + // The router's write cap has to stay under the helper's read cap, or an + // accepted snapshot is silently dropped on the far side of the pipe. + it("caps the published projection at 64 items and 192KB", () => { + const many = (count: number) => ({ + ...snapshot(), + items: Array.from({ length: count }, (_unused, index) => + item({ id: `agent-${index}`, fingerprint: `agent-${index}:3` })), + }); + expect(parseAttentionNotchSnapshot(many(64))).not.toBeNull(); + expect(parseAttentionNotchSnapshot(many(65))).toBeNull(); + expect(parseAttentionNotchSnapshot({ + ...snapshot(), + items: [item({ detail: "x".repeat(8_000) })], + // A single oversized field is enough once the payload clears 192KB. + generatedAt: "2026-07-28T12:00:03.000Z", + streamId: "s".repeat(400), + tombstones: Array.from({ length: 4_000 }, (_unused, index) => ({ + id: `tombstone-${index}-${"x".repeat(40)}`, + revision: 1, + deletedAt: "2026-07-28T12:00:03.000Z", + })), + })).toBeNull(); + }); + + it("validates toasts and rejects anything the native side would have to bend", () => { + expect(parseAttentionNotchToast({ + itemId: "agent-1", + eventKind: "pr_merged", + treatment: "celebration", + title: "Merged #42", + subtitle: "acme/ade", + tone: "emerald", + durationMs: 1_650, + })).toEqual({ + itemId: "agent-1", + eventKind: "pr_merged", + treatment: "celebration", + title: "Merged #42", + subtitle: "acme/ade", + tone: "emerald", + durationMs: 1_650, + }); + + // itemId is optional: a toast can be about the account, not a row. + expect(parseAttentionNotchToast({ + eventKind: "agent_needs_you", + treatment: "alert", + title: "Agent needs you", + })).toEqual({ + itemId: null, + eventKind: "agent_needs_you", + treatment: "alert", + title: "Agent needs you", + subtitle: null, + tone: null, + durationMs: null, + }); + + expect(parseAttentionNotchToast({ + eventKind: "agent_vibed", + treatment: "alert", + title: "Agent needs you", + })).toBeNull(); + expect(parseAttentionNotchToast({ + eventKind: "agent_needs_you", + treatment: "fanfare", + title: "Agent needs you", + })).toBeNull(); + expect(parseAttentionNotchToast({ + eventKind: "agent_needs_you", + treatment: "alert", + title: "x".repeat(257), + })).toBeNull(); + expect(parseAttentionNotchToast({ + eventKind: "agent_needs_you", + treatment: "alert", + title: "", + })).toBeNull(); + expect(parseAttentionNotchToast({ + eventKind: "agent_needs_you", + treatment: "alert", + title: "Agent needs you", + tone: "chartreuse", + })).toBeNull(); + // Out of range is rejected rather than clamped: 800..15000 mirrors the + // native clamp, and a host outside it has drifted. + expect(parseAttentionNotchToast({ + eventKind: "agent_needs_you", + treatment: "alert", + title: "Agent needs you", + durationMs: 200, + })).toBeNull(); + expect(parseAttentionNotchToast({ + eventKind: "agent_needs_you", + treatment: "alert", + title: "Agent needs you", + durationMs: 60_000, + })).toBeNull(); + }); + + it("deduplicates the same cross-window toast for five seconds", () => { + let now = 1_000; + const shouldForward = createAttentionNotchToastDeduper(() => now); + const toast = { + itemId: "agent-1", + eventKind: "agent_needs_you" as const, + treatment: "alert" as const, + title: "Agent needs you", + }; + + expect(shouldForward(toast)).toBe(true); + expect(shouldForward({ ...toast, title: "A second window's copy" })).toBe(false); + expect(shouldForward({ ...toast, eventKind: "agent_failed" })).toBe(true); + expect(shouldForward({ ...toast, itemId: "agent-2" })).toBe(true); + + now += 5_000; + expect(shouldForward(toast)).toBe(true); + + const titleOnly = { ...toast, itemId: null }; + expect(shouldForward(titleOnly)).toBe(true); + expect(shouldForward({ ...titleOnly })).toBe(false); + }); + it("preserves exact PR ids and detail tabs", () => { const pr = item({ id: "pr-1", diff --git a/apps/desktop/src/main/services/attention/attentionNotchRouter.ts b/apps/desktop/src/main/services/attention/attentionNotchRouter.ts index 43f37e663..20463b2f2 100644 --- a/apps/desktop/src/main/services/attention/attentionNotchRouter.ts +++ b/apps/desktop/src/main/services/attention/attentionNotchRouter.ts @@ -1,21 +1,37 @@ import type { AppNavigationRequest, AttentionAction, + AttentionEventKind, AttentionItem, AttentionNotchSettings, + AttentionNotchToast, + AttentionNotchToastTreatment, AttentionSnapshot, + AttentionTone, OpenProjectBinding, } from "../../../shared/types"; import { ATTENTION_CONTRACT_VERSION, + ATTENTION_NOTCH_TOAST_MAX_DURATION_MS, + ATTENTION_NOTCH_TOAST_MIN_DURATION_MS, + ATTENTION_NOTCH_TOAST_TREATMENTS, + ATTENTION_TONES, DEFAULT_ATTENTION_NOTCH_REVEAL_MODE, isAttentionNotchRevealMode, } from "../../../shared/types/attention"; import type { AttentionNotchOutput } from "./attentionNotchHelper"; -const MAX_NOTCH_ITEMS = 256; +// The write cap must stay under the helper's own read cap, or a snapshot the +// router happily accepts is silently dropped on the far side of the pipe. +const MAX_NOTCH_ITEMS = 64; const MAX_NOTCH_ACTIONS = 12; -const MAX_SNAPSHOT_BYTES = 512 * 1024; +const MAX_SNAPSHOT_BYTES = 192 * 1024; +const MAX_TOAST_TITLE_LENGTH = 256; +const MAX_TOAST_SUBTITLE_LENGTH = 512; +const MAX_TOAST_ITEM_ID_LENGTH = 512; +export const ATTENTION_NOTCH_TOAST_DEDUPE_MS = 5_000; +const TOAST_TREATMENTS = new Set(ATTENTION_NOTCH_TOAST_TREATMENTS); +const TONES = new Set(ATTENTION_TONES); const ATTENTION_PHASES = new Set([ "starting", "running", @@ -46,6 +62,28 @@ const ATTENTION_EVENTS = new Set([ "pr_closed", ]); +/** + * Several renderer windows observe the same account store. Keep that useful + * redundancy for snapshots, but allow only one native toast for the same event + * during a short cross-window arbitration window. + */ +export function createAttentionNotchToastDeduper( + now: () => number = Date.now, + windowMs = ATTENTION_NOTCH_TOAST_DEDUPE_MS, +): (toast: AttentionNotchToast) => boolean { + const forwardedAtByKey = new Map(); + return (toast) => { + const at = now(); + for (const [key, forwardedAt] of forwardedAtByKey) { + if (at - forwardedAt >= windowMs) forwardedAtByKey.delete(key); + } + const key = JSON.stringify([toast.itemId ?? toast.title, toast.eventKind]); + if (forwardedAtByKey.has(key)) return false; + forwardedAtByKey.set(key, at); + return true; + }; +} + export type AttentionNotchResolvedOutput = | { kind: "navigate"; @@ -159,6 +197,14 @@ function isAttentionItem(value: unknown): value is AttentionItem { || !Number.isSafeInteger(value.revision) || Number(value.revision) < 0 || !isNonEmptyString(value.fingerprint, 1_024) + || ( + value.activityTier !== undefined + && value.activityTier !== "signal" + && value.activityTier !== "ambient" + && value.activityTier !== "idle" + ) + || (value.contentFingerprint !== undefined && !isNonEmptyString(value.contentFingerprint, 1_024)) + || (value.alertFingerprint !== undefined && !isNonEmptyString(value.alertFingerprint, 1_024)) || (value.kind !== "agent" && value.kind !== "pull_request") || typeof value.eventKind !== "string" || !ATTENTION_EVENTS.has(value.eventKind) @@ -220,6 +266,7 @@ function isAttentionItem(value: unknown): value is AttentionItem { || !value.actions.every(isAttentionAction) || !isNonEmptyString(value.occurredAt, 128) || !isNonEmptyString(value.updatedAt, 128) + || !isNullableString(value.statusSince, 128) || !isNullableString(value.seenAt, 128) || !isNullableString(value.dismissedAt, 128) || !isNullableString(value.expiresAt, 128) @@ -229,6 +276,23 @@ function isAttentionItem(value: unknown): value is AttentionItem { return true; } +const ATTENTION_COUNT_KEYS = [ + "needsYou", + "working", + "done", + "total", + "machinesOnline", + "machinesTotal", +] as const; + +function isAttentionCounts(value: unknown): boolean { + if (!isRecord(value)) return false; + return ATTENTION_COUNT_KEYS.every((key) => { + const count = value[key]; + return Number.isSafeInteger(count) && Number(count) >= 0; + }); +} + export function parseAttentionNotchSnapshot(input: unknown): AttentionSnapshot | null { if (!isRecord(input)) return null; try { @@ -245,12 +309,51 @@ export function parseAttentionNotchSnapshot(input: unknown): AttentionSnapshot | || !Array.isArray(input.items) || input.items.length > MAX_NOTCH_ITEMS || !input.items.every(isAttentionItem) + || (input.itemsTruncated !== undefined && typeof input.itemsTruncated !== "boolean") + || (input.counts !== undefined && input.counts !== null && !isAttentionCounts(input.counts)) ) { return null; } return input as AttentionSnapshot; } +/** + * A toast is an event, not state: a malformed one is dropped rather than + * clamped, so a drifted renderer cannot quietly pin the surface open. + */ +export function parseAttentionNotchToast(input: unknown): AttentionNotchToast | null { + if (!isRecord(input)) return null; + if ( + typeof input.eventKind !== "string" + || !ATTENTION_EVENTS.has(input.eventKind) + || typeof input.treatment !== "string" + || !TOAST_TREATMENTS.has(input.treatment) + || !isNonEmptyString(input.title, MAX_TOAST_TITLE_LENGTH) + || !isNullableString(input.subtitle, MAX_TOAST_SUBTITLE_LENGTH) + || !isNullableString(input.itemId, MAX_TOAST_ITEM_ID_LENGTH) + || (input.tone != null && (typeof input.tone !== "string" || !TONES.has(input.tone))) + || ( + input.durationMs != null + && ( + !Number.isSafeInteger(input.durationMs) + || Number(input.durationMs) < ATTENTION_NOTCH_TOAST_MIN_DURATION_MS + || Number(input.durationMs) > ATTENTION_NOTCH_TOAST_MAX_DURATION_MS + ) + ) + ) { + return null; + } + return { + itemId: input.itemId == null ? null : String(input.itemId), + eventKind: input.eventKind as AttentionEventKind, + treatment: input.treatment as AttentionNotchToastTreatment, + title: input.title, + subtitle: input.subtitle == null ? null : String(input.subtitle), + tone: input.tone == null ? null : (input.tone as AttentionTone), + durationMs: input.durationMs == null ? null : Number(input.durationMs), + }; +} + export function parseAttentionNotchSettings(input: unknown): AttentionNotchSettings | null { if (!isRecord(input)) return null; if ( @@ -270,6 +373,11 @@ export function parseAttentionNotchSettings(input: unknown): AttentionNotchSetti input.expandedPanelEnabled !== undefined && typeof input.expandedPanelEnabled !== "boolean" ) + || ( + input.automaticRevealEnabled !== undefined + && typeof input.automaticRevealEnabled !== "boolean" + ) + || (input.tickerEnabled !== undefined && typeof input.tickerEnabled !== "boolean") ) { return null; } @@ -279,6 +387,8 @@ export function parseAttentionNotchSettings(input: unknown): AttentionNotchSetti ? input.revealMode : DEFAULT_ATTENTION_NOTCH_REVEAL_MODE, expandedPanelEnabled: input.expandedPanelEnabled !== false, + automaticRevealEnabled: input.automaticRevealEnabled !== false, + tickerEnabled: input.tickerEnabled !== false, preferredDisplayId: input.preferredDisplayId == null ? null : Number(input.preferredDisplayId), diff --git a/apps/desktop/src/main/services/deeplinks/ownerAwareNavigation.ts b/apps/desktop/src/main/services/deeplinks/ownerAwareNavigation.ts index 07afd3878..ace4c6558 100644 --- a/apps/desktop/src/main/services/deeplinks/ownerAwareNavigation.ts +++ b/apps/desktop/src/main/services/deeplinks/ownerAwareNavigation.ts @@ -92,19 +92,19 @@ export function ownerNavigationFailureCopy( return { title: "Update the owning ADE machine", message: "This item belongs to a machine running an incompatible ADE service.", - detail: `${detail}\n\nUpdate and restart ADE on that host, then retry from Attention.`, + detail: `${detail}\n\nUpdate and restart ADE on that host, then retry from Activity.`, }; } if (/project .* no longer available on this ADE machine/i.test(detail)) { return { title: "Project no longer available", message: "ADE found the owning machine, but that project is no longer registered there.", - detail: `${detail}\n\nOpen or restore the project on that machine, then retry from Attention.`, + detail: `${detail}\n\nOpen or restore the project on that machine, then retry from Activity.`, }; } return { title: "Owning machine unavailable", message: "ADE couldn’t open this item on the machine and project that own it.", - detail: `${detail}\n\nReconnect that machine from Connections, then retry from Attention.`, + detail: `${detail}\n\nReconnect that machine from Connections, then retry from Activity.`, }; } diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 66baa1f5f..9af99cbb5 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -20,6 +20,7 @@ import { IPC } from "../../../shared/ipc"; import type { AttentionItem, AttentionNotchSettings, + AttentionNotchToast, AttentionSnapshot, } from "../../../shared/types/attention"; import { ATTENTION_CONTRACT_VERSION } from "../../../shared/types/attention"; @@ -105,6 +106,7 @@ import { authorizeRecentProjectRuntimeRoot } from "../projects/recentProjectRunt import { parseAttentionNotchSettings, parseAttentionNotchSnapshot, + parseAttentionNotchToast, } from "../attention/attentionNotchRouter"; import { AttentionAccountCoordinator } from "../attention/attentionAccountCoordinator"; import type { @@ -1589,6 +1591,7 @@ export function registerIpc({ builtInBrowserService, productAnalyticsService, publishAttentionNotchSnapshot, + publishAttentionNotchToast, updateAttentionNotchSettings, getAttentionNotchHealth, retryAttentionNotch, @@ -1616,6 +1619,7 @@ export function registerIpc({ builtInBrowserService?: ReturnType | null; productAnalyticsService?: ProductAnalyticsService; publishAttentionNotchSnapshot?: (snapshot: AttentionSnapshot) => void; + publishAttentionNotchToast?: (toast: AttentionNotchToast) => void; updateAttentionNotchSettings?: (settings: AttentionNotchSettings) => void; getAttentionNotchHealth?: () => import("../../../shared/types").AttentionNotchHealth; retryAttentionNotch?: () => import("../../../shared/types").AttentionNotchHealth; @@ -1628,6 +1632,7 @@ export function registerIpc({ | "reportAttentionPresence" | "getAttentionPreferences" | "putAttentionPreferences" + | "putActivityMachinePreferences" > | null; }) { // Process-scoped by design: renderer reloads and additional windows in the @@ -1857,6 +1862,7 @@ export function registerIpc({ [IPC.accountRenameMachine]: new Set(["machineKey", "customName"]), [IPC.accountRemoveMachine]: new Set(["machineKey"]), [IPC.attentionNotchPublishSnapshot]: new Set(["items"]), + [IPC.attentionNotchPublishToast]: new Set(["title", "subtitle"]), }; const redactIpcArgsForChannel = (channel: string, args: unknown[]): unknown[] => { @@ -3204,13 +3210,19 @@ export function registerIpc({ ipcMain.handle(IPC.attentionNotchPublishSnapshot, async (_event, input: unknown) => { const snapshot = parseAttentionNotchSnapshot(input); - if (!snapshot) throw new Error("Invalid Attention Notch snapshot."); + if (!snapshot) throw new Error("Invalid ADE Notch snapshot."); publishAttentionNotchSnapshot?.(snapshot); }); + ipcMain.handle(IPC.attentionNotchPublishToast, async (_event, input: unknown) => { + const toast = parseAttentionNotchToast(input); + if (!toast) throw new Error("Invalid ADE Notch toast."); + publishAttentionNotchToast?.(toast); + }); + ipcMain.handle(IPC.attentionNotchUpdateSettings, async (_event, input: unknown) => { const settings = parseAttentionNotchSettings(input); - if (!settings) throw new Error("Invalid Attention Notch settings."); + if (!settings) throw new Error("Invalid ADE Notch settings."); updateAttentionNotchSettings?.(settings); }); @@ -3257,6 +3269,24 @@ export function registerIpc({ async (_event, input: unknown) => attentionAccountCoordinator.putPreferences(input), ); + ipcMain.handle( + IPC.attentionPutMachinePreferences, + async (_event, input: unknown) => { + const request = input && typeof input === "object" && !Array.isArray(input) + ? input as { + accountOwnerId?: unknown; + machineKey?: unknown; + preferences?: unknown; + } + : {}; + return attentionAccountCoordinator.putActivityMachinePreferences( + request.machineKey, + request.preferences, + request.accountOwnerId, + ); + }, + ); + ipcMain.handle(IPC.attentionOpenItem, async (_event, input: unknown) => { const snapshot = parseAttentionNotchSnapshot({ contractVersion: ATTENTION_CONTRACT_VERSION, @@ -3273,7 +3303,7 @@ export function registerIpc({ tombstones: [], }); const item = snapshot?.items[0] ?? null; - if (!item) throw new Error("Invalid Attention item."); + if (!item) throw new Error("Invalid Activity item."); await openAttentionItem?.(item); }); @@ -10574,7 +10604,7 @@ export function registerIpc({ windowId: number | null; }) { const machineKey = args.machineKey.trim(); - if (!machineKey) throw new Error("Attention machine identity is required."); + if (!machineKey) throw new Error("Activity machine identity is required."); let targetId = runtimeBridge.resolveTargetIdForMachineKey(machineKey); if (!targetId) { targetId = (await accountBridge.pairMachine(machineKey)).targetId; diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index 49f3c583a..0ceb8d1d8 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -1519,16 +1519,20 @@ describe("registerIpc sync bridge", () => { streamId: "account-stream", revision: 5, generatedAt: "2026-07-28T12:00:00.000Z", - items: [], + items: [{ id: "attention-1", revision: 5 } as never], tombstones: [], }; const callAttention = vi.fn(); const accountAttentionClient = { getAttentionSnapshot: vi.fn(async () => snapshot), - acknowledgeAttention: vi.fn(async () => ({})), + acknowledgeAttention: vi.fn(async () => ({ + applied: ["attention-1"], + stale: [], + })), reportAttentionPresence: vi.fn(async () => undefined), getAttentionPreferences: vi.fn(async () => ({ account: { hideDetails: true } })), putAttentionPreferences: vi.fn(async () => undefined), + putActivityMachinePreferences: vi.fn(async () => undefined), }; const openAttentionItem = vi.fn(async () => undefined); registerIpc({ @@ -1570,6 +1574,8 @@ describe("registerIpc sync bridge", () => { }); await ipcHandlers.get(IPC.attentionAcknowledge)?.(eventForSender(), { itemIds: ["attention-1"], + sourceRevisions: { "attention-1": 5 }, + expectedAccountOwnerId: "account-a", seenAt: "2026-07-28T12:01:00.000Z", }); await ipcHandlers.get(IPC.attentionReportPresence)?.(eventForSender(), { @@ -1588,6 +1594,14 @@ describe("registerIpc sync bridge", () => { preferences: { account: { hideDetails: false } }, }, ); + await ipcHandlers.get(IPC.attentionPutMachinePreferences)?.( + eventForSender(), + { + accountOwnerId: "account-a", + machineKey: "machine-a", + preferences: { notificationsEnabled: false }, + }, + ); const attentionItem: AttentionItem = { contractVersion: ATTENTION_CONTRACT_VERSION, id: "attention-1", @@ -1637,6 +1651,8 @@ describe("registerIpc sync bridge", () => { ); expect(accountAttentionClient.acknowledgeAttention).toHaveBeenCalledWith({ itemIds: ["attention-1"], + sourceRevisions: { "attention-1": 5 }, + expectedAccountOwnerId: "account-a", seenAt: "2026-07-28T12:01:00.000Z", }); expect(accountAttentionClient.getAttentionPreferences).toHaveBeenCalledWith("account-a"); @@ -1644,6 +1660,11 @@ describe("registerIpc sync bridge", () => { "account-a", { account: { hideDetails: false } }, ); + expect(accountAttentionClient.putActivityMachinePreferences).toHaveBeenCalledWith( + "account-a", + "machine-a", + { notificationsEnabled: false }, + ); expect(openAttentionItem).toHaveBeenCalledWith(attentionItem); openAttentionItem.mockRejectedValueOnce( diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 3b5a48248..a98d452c9 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -1203,6 +1203,11 @@ declare global { accountOwnerId: string, preferences: import("../shared/types").AttentionPreferences, ) => Promise; + putMachinePreferences?: ( + accountOwnerId: string, + machineKey: string, + preferences: Partial, + ) => Promise; openItem: ( item: import("../shared/types").AttentionItem, ) => Promise; @@ -1211,6 +1216,11 @@ declare global { publishSnapshot: ( snapshot: import("../shared/types").AttentionSnapshot, ) => Promise; + // Optional like `onRefreshRequested`: the web adapter has no notch at + // all, so every call site must optional-chain through it. + publishToast?: ( + toast: import("../shared/types").AttentionNotchToast, + ) => Promise; updateSettings: ( settings: import("../shared/types").AttentionNotchSettings, ) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index fe9006dd0..d2cced96d 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -6,6 +6,7 @@ import { type AttentionItem, type AttentionNotchAcknowledgeRequest, type AttentionNotchSettings, + type AttentionPreferenceScope, type AttentionPreferences, type AttentionPresence, type AttentionSnapshot, @@ -4875,6 +4876,16 @@ contextBridge.exposeInMainWorld("ade", { accountOwnerId, preferences, }), + putMachinePreferences: async ( + accountOwnerId: string, + machineKey: string, + preferences: Partial, + ): Promise => + ipcRenderer.invoke(IPC.attentionPutMachinePreferences, { + accountOwnerId, + machineKey, + preferences, + }), openItem: async (item: AttentionItem): Promise => { await ipcRenderer.invoke(IPC.attentionOpenItem, item); }, @@ -4882,6 +4893,9 @@ contextBridge.exposeInMainWorld("ade", { attentionNotch: { publishSnapshot: async (snapshot: AttentionSnapshot): Promise => ipcRenderer.invoke(IPC.attentionNotchPublishSnapshot, snapshot), + publishToast: async ( + toast: import("../shared/types").AttentionNotchToast, + ): Promise => ipcRenderer.invoke(IPC.attentionNotchPublishToast, toast), updateSettings: async (settings: AttentionNotchSettings): Promise => ipcRenderer.invoke(IPC.attentionNotchUpdateSettings, settings), getHealth: async (): Promise => diff --git a/apps/desktop/src/renderer/components/activity/Activity.css b/apps/desktop/src/renderer/components/activity/Activity.css new file mode 100644 index 000000000..408e33cfb --- /dev/null +++ b/apps/desktop/src/renderer/components/activity/Activity.css @@ -0,0 +1,1207 @@ +/* Activity chrome: the tone system every Activity surface shares, the row + chrome for `ActivityCard`, the expanded pane (split columns + slide-over + detail), and the settings popover. + + This replaces `AttentionCenter.css`, which was a 1761-line page stylesheet + for a route that no longer exists. Almost none of it survived on purpose: + the container changed from a full-page route to a modal, and the rows became + Tailwind `ActivityCard`s, so the `attention-item-*` / `attention-roster-*` / + `attention-breadcrumb-*` families died with the components that used them. + What was worth keeping — the tone table, the plan-progress bar, the recent + activity rail, and the settings popover — was copied by hand rather than + swept, because a mechanical `attention-` → `activity-` rename would have + produced `activity-activity-list`. + + Rows are Tailwind; only what needs a per-tone colour lives here, driven by + the `--tone-color` variable each `.activity-tone-*` class sets. */ + +/* ── Tones ────────────────────────────────────────────────────────────── + One hue, one meaning — the same table as + `shared/sessionStatusPresentation.ts`. Unscoped on purpose: a tone class + means the same thing in the header popover, the pane, and the detail sheet, + and scoping it per surface is how three copies drift apart. */ + +.activity-tone-amber { --tone-color: #fbbf24; } +.activity-tone-red { --tone-color: #f87171; } +.activity-tone-violet { --tone-color: #a78bfa; } +.activity-tone-blue { --tone-color: #60a5fa; } +.activity-tone-cyan { --tone-color: #22d3ee; } +.activity-tone-emerald { --tone-color: #34d399; } +.activity-tone-neutral { --tone-color: #a1a1aa; } + +/* 400-level tones sit near 1.7:1 on a white card; light mode uses the 600/700 + equivalents so pills and dots stay legible instead of washing out. */ +[data-theme="light"] .activity-tone-amber { --tone-color: #b45309; } +[data-theme="light"] .activity-tone-red { --tone-color: #dc2626; } +[data-theme="light"] .activity-tone-violet { --tone-color: #6d28d9; } +[data-theme="light"] .activity-tone-blue { --tone-color: #1d4ed8; } +[data-theme="light"] .activity-tone-cyan { --tone-color: #0e7490; } +[data-theme="light"] .activity-tone-emerald { --tone-color: #047857; } +[data-theme="light"] .activity-tone-neutral { --tone-color: #52525b; } + +/* ── Row chrome (the card body itself is Tailwind) ──────────────────── */ + +.activity-card { + --tone-color: #a1a1aa; + transition: background-color 120ms ease, box-shadow 120ms ease; +} + +.activity-card::before { + content: ""; + position: absolute; + left: 0; + top: 8px; + bottom: 8px; + width: 2px; + border-radius: 999px; + background: var(--tone-color); + opacity: 0; + transition: opacity 120ms ease; +} + +.activity-card:hover, +.activity-card:focus-visible, +.activity-card[data-selected="true"] { + background: color-mix(in srgb, var(--color-fg) 6%, transparent); + outline: none; +} + +.activity-card:hover::before, +.activity-card:focus-visible::before, +.activity-card[data-selected="true"]::before { + opacity: 1; +} + +.activity-card:focus-visible { + box-shadow: 0 0 0 1px color-mix(in srgb, var(--tone-color) 55%, transparent); +} + +/* The lane is the row's identity line. Accent, not tone: a lane's colour must + not change because its agent's phase did. */ +.activity-card-lane { + color: color-mix(in srgb, var(--color-accent) 82%, var(--color-fg)); +} + +.activity-card-unseen { + width: 6px; + height: 6px; + border-radius: 999px; + background: var(--tone-color); +} + +/* ── Pane shell ─────────────────────────────────────────────────────── */ + +.activity-pane { + --activity-fs-2xs: 10px; + --activity-fs-xs: 11px; + --activity-fs-sm: 12px; + --activity-fs-md: 13px; + --activity-hairline: color-mix(in srgb, var(--color-border) 70%, transparent); + --activity-surface: color-mix(in srgb, var(--color-card) 94%, var(--color-bg)); + --activity-sunken: color-mix(in srgb, var(--color-bg) 45%, transparent); + --tone-color: #a1a1aa; + color: var(--color-fg); + background: var(--activity-surface); +} + +.activity-pane:focus-visible, +.activity-pane [role="dialog"]:focus-visible { + outline: none; +} + +.activity-pane-head { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 8px; + padding: 10px 10px 10px 14px; + border-bottom: 1px solid var(--activity-hairline); +} + +.activity-pane-head h2 { + margin: 0; + flex-shrink: 0; + font-size: 14px; + font-weight: 650; + letter-spacing: -0.01em; +} + +.activity-pane-machines { + min-width: 0; + flex: 1; + overflow: hidden; + color: var(--color-muted-fg); + font-size: var(--activity-fs-xs); + text-overflow: ellipsis; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.activity-pane-freshness { + display: inline-flex; + flex-shrink: 0; + align-items: center; + gap: 4px; + padding: 3px 7px; + border: 1px solid var(--activity-hairline); + border-radius: 999px; + background: color-mix(in srgb, var(--color-card) 60%, transparent); + color: var(--color-muted-fg); + font-size: var(--activity-fs-2xs); + font-weight: 600; +} + +button.activity-pane-freshness { + cursor: pointer; +} + +.activity-pane-freshness.is-error { + border-color: color-mix(in srgb, var(--color-error, #ef4444) 45%, transparent); + color: var(--color-error, #ef4444); +} + +.activity-pane-icon-button { + display: inline-flex; + height: 24px; + width: 24px; + flex-shrink: 0; + align-items: center; + justify-content: center; + border-radius: 7px; + color: var(--color-muted-fg); + transition: background-color 120ms ease, color 120ms ease; +} + +.activity-pane-icon-button:hover, +.activity-pane-icon-button:focus-visible { + background: color-mix(in srgb, var(--color-fg) 8%, transparent); + color: var(--color-fg); + outline: none; +} + +.activity-pane-spin { + animation: activity-spin 1.1s linear infinite; +} + +@keyframes activity-spin { + to { transform: rotate(360deg); } +} + +.activity-pane-alert { + display: flex; + flex-shrink: 0; + align-items: flex-start; + gap: 7px; + padding: 8px 14px; + border-bottom: 1px solid var(--activity-hairline); + background: color-mix(in srgb, var(--color-error, #ef4444) 10%, transparent); + color: var(--color-error, #ef4444); + font-size: var(--activity-fs-xs); + line-height: 1.45; +} + +/* ── Filters ────────────────────────────────────────────────────────── */ + +.activity-filters { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-bottom: 1px solid var(--activity-hairline); +} + +.activity-filter { + position: relative; +} + +.activity-filter-trigger { + display: inline-flex; + align-items: center; + gap: 5px; + height: 24px; + padding: 0 8px; + border: 1px solid var(--activity-hairline); + border-radius: 999px; + background: color-mix(in srgb, var(--color-card) 60%, transparent); + color: var(--color-muted-fg); + font-size: var(--activity-fs-xs); + font-weight: 600; + transition: color 120ms ease, border-color 120ms ease, background-color 120ms ease; +} + +.activity-filter-trigger:hover, +.activity-filter-trigger:focus-visible { + color: var(--color-fg); + outline: none; +} + +.activity-filter-trigger[data-active="true"] { + border-color: color-mix(in srgb, var(--color-accent) 45%, transparent); + background: color-mix(in srgb, var(--color-accent) 14%, transparent); + color: var(--color-accent); +} + +.activity-filter-menu { + position: absolute; + z-index: 5; + top: calc(100% + 5px); + left: 0; + display: flex; + min-width: 190px; + max-height: 280px; + flex-direction: column; + gap: 1px; + overflow-y: auto; + padding: 4px; + border: 1px solid var(--activity-hairline); + border-radius: 10px; + background: color-mix(in srgb, var(--color-card) 97%, var(--color-bg)); + box-shadow: 0 22px 55px -25px rgba(0, 0, 0, 0.75); +} + +.activity-filter-option { + display: flex; + align-items: center; + gap: 7px; + padding: 6px 7px; + border-radius: 7px; + color: var(--color-fg); + font-size: var(--activity-fs-sm); + text-align: left; +} + +.activity-filter-option:hover, +.activity-filter-option:focus-visible { + background: color-mix(in srgb, var(--color-fg) 6%, transparent); + outline: none; +} + +.activity-filter-option > span:first-child { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.activity-filter-clear { + flex-shrink: 0; + padding: 0 6px; + color: var(--color-muted-fg); + font-size: var(--activity-fs-xs); + font-weight: 600; +} + +.activity-filter-clear:hover, +.activity-filter-clear:focus-visible { + color: var(--color-fg); + outline: none; + text-decoration: underline; +} + +/* ── Split body ─────────────────────────────────────────────────────── */ + +.activity-pane-body { + position: relative; + display: grid; + min-height: 0; + flex: 1; + grid-template-columns: minmax(0, 1.55fr) minmax(280px, 1fr); + overflow: hidden; +} + +.activity-column { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + overflow: hidden; +} + +.activity-column + .activity-column { + border-left: 1px solid var(--activity-hairline); + background: var(--activity-sunken); +} + +.activity-column-head { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 8px; + padding: 9px 12px 8px; + border-bottom: 1px solid var(--activity-hairline); +} + +.activity-column-head h3 { + margin: 0; + font-size: var(--activity-fs-2xs); + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--color-muted-fg); +} + +.activity-column-head-count { + flex: 1; + color: var(--color-muted-fg); + font-family: var(--font-mono); + font-size: var(--activity-fs-2xs); + font-variant-numeric: tabular-nums; +} + +.activity-column-action { + flex-shrink: 0; + padding: 2px 6px; + border-radius: 6px; + color: var(--color-muted-fg); + font-size: var(--activity-fs-xs); + font-weight: 600; + transition: color 120ms ease, background-color 120ms ease; +} + +.activity-column-action:hover, +.activity-column-action:focus-visible { + color: var(--color-fg); + background: color-mix(in srgb, var(--color-fg) 6%, transparent); + outline: none; +} + +.activity-column-scroll { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 1px; + overflow-y: auto; + padding: 5px; +} + +.activity-section-heading { + position: sticky; + top: -5px; + z-index: 1; + display: flex; + align-items: center; + gap: 6px; + margin: 0; + padding: 8px 7px 5px; + background: color-mix(in srgb, var(--color-card) 94%, var(--color-bg)); + color: var(--color-muted-fg); + font-size: var(--activity-fs-2xs); + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.activity-section-dot { + width: 6px; + height: 6px; + flex-shrink: 0; + border-radius: 999px; + background: var(--tone-color); +} + +.activity-section-count { + color: var(--tone-color); + font-family: var(--font-mono); + font-size: var(--activity-fs-2xs); + font-variant-numeric: tabular-nums; +} + +/* An offline machine's rows are memory, not observation: label the boundary + once and dim what follows rather than repeating a warning per row. */ +.activity-offline-divider { + display: flex; + align-items: center; + gap: 6px; + margin: 6px 7px 3px; + padding-top: 6px; + border-top: 1px dashed color-mix(in srgb, var(--color-border) 80%, transparent); + color: var(--color-muted-fg); + font-size: var(--activity-fs-2xs); + font-weight: 600; +} + +.activity-offline-group { + display: flex; + flex-direction: column; + gap: 1px; + opacity: 0.55; +} + +.activity-more { + align-self: flex-start; + margin: 4px 0 6px 8px; + padding: 3px 7px; + border-radius: 7px; + color: var(--color-muted-fg); + font-size: var(--activity-fs-xs); + font-weight: 600; +} + +.activity-more:hover, +.activity-more:focus-visible { + color: var(--color-fg); + background: color-mix(in srgb, var(--color-fg) 6%, transparent); + outline: none; +} + +/* ── Inbox rows ─────────────────────────────────────────────────────── */ + +.activity-inbox-row { + position: relative; + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 7px 8px; + border-radius: 9px; + text-align: left; + transition: background-color 120ms ease; +} + +.activity-inbox-row:hover, +.activity-inbox-row:focus-within { + background: color-mix(in srgb, var(--color-fg) 6%, transparent); +} + +.activity-inbox-open { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + gap: 8px; + text-align: left; +} + +.activity-inbox-open:focus-visible { + outline: none; +} + +.activity-inbox-icon { + display: inline-flex; + height: 24px; + width: 24px; + flex-shrink: 0; + align-items: center; + justify-content: center; + border-radius: 7px; + border: 1px solid color-mix(in srgb, var(--tone-color) 26%, transparent); + background: color-mix(in srgb, var(--tone-color) 11%, transparent); + color: var(--tone-color); +} + +.activity-inbox-copy { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; +} + +.activity-inbox-copy strong { + overflow: hidden; + font-size: var(--activity-fs-sm); + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.activity-inbox-copy span { + overflow: hidden; + color: var(--color-muted-fg); + font-size: var(--activity-fs-xs); + text-overflow: ellipsis; + white-space: nowrap; +} + +.activity-inbox-dismiss { + display: inline-flex; + height: 22px; + width: 22px; + flex-shrink: 0; + align-items: center; + justify-content: center; + border-radius: 6px; + color: var(--color-muted-fg); + opacity: 0; + transition: opacity 120ms ease, background-color 120ms ease, color 120ms ease; +} + +.activity-inbox-row:hover .activity-inbox-dismiss, +.activity-inbox-dismiss:focus-visible { + opacity: 1; +} + +.activity-inbox-dismiss:hover, +.activity-inbox-dismiss:focus-visible { + background: color-mix(in srgb, var(--color-fg) 9%, transparent); + color: var(--color-fg); + outline: none; +} + +/* ── Detail sheet ───────────────────────────────────────────────────── */ + +/* The sheet slides over both columns rather than replacing one, so the list + you came from stays where your eye left it. */ +.activity-sheet-scrim { + position: absolute; + inset: 0; + z-index: 2; + background: color-mix(in srgb, var(--color-bg) 55%, transparent); + backdrop-filter: blur(2px); +} + +.activity-sheet { + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 3; + display: flex; + width: 62%; + min-width: 380px; + flex-direction: column; + overflow: hidden; + border-left: 1px solid var(--activity-hairline); + background: color-mix(in srgb, var(--color-card) 98%, var(--color-bg)); + box-shadow: -24px 0 60px -32px rgba(0, 0, 0, 0.75); + transform: translateX(0); +} + +@media (prefers-reduced-motion: no-preference) { + .activity-sheet { + animation: activity-sheet-in 180ms cubic-bezier(0.2, 0.8, 0.2, 1); + } +} + +@keyframes activity-sheet-in { + from { transform: translateX(14px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +.activity-sheet-head { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 8px; + padding: 9px 10px 9px 8px; + border-bottom: 1px solid var(--activity-hairline); +} + +.activity-sheet-breadcrumb { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + gap: 5px; + overflow: hidden; + color: var(--color-muted-fg); + font-size: var(--activity-fs-xs); + text-overflow: ellipsis; + white-space: nowrap; +} + +.activity-sheet-body { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 16px; + overflow-y: auto; + padding: 16px; +} + +.activity-sheet-title { + margin: 0; + font-size: 16px; + font-weight: 640; + letter-spacing: -0.01em; +} + +.activity-sheet-kicker { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + color: var(--tone-color); + font-size: var(--activity-fs-xs); + font-weight: 650; +} + +.activity-sheet-note { + margin: 8px 0 0; + color: var(--color-muted-fg); + font-size: var(--activity-fs-md); + font-style: italic; + line-height: 1.55; + overflow-wrap: anywhere; +} + +.activity-sheet-meta { + display: flex; + flex-wrap: wrap; + gap: 6px 10px; + color: var(--color-muted-fg); + font-size: var(--activity-fs-xs); +} + +.activity-sheet-meta > span { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.activity-sheet-section h4 { + margin: 0 0 8px; + color: var(--color-muted-fg); + font-size: var(--activity-fs-2xs); + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.activity-sheet-actions { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.activity-action { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 11px; + border: 1px solid var(--activity-hairline); + border-radius: 8px; + color: var(--color-fg); + font-size: var(--activity-fs-sm); + font-weight: 600; + transition: background-color 120ms ease, border-color 120ms ease; +} + +.activity-action[data-tone="primary"] { + border-color: color-mix(in srgb, var(--color-accent) 55%, transparent); + background: color-mix(in srgb, var(--color-accent) 70%, var(--color-accent-deep, var(--color-accent))); + color: #fff; +} + +.activity-action[data-tone="danger"] { + border-color: color-mix(in srgb, #f87171 45%, transparent); + color: #f87171; +} + +.activity-action[data-tone="secondary"]:hover, +.activity-action[data-tone="ghost"]:hover { + background: color-mix(in srgb, var(--color-fg) 7%, transparent); +} + +.activity-action:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.activity-progress-track { + height: 5px; + margin-top: 10px; + overflow: hidden; + border-radius: 99px; + background: color-mix(in srgb, var(--color-fg) 8%, transparent); +} + +.activity-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); + transition: width 260ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.activity-plan-current { + margin: 9px 0 0; + color: var(--color-muted-fg); + font-size: var(--activity-fs-md); + overflow-wrap: anywhere; +} + +/* A vertical rail through the bullets: the list is a sequence, and the rail is + what says so without numbering it. */ +.activity-recent { + position: relative; + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; +} + +.activity-recent::before { + content: ""; + position: absolute; + top: 10px; + bottom: 10px; + left: 3px; + width: 1px; + background: color-mix(in srgb, var(--color-border) 85%, transparent); +} + +.activity-recent li { + position: relative; + display: flex; + gap: 9px; + padding: 5px 0; + color: color-mix(in srgb, var(--color-muted-fg) 92%, transparent); + font-size: var(--activity-fs-md); + line-height: 1.45; + overflow-wrap: anywhere; +} + +.activity-recent-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)); +} + +.activity-sheet-banner { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 9px 11px; + border: 1px solid color-mix(in srgb, var(--color-border) 80%, transparent); + border-radius: 10px; + background: color-mix(in srgb, var(--color-fg) 4%, transparent); + color: var(--color-muted-fg); + font-size: var(--activity-fs-xs); + line-height: 1.45; +} + +.activity-sheet-banner[data-tone="error"] { + border-color: color-mix(in srgb, #f87171 30%, transparent); + background: color-mix(in srgb, #f87171 8%, transparent); + color: #f87171; +} + +/* ── Empty states ───────────────────────────────────────────────────── */ + +.activity-empty { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 40px 26px; + text-align: center; + color: var(--color-muted-fg); +} + +.activity-empty strong { + color: var(--color-fg); + font-size: var(--activity-fs-md); + font-weight: 650; +} + +.activity-empty p { + margin: 0; + max-width: 32ch; + font-size: var(--activity-fs-xs); + line-height: 1.5; +} + +/* All-clear is a state worth designing, not a gap to apologise for. */ +.activity-calm-dot { + position: relative; + width: 9px; + height: 9px; + margin-bottom: 4px; + border-radius: 999px; + background: color-mix(in srgb, #34d399 78%, transparent); +} + +.activity-calm-dot::after { + content: ""; + position: absolute; + inset: -6px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, #34d399 26%, transparent); + animation: activity-calm 3.6s ease-in-out infinite; +} + +@keyframes activity-calm { + 0%, 100% { opacity: 0.55; transform: scale(0.9); } + 50% { opacity: 0.15; transform: scale(1.12); } +} + +/* ── Settings popover ─────────────────────────────────────────────────── + Moved verbatim (bar the tokens it used to inherit from the center's page + scope) so the gear behaves the same wherever it is mounted. */ + +.activity-settings-wrap { + position: relative; +} + +.activity-settings-trigger { + display: inline-flex; + width: 24px; + height: 24px; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 7px; + background: transparent; + color: color-mix(in srgb, var(--color-muted-fg) 92%, transparent); + transition: color 140ms ease, border-color 140ms ease, background 140ms ease, transform 140ms ease; +} + +.activity-settings-trigger:hover, +.activity-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)); +} + +.activity-settings-trigger:active { + transform: scale(0.94); +} + +.activity-settings-popover { + position: absolute; + top: calc(100% + 9px); + right: 0; + z-index: 80; + width: min(400px, calc(100vw - 32px)); + max-height: min(560px, calc(100vh - 120px)); + overflow-y: auto; + border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent); + border-radius: 16px; + background: color-mix(in srgb, var(--color-card) 97%, var(--color-bg)); + box-shadow: 0 30px 80px -32px rgba(0, 0, 0, 0.86); + backdrop-filter: blur(30px) saturate(1.25); + transform-origin: top right; + color: var(--color-fg); +} + +.activity-settings-popover:focus { + outline: none; +} + +.activity-settings-popover > header { + position: sticky; + top: 0; + z-index: 1; + display: flex; + min-height: 52px; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 11px 13px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 68%, transparent); + background: color-mix(in srgb, var(--color-card) 96%, var(--color-bg)); +} + +.activity-settings-popover > header > div { + display: flex; + min-width: 0; + align-items: center; + gap: 9px; +} + +.activity-settings-popover > header > div > span:last-child { + display: flex; + min-width: 0; + flex-direction: column; +} + +.activity-settings-popover > header strong { + font-size: 12px; + font-weight: 660; + letter-spacing: -0.01em; +} + +.activity-settings-popover > header small { + margin-top: 2px; + color: color-mix(in srgb, var(--color-muted-fg) 88%, transparent); + font-size: 11px; +} + +.activity-settings-heading-icon { + display: inline-flex; + width: 31px; + height: 31px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + 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); + color: var(--color-accent-bright, var(--color-accent)); +} + +.activity-settings-account-badge { + flex: 0 0 auto; + padding: 3px 7px; + 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); + color: color-mix(in srgb, var(--color-accent) 55%, var(--color-fg)); + font-size: 10px; + font-weight: 650; + letter-spacing: 0.02em; +} + +.activity-settings-popover section { + padding: 10px 10px 6px; +} + +.activity-settings-popover section + section { + padding-top: 9px; + border-top: 1px solid color-mix(in srgb, var(--color-border) 68%, transparent); +} + +.activity-settings-popover section h3 { + margin: 0 0 5px 3px; + color: color-mix(in srgb, var(--color-muted-fg) 88%, transparent); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.activity-settings-row { + display: grid; + min-height: 44px; + grid-template-columns: 30px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + padding: 7px; + border-radius: 10px; + transition: background 130ms ease; +} + +.activity-settings-row:hover { + background: color-mix(in srgb, var(--color-fg) 4%, transparent); +} + +.activity-settings-row[data-disabled] { + opacity: 0.5; +} + +.activity-settings-row-icon { + display: inline-flex; + width: 29px; + height: 29px; + align-items: center; + justify-content: center; + 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); + color: color-mix(in srgb, var(--color-accent) 43%, var(--color-muted-fg)); +} + +.activity-settings-row-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.activity-settings-row-copy > span { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; +} + +.activity-settings-row-copy strong { + font-size: 12px; + font-weight: 630; +} + +.activity-settings-row-copy small { + flex: 0 0 auto; + padding: 2px 5px; + border-radius: 4px; + background: color-mix(in srgb, var(--color-accent) 10%, transparent); + color: color-mix(in srgb, var(--color-accent) 50%, var(--color-fg)); + font-size: 10px; + font-weight: 650; +} + +.activity-settings-row-copy em { + margin-top: 3px; + color: color-mix(in srgb, var(--color-muted-fg) 88%, transparent); + font-size: 11px; + font-style: normal; + line-height: 1.35; +} + +.activity-settings-row select { + width: 148px; + height: 27px; + padding: 0 7px; + border: 1px solid color-mix(in srgb, var(--color-border) 68%, transparent); + border-radius: 7px; + background: color-mix(in srgb, var(--color-bg) 62%, var(--color-card)); + color: var(--color-fg); + font-family: var(--font-sans); + font-size: 11px; +} + +.activity-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); + transition: border-color 150ms ease, background 150ms ease; +} + +.activity-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; +} + +.activity-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))); +} + +.activity-settings-switch[aria-checked="true"] > span { + background: #fff; + transform: translateX(13px); +} + +.activity-settings-machines { + display: flex; + flex-direction: column; + gap: 1px; +} + +.activity-settings-loading { + display: flex; + min-height: 180px; + align-items: center; + justify-content: center; + gap: 9px; + color: color-mix(in srgb, var(--color-muted-fg) 88%, transparent); + font-size: 13px; +} + +.activity-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: activity-spin 700ms linear infinite; +} + +.activity-settings-error { + display: flex; + align-items: flex-start; + gap: 7px; + margin: 5px 10px 8px; + padding: 8px 9px; + border: 1px solid color-mix(in srgb, #f87171 26%, transparent); + border-radius: 8px; + background: color-mix(in srgb, #f87171 7%, transparent); + color: #f87171; + font-size: 11px; + line-height: 1.4; +} + +.activity-settings-error svg { + flex: 0 0 auto; + margin-top: 1px; +} + +.activity-settings-popover > footer { + position: sticky; + bottom: 0; + display: flex; + min-height: 46px; + align-items: center; + gap: 7px; + padding: 9px 10px; + border-top: 1px solid color-mix(in srgb, var(--color-border) 68%, transparent); + background: color-mix(in srgb, var(--color-card) 96%, var(--color-bg)); +} + +.activity-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: 11px; +} + +.activity-settings-open-full { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + margin-top: 4px; + padding: 8px 10px; + border: 1px solid color-mix(in srgb, var(--color-border) 85%, transparent); + border-radius: 9px; + background: color-mix(in srgb, var(--color-fg) 4%, transparent); + color: var(--color-secondary-fg); + font-size: 12px; + font-weight: 500; + transition: background 120ms ease, color 120ms ease; +} + +.activity-settings-open-full:hover, +.activity-settings-open-full:focus-visible { + color: var(--color-fg); + background: color-mix(in srgb, var(--color-fg) 8%, transparent); + outline: none; +} + +/* ── Responsive and motion ──────────────────────────────────────────── */ + +/* Below this the inbox column cannot hold a title and a meta line side by side + with the sessions list, so the sheet takes the whole width instead of + leaving a 120px sliver of columns nobody can read. */ +@media (max-width: 1080px) { + .activity-pane-body { + grid-template-columns: minmax(0, 1fr) minmax(240px, 0.8fr); + } + + .activity-sheet { + width: 100%; + min-width: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .activity-pane *, + .activity-pane *::before, + .activity-pane *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } + + .activity-calm-dot::after { + animation: none; + } +} diff --git a/apps/desktop/src/renderer/components/activity/ActivityCard.test.tsx b/apps/desktop/src/renderer/components/activity/ActivityCard.test.tsx new file mode 100644 index 000000000..315a41ed3 --- /dev/null +++ b/apps/desktop/src/renderer/components/activity/ActivityCard.test.tsx @@ -0,0 +1,145 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + ATTENTION_CONTRACT_VERSION, + type AttentionItem, + type AttentionPhase, +} from "../../../shared/types"; +import { ActivityCard } from "./ActivityCard"; + +afterEach(cleanup); + +function item(patch: Partial = {}): AttentionItem { + return { + contractVersion: ATTENTION_CONTRACT_VERSION, + id: "item-a", + revision: 1, + fingerprint: "fingerprint-item-a", + kind: "agent", + eventKind: "agent_running", + phase: "running" as AttentionPhase, + machine: { + machineKey: "studio", + name: "Studio Mac", + online: true, + lastSeenAt: "2026-08-01T11:59:00.000Z", + }, + project: { projectId: "ade", name: "ADE", rootPath: "/repo/ade" }, + laneId: "lane-1", + laneName: "attention-revamp", + provider: "codex", + model: "gpt-5.6-sol", + title: "Rewrite the header popover", + preview: "Editing HeaderActivityControl.tsx", + privacyPreview: "Agent is working", + destination: { kind: "session", sessionId: "session-a" }, + actions: [], + occurredAt: "2026-08-01T11:58:00.000Z", + updatedAt: "2026-08-01T11:59:30.000Z", + seenAt: null, + dismissedAt: null, + expiresAt: null, + ...patch, + }; +} + +describe("ActivityCard", () => { + it("renders the whole row: lane, machine, status, title, note, and model", () => { + render(); + + const row = screen.getByRole("button"); + expect(row.getAttribute("data-activity-row")).toBe("item-a"); + expect(screen.getByText("attention-revamp")).toBeTruthy(); + expect(screen.getByText("Studio Mac")).toBeTruthy(); + expect(screen.getByText("Rewrite the header popover")).toBeTruthy(); + expect(screen.getByText("Editing HeaderActivityControl.tsx")).toBeTruthy(); + expect(screen.getByText("gpt-5.6-sol")).toBeTruthy(); + // The shared status vocabulary, not a second table: a running agent reads + // exactly as it does on a Work sidebar row, elapsed ticker included. + expect(screen.getByRole("status").textContent).toBe("Working"); + expect(row.getAttribute("data-activity-tone")).toBe("blue"); + }); + + it("anchors elapsed on statusSince so a cosmetic republish cannot reset it", () => { + const { container } = render( + , + ); + + expect(container.textContent).toContain("1m"); + }); + + it("shows only the redacted preview when hide-details is on", () => { + render(); + + expect(screen.getByText("Agent is working")).toBeTruthy(); + expect(screen.queryByText("Editing HeaderActivityControl.tsx")).toBeNull(); + }); + + it("dims an offline machine's row and says when it was last seen", () => { + render( + , + ); + + const chip = screen.getByText("MacBook Pro").parentElement; + expect(chip?.getAttribute("data-machine-online")).toBe("false"); + expect(chip?.getAttribute("title")).toContain("offline"); + expect(screen.getByRole("button").className).toContain("opacity-70"); + }); + + it("falls back to the project name when an item has no lane", () => { + render(); + + expect(screen.getByText("ADE")).toBeTruthy(); + }); + + it("hands the whole item back on open and does nothing else", () => { + const onOpen = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button")); + + expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ id: "item-a" })); + }); + + it("keeps the compact form to two lines without losing the status word", () => { + const { container } = render(); + + expect(container.querySelector(".h-\\[2\\.75rem\\]")).toBeTruthy(); + expect(container.querySelector(".h-\\[4\\.875rem\\]")).toBeNull(); + expect(screen.getByRole("status").textContent).toBe("Working"); + }); + + it("marks a pull request seen-state without pretending it has a provider", () => { + render( + , + ); + + expect(screen.getByRole("status").textContent).toBe("Ready to merge"); + expect(screen.queryByLabelText("Unseen")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/components/activity/ActivityCard.tsx b/apps/desktop/src/renderer/components/activity/ActivityCard.tsx new file mode 100644 index 000000000..d5d67e8b6 --- /dev/null +++ b/apps/desktop/src/renderer/components/activity/ActivityCard.tsx @@ -0,0 +1,236 @@ +import React from "react"; +import { DesktopTower, GitPullRequest, Laptop } from "@phosphor-icons/react"; + +import type { AttentionItem } from "../../../shared/types"; +import { relativeWhen } from "../../lib/format"; +import { ProviderLogo } from "../shared/ProviderLogos"; +import { SessionStatusLabel } from "../terminals/SessionStatusLabel"; +import { LaneIcon } from "../ui/vcsIcons"; +import { cn } from "../ui/cn"; +import { activityItemPresentation } from "./activityPresentation"; +// The row carries its own chrome and the shared tone table, so every surface +// that can render an `ActivityCard` gets both without importing a stylesheet +// it does not otherwise use. +import "./Activity.css"; + +/* ── Why this is not `terminals/SessionCard` ─────────────────────────────── + Three reasons, all of them load-bearing. Anyone tempted to "simplify" this + file by adapting an `AttentionItem` into a `TerminalSessionSummary` should + read them first — the shortcut is not cosmetic, it is a correctness bug. + + 1. WRONG-MACHINE MUTATIONS. `SessionCard` renders `SessionStatusSlot`, which + owns `SessionSnoozeControl` and calls `settleSession` / `unsettleSession` + against THIS Mac's local session service. An Activity row very often + belongs to another machine on the account. A fabricated summary would put + live settle/snooze buttons on it, and because session ids are not + globally unique per machine, the mutation could land on a same-id LOCAL + session. The status vocabulary is shared instead, through the pure + `SessionStatusLabel` extracted from that slot — words and hues cannot + drift, and no IPC comes along for the ride. + + 2. GEOMETRY IS A CONTRACT, NOT A STYLE. `SESSION_ROW_BLEED_CLASS` pays back a + measured 12px of `SessionListPane` ancestor inset plus a 6px webkit + scrollbar term, and it must sit on the element carrying + `content-visibility` because that implies paint containment. Inside a + popover with different padding the row would bleed under the panel + border. Activity rows are plain Tailwind and inset-neutral. + + 3. THE ADAPTER WOULD BE FICTION. `SessionCard` reads ~30 fields + `AttentionItem` does not have (`statusNote`, `lastOutputPreview`, `goal`, + `snoozedUntil`, `exitCode`, `runtimeState`, `orchestration*`, a whole + `LaneSummary`, …). It also pulls `useSessionDelta`, `useLaneNaming`, the + app store's project binding, and a work-grid drag source — every one of + them scoped to the open project, i.e. wrong for an account-wide feed. + + What IS shared: `shared/sessionStatusPresentation.ts` (the one-hue-one-meaning + table), `SessionStatusLabel`, and `activityItemPresentation()`. That is the + whole of the vocabulary and none of the machinery. + ────────────────────────────────────────────────────────────────────────── */ + +/** + * Machines only tell us their name, so the glyph is a read of that name rather + * than a hardware fact. It is decoration either way — the name beside it is the + * identity, and the chip is deliberately neutral because amber means "your + * move" everywhere in Activity. + */ +function MachineGlyph({ name, size }: { name: string; size: number }) { + const portable = /\b(?:macbook|laptop|air|book)\b/i.test(name); + const Glyph = portable ? Laptop : DesktopTower; + return ; +} + +function ActivityMachineChip({ item, size }: { item: AttentionItem; size: number }) { + const online = item.machine.online; + return ( + + + {item.machine.name} + + ); +} + +/** The provider mark, or the PR glyph for pull-request rows. */ +function ActivityAvatar({ item, size }: { item: AttentionItem; size: number }) { + if (item.kind === "pull_request") { + return ( + + ); + } + return ( + + ); +} + +/** + * The status note. `preview` is the agent's own words; `detail` is the + * publisher's fallback sentence. When the account has hide-details on, the + * publisher's already-redacted `privacyPreview` is the only line allowed out. + */ +export function activityCardPreview(item: AttentionItem, hideDetails: boolean): string { + if (hideDetails) return item.privacyPreview.trim(); + return (item.preview || item.detail || item.privacyPreview || "").trim(); +} + +export type ActivityCardProps = { + item: AttentionItem; + /** The row's only side effect. Navigation and acknowledgment live upstream. */ + onOpen: (item: AttentionItem) => void; + /** Mirrors the account's `hideDetails` preference. */ + hideDetails?: boolean; + /** Two-line form for dense mirrors (notch panel, mobile hub strip). */ + compact?: boolean; + /** Keeps the hover treatment on the row whose detail is open. */ + selected?: boolean; +}; + +/** + * One Activity row. `AttentionItem` in, `onOpen` out — no store reads, no IPC, + * no project scoping, so the same row renders in the header popover, the pane, + * and any surface that can hand it an item. + */ +export function ActivityCard({ + item, + onOpen, + hideDetails = false, + compact = false, + selected = false, +}: ActivityCardProps) { + const presentation = activityItemPresentation(item); + const tone = presentation?.tone ?? "neutral"; + const preview = activityCardPreview(item, hideDetails); + const laneLabel = item.laneName?.trim() || item.project.name; + const statusLabel = ( + + ); + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/components/activity/ActivityCardSkeleton.tsx b/apps/desktop/src/renderer/components/activity/ActivityCardSkeleton.tsx new file mode 100644 index 000000000..4a7d70e02 --- /dev/null +++ b/apps/desktop/src/renderer/components/activity/ActivityCardSkeleton.tsx @@ -0,0 +1,35 @@ +import { cn } from "../ui/cn"; + +/** + * Same fixed heights as `ActivityCard`, so a popover that opens before the + * first snapshot lands does not resize under the pointer when it arrives. + */ +export function ActivityCardSkeleton({ compact = false }: { compact?: boolean }) { + const bar = "rounded-full bg-white/[0.07] motion-safe:animate-pulse"; + return ( +
+
+ + +
+
+ +
+ {compact ? null : ( +
+ + +
+ )} +
+ ); +} + +export default ActivityCardSkeleton; diff --git a/apps/desktop/src/renderer/components/activity/ActivityDetailSheet.tsx b/apps/desktop/src/renderer/components/activity/ActivityDetailSheet.tsx new file mode 100644 index 000000000..e232dc37b --- /dev/null +++ b/apps/desktop/src/renderer/components/activity/ActivityDetailSheet.tsx @@ -0,0 +1,273 @@ +import React, { useEffect, useRef } from "react"; +import { + ArrowClockwise, + ArrowSquareOut, + CaretLeft, + Check, + CheckCircle, + Lightning, + WarningCircle, + WifiSlash, + X, + XCircle, +} from "@phosphor-icons/react"; + +import type { AttentionAction, AttentionItem } from "../../../shared/types"; +import { relativeWhen } from "../../lib/format"; +import { ProviderLogo } from "../shared/ProviderLogos"; +import { SessionStatusLabel } from "../terminals/SessionStatusLabel"; +import { cn } from "../ui/cn"; +import { activityCardPreview } from "./ActivityCard"; +import { activityItemPresentation, activityActionTone } from "./activityPresentation"; + +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; +} + +/** Seen and dismiss are local bookkeeping; everything else needs the machine. */ +function actionWorksOffline(action: AttentionAction): boolean { + return action.kind === "mark_seen" || action.kind === "dismiss"; +} + +/** + * The slide-over detail. It covers the columns rather than replacing one, so + * the row you came from is still where you left it when you close the sheet. + * + * There is no placeholder twin of this component. The old center rendered a + * "Ready when you are" card whenever nothing was selected and again whenever a + * selected item happened to carry no detail — an apology for a state that only + * ever meant "you have not clicked anything yet". Nothing selected now renders + * nothing at all. + */ +export function ActivityDetailSheet({ + item, + hideDetails, + pendingActionId, + errorMessage, + onClose, + onOpen, + onAction, +}: { + item: AttentionItem; + hideDetails: boolean; + pendingActionId: string | null; + errorMessage: string | null; + onClose: () => void; + onOpen: (item: AttentionItem) => void; + onAction: (item: AttentionItem, action: AttentionAction) => void; +}) { + const sheetRef = useRef(null); + const presentation = activityItemPresentation(item); + const tone = presentation?.tone ?? "neutral"; + const note = activityCardPreview(item, hideDetails); + 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; + const actions = item.actions.filter( + (action) => action.kind !== "open" && action.kind !== "mark_seen", + ); + + // Focus lands in the sheet so Escape, Tab and a screen reader all agree that + // this is the thing on top now. + useEffect(() => { + sheetRef.current?.focus(); + }, [item.id]); + + return ( + <> + + + {item.machine.name} + / + {item.project.name} + {item.laneName ? ( + <> + / + {item.laneName} + + ) : null} + + + + + +
+
+
+ + {presentation?.label ?? "Tracked"} + · + + {relativeWhen(item.updatedAt)} + +
+

{item.title}

+ {note ?

{note}

: null} +
+ +
+ {item.model ? {item.model} : null} + + {item.machine.name} + {item.machine.online + ? " · online" + : item.machine.lastSeenAt + ? ` · last seen ${relativeWhen(item.machine.lastSeenAt)}` + : " · offline"} + + {item.project.name} + {item.laneName ? {item.laneName} : null} +
+ + {item.machine.online ? null : ( +
+ + + {item.machine.name} is offline.{" "} + This is its last-known state. Remote actions unlock when it reconnects. + +
+ )} + + {errorMessage ? ( +
+ + {errorMessage} +
+ ) : null} + + {actions.length > 0 ? ( +
+

Actions

+
+ {actions.map((action) => { + const blocked = !item.machine.online && !actionWorksOffline(action); + const Icon = actionIcon(action); + return ( + + ); + })} +
+
+ ) : null} + + {item.planProgress ? ( +
+

Plan progress

+
+
+
+

+ {planCompleted} of {planTotal} + {item.planProgress.current ? ` · ${item.planProgress.current}` : ""} +

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

Recent activity

+
    + {item.recentActivity.slice(0, 8).map((entry, index) => ( +
  1. + + {entry} +
  2. + ))} +
+
+ ) : null} + +
+ + +
+
+
+ + ); +} diff --git a/apps/desktop/src/renderer/components/activity/ActivityFilters.test.tsx b/apps/desktop/src/renderer/components/activity/ActivityFilters.test.tsx new file mode 100644 index 000000000..7b84df2d9 --- /dev/null +++ b/apps/desktop/src/renderer/components/activity/ActivityFilters.test.tsx @@ -0,0 +1,158 @@ +// @vitest-environment jsdom + +import React from "react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ATTENTION_CONTRACT_VERSION, type AttentionItem } from "../../../shared/types"; +import { + ActivityFilters, + activityFiltersAreEmpty, + applyActivityFilters, + EMPTY_ACTIVITY_FILTERS, +} from "./ActivityFilters"; + +function item(id: string, patch: Partial = {}): AttentionItem { + return { + contractVersion: ATTENTION_CONTRACT_VERSION, + id, + revision: 1, + fingerprint: `fingerprint-${id}`, + kind: "agent", + eventKind: "agent_running", + phase: "running", + machine: { machineKey: "studio", name: "Studio Mac", online: true, lastSeenAt: null }, + project: { projectId: "ade", name: "ADE", rootPath: "/repo/ade" }, + provider: "codex", + model: "GPT-5", + title: `Task ${id}`, + preview: "", + privacyPreview: "", + destination: { kind: "session", sessionId: `session-${id}` }, + actions: [], + occurredAt: "2026-07-28T14:00:00.000Z", + updatedAt: "2026-07-28T14:00:00.000Z", + seenAt: null, + dismissedAt: null, + expiresAt: null, + ...patch, + }; +} + +afterEach(cleanup); + +describe("applyActivityFilters", () => { + const studio = item("studio"); + const laptop = item("laptop", { + machine: { machineKey: "laptop", name: "MacBook", online: true, lastSeenAt: null }, + model: "Claude Opus 5", + }); + const pr = item("pr", { + kind: "pull_request", + model: null, + provider: null, + machine: { machineKey: "cloud", name: "Cloud Mac", online: true, lastSeenAt: null }, + }); + const items = [studio, laptop, pr]; + + it("treats an empty axis as everything", () => { + expect(activityFiltersAreEmpty(EMPTY_ACTIVITY_FILTERS)).toBe(true); + expect(applyActivityFilters(items, EMPTY_ACTIVITY_FILTERS)).toHaveLength(3); + }); + + it("intersects across axes and unions within one", () => { + expect( + applyActivityFilters(items, { ...EMPTY_ACTIVITY_FILTERS, machineKeys: ["studio", "laptop"] }) + .map((entry) => entry.id), + ).toEqual(["studio", "laptop"]); + + expect( + applyActivityFilters(items, { + machineKeys: ["laptop"], + kinds: ["agent"], + models: [], + }).map((entry) => entry.id), + ).toEqual(["laptop"]); + }); + + it("excludes items with no model from a model filter", () => { + // "Which model is running" is a claim an item without one cannot make, so + // it must not sneak through as a wildcard match. + expect( + applyActivityFilters(items, { ...EMPTY_ACTIVITY_FILTERS, models: ["GPT-5"] }) + .map((entry) => entry.id), + ).toEqual(["studio"]); + }); +}); + +describe("ActivityFilters", () => { + it("offers only options the snapshot actually contains", () => { + render( + {}} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Filter by machine" })); + expect(screen.getAllByRole("menuitemcheckbox").map((node) => node.textContent)) + .toEqual(["Studio Mac"]); + }); + + it("hides an axis with nothing to choose from", () => { + render( + {}} + />, + ); + + expect(screen.queryByRole("button", { name: "Filter by model" })).toBeNull(); + }); + + it("toggles a value on and back off", () => { + const onChange = vi.fn(); + const items = [item("studio")]; + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Filter by machine" })); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Studio Mac" })); + expect(onChange).toHaveBeenCalledWith({ ...EMPTY_ACTIVITY_FILTERS, machineKeys: ["studio"] }); + + onChange.mockClear(); + rerender( + , + ); + // The menu is still open from the first click — reopening would close it. + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Studio Mac" })); + expect(onChange).toHaveBeenCalledWith(EMPTY_ACTIVITY_FILTERS); + }); + + it("shows the clear affordance only while something is filtered", () => { + const { rerender } = render( + {}} + />, + ); + expect(screen.queryByRole("button", { name: "Clear filters" })).toBeNull(); + + rerender( + {}} + />, + ); + expect(screen.getByRole("button", { name: "Clear filters" })).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/renderer/components/activity/ActivityFilters.tsx b/apps/desktop/src/renderer/components/activity/ActivityFilters.tsx new file mode 100644 index 000000000..88f24ee05 --- /dev/null +++ b/apps/desktop/src/renderer/components/activity/ActivityFilters.tsx @@ -0,0 +1,220 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { CaretDown, Check } from "@phosphor-icons/react"; + +import type { AttentionItem } from "../../../shared/types"; +import { cn } from "../ui/cn"; + +/** + * Activity's three filters — machine, chat type, model. Every option is derived + * from the snapshot on screen rather than a fixed list, so a filter can never + * offer a machine the account no longer has or hide one it just gained. + * + * Selection is a set per axis; an empty set means "everything", which is why + * clearing a filter and selecting all of its options read the same on screen + * and produce the same list. + */ +export type ActivityFilterState = { + machineKeys: string[]; + kinds: AttentionItem["kind"][]; + models: string[]; +}; + +export const EMPTY_ACTIVITY_FILTERS: ActivityFilterState = { + machineKeys: [], + kinds: [], + models: [], +}; + +export function activityFiltersAreEmpty(filters: ActivityFilterState): boolean { + return filters.machineKeys.length === 0 + && filters.kinds.length === 0 + && filters.models.length === 0; +} + +export function applyActivityFilters( + items: readonly AttentionItem[], + filters: ActivityFilterState, +): AttentionItem[] { + if (activityFiltersAreEmpty(filters)) return [...items]; + const machines = new Set(filters.machineKeys); + const kinds = new Set(filters.kinds); + const models = new Set(filters.models); + return items.filter((item) => { + if (machines.size > 0 && !machines.has(item.machine.machineKey)) return false; + if (kinds.size > 0 && !kinds.has(item.kind)) return false; + // An item with no model can only ever match "everything": a model filter is + // a claim about which model is running, and "unknown" is not one. + if (models.size > 0 && !(item.model && models.has(item.model))) return false; + return true; + }); +} + +type FilterOption = { value: string; label: string }; + +const KIND_LABEL: Record = { + agent: "Agents", + pull_request: "Pull requests", +}; + +function optionsFrom( + items: readonly AttentionItem[], + pick: (item: AttentionItem) => { value: string; label: string } | null, +): FilterOption[] { + const byValue = new Map(); + for (const item of items) { + const option = pick(item); + if (!option || !option.value) continue; + if (!byValue.has(option.value)) byValue.set(option.value, option.label); + } + return [...byValue.entries()] + .map(([value, label]) => ({ value, label })) + .sort((left, right) => left.label.localeCompare(right.label)); +} + +function FilterChip({ + label, + options, + selected, + onChange, +}: { + label: string; + options: FilterOption[]; + selected: readonly string[]; + onChange: (next: string[]) => void; +}) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const triggerRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onPointerDown = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + document.addEventListener("pointerdown", onPointerDown); + return () => document.removeEventListener("pointerdown", onPointerDown); + }, [open]); + + const toggle = (value: string) => { + onChange( + selected.includes(value) + ? selected.filter((entry) => entry !== value) + : [...selected, value], + ); + }; + + const selectedLabels = options + .filter((option) => selected.includes(option.value)) + .map((option) => option.label); + const summary = selectedLabels.length === 0 + ? label + : selectedLabels.length === 1 + ? selectedLabels[0] + : `${label} · ${selectedLabels.length}`; + + if (options.length === 0) return null; + + return ( +
+ + {open ? ( +
+ {options.map((option) => { + const checked = selected.includes(option.value); + return ( + + ); + })} +
+ ) : null} +
+ ); +} + +export function ActivityFilters({ + items, + filters, + onChange, +}: { + /** Every unfiltered item in the pane — the option lists come from these. */ + items: readonly AttentionItem[]; + filters: ActivityFilterState; + onChange: (next: ActivityFilterState) => void; +}) { + const machineOptions = useMemo( + () => optionsFrom(items, (item) => ({ + value: item.machine.machineKey, + label: item.machine.name, + })), + [items], + ); + const kindOptions = useMemo( + () => optionsFrom(items, (item) => ({ + value: item.kind, + label: KIND_LABEL[item.kind], + })), + [items], + ); + const modelOptions = useMemo( + () => optionsFrom(items, (item) => ( + item.model ? { value: item.model, label: item.model } : null + )), + [items], + ); + + const clear = useCallback(() => onChange(EMPTY_ACTIVITY_FILTERS), [onChange]); + + return ( +
+ onChange({ ...filters, machineKeys })} + /> + onChange({ + ...filters, + kinds: kinds as AttentionItem["kind"][], + })} + /> + onChange({ ...filters, models })} + /> + {activityFiltersAreEmpty(filters) ? null : ( + + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/activity/ActivityInboxColumn.tsx b/apps/desktop/src/renderer/components/activity/ActivityInboxColumn.tsx new file mode 100644 index 000000000..fad03a2e6 --- /dev/null +++ b/apps/desktop/src/renderer/components/activity/ActivityInboxColumn.tsx @@ -0,0 +1,188 @@ +import React, { useMemo } from "react"; +import { + ArrowsClockwise, + CheckCircle, + CircleDashed, + GitBranch, + GitMerge, + GitPullRequest, + PencilSimpleLine, + Prohibit, + WarningCircle, + X, +} from "@phosphor-icons/react"; + +import { ACTIVITY_EVENT_BY_KIND, type ActivityIconKey } from "../../../shared/activityCatalog"; +import { + attentionItemNeedsInbox, + sortAttentionItems, + type AttentionItem, +} from "../../../shared/types"; +import { relativeWhen } from "../../lib/format"; +import { cn } from "../ui/cn"; +import { activityItemPresentation } from "./activityPresentation"; +import { useProgressiveRows } from "./useProgressiveRows"; + +/** The catalog names an icon per event; this is the renderer's half of that. */ +const CATALOG_ICON: Record = { + working: CircleDashed, + "needs-you": WarningCircle, + failed: WarningCircle, + done: CheckCircle, + checks: ArrowsClockwise, + review: PencilSimpleLine, + changes: PencilSimpleLine, + "merge-ready": GitMerge, + "pull-request": GitPullRequest, + closed: Prohibit, +}; + +export function activityInboxItems( + items: readonly AttentionItem[], +): AttentionItem[] { + return sortAttentionItems(items.filter(attentionItemNeedsInbox)); +} + +function InboxRow({ + item, + selected, + onOpen, + onDismiss, +}: { + item: AttentionItem; + selected: boolean; + onOpen: (item: AttentionItem) => void; + onDismiss: (item: AttentionItem) => void; +}) { + const descriptor = ACTIVITY_EVENT_BY_KIND[item.eventKind]; + const Icon = CATALOG_ICON[descriptor?.iconKey ?? "pull-request"] ?? GitBranch; + const tone = activityItemPresentation(item)?.tone ?? "neutral"; + return ( +
+ + +
+ ); +} + +/** + * The right column: the things that would have pushed a notification — raised + * hands, failures, review requests, and finished work nobody has looked at yet. + * Dismiss is per row here because the whole point of the column is that it + * should empty, and a list you can only clear wholesale never does. + */ +export function ActivityInboxColumn({ + items, + selectedItemId, + filtered, + onOpenItem, + onDismissItem, + onClearAll, +}: { + /** Already filtered; inbox eligibility is decided here. */ + items: readonly AttentionItem[]; + selectedItemId: string | null; + filtered: boolean; + onOpenItem: (item: AttentionItem) => void; + onDismissItem: (item: AttentionItem) => void; + onClearAll: (items: readonly AttentionItem[]) => void; +}) { + const inbox = useMemo(() => activityInboxItems(items), [items]); + const { + visibleRows: shown, + hiddenCount, + nextCount, + showMore, + } = useProgressiveRows(inbox); + + return ( +
+
+

Inbox

+ {inbox.length} + {inbox.length > 0 ? ( + + ) : null} +
+
+ {inbox.length === 0 ? ( +
+ {filtered ? ( + <> + Nothing here matches +

Clear a filter to see the rest of your inbox.

+ + ) : ( + <> + + Inbox zero +

+ Failures, review requests, and finished work you haven’t seen + collect here. +

+ + )} +
+ ) : ( + <> + {shown.map((item) => ( + + ))} + {hiddenCount > 0 ? ( + + ) : null} + + )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/activity/ActivityPane.test.tsx b/apps/desktop/src/renderer/components/activity/ActivityPane.test.tsx new file mode 100644 index 000000000..f83957184 --- /dev/null +++ b/apps/desktop/src/renderer/components/activity/ActivityPane.test.tsx @@ -0,0 +1,447 @@ +// @vitest-environment jsdom + +import React from "react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } 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 { + activityStore, + resetActivityStoreForTests, +} from "../../state/activityStore"; +import { publishAccountStatus, SIGNED_OUT_ACCOUNT } from "../../lib/account"; +import { ActivityPane } from "./ActivityPane"; + +const originalAde = window.ade; +const signedInAccount = { + signedIn: true as const, + userId: "account-a", + email: null, + name: null, + expiresAt: null, + provider: null, + imageUrl: null, +}; + +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" }, + laneName: "attention-revamp", + 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, + }; +} + +function installAde(overrides: Record = {}) { + const attention = { + getSnapshot: vi.fn(), + acknowledge: vi.fn(async () => {}), + reportPresence: vi.fn(), + getPreferences: vi.fn(async () => DEFAULT_ATTENTION_PREFERENCES), + putPreferences: vi.fn(async () => {}), + openItem: vi.fn(async () => {}), + ...overrides, + }; + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + account: { status: vi.fn(async () => signedInAccount) }, + attention, + }, + }); + return attention; +} + +beforeEach(() => { + publishAccountStatus(signedInAccount); + installAde(); +}); + +afterEach(() => { + cleanup(); + resetActivityStoreForTests(); + publishAccountStatus(SIGNED_OUT_ACCOUNT); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: originalAde, + }); +}); + +/** Rows appear in both columns; the sessions card is the one under test. */ +function openDetail(title: string) { + const sessions = screen.getByRole("region", { name: "Sessions" }); + fireEvent.click(within(sessions).getByTitle(new RegExp(`^${title} —`))); +} + +function sessionRow(title: string) { + const sessions = screen.getByRole("region", { name: "Sessions" }); + return within(sessions).queryByTitle(new RegExp(`^${title} —`)); +} + +describe("ActivityPane", () => { + it("renders both columns at once so neither question hides the other", () => { + const needsYou = item("approval"); + const running = item("running", { + phase: "running", + eventKind: "agent_running", + title: "Task running", + }); + activityStore.setState({ itemsById: { approval: needsYou, running } }); + + render( {}} />); + + const sessions = screen.getByRole("region", { name: "Sessions" }); + const inbox = screen.getByRole("region", { name: "Inbox" }); + expect(within(sessions).getByTitle(/^Task approval —/)).toBeTruthy(); + expect(within(sessions).getByTitle(/^Task running —/)).toBeTruthy(); + // Running work is not an inbox item: nothing is waiting on the reader. + expect(within(inbox).queryByText("Task running")).toBeNull(); + expect(within(inbox).getByText("Task approval")).toBeTruthy(); + }); + + it("reveals long session lists one bounded page at a time", () => { + const itemsById = Object.fromEntries(Array.from({ length: 61 }, (_unused, index) => { + const id = `running-${String(index).padStart(2, "0")}`; + return [id, item(id, { + eventKind: "agent_running", + phase: "running", + title: `Running ${index}`, + })]; + })); + activityStore.setState({ itemsById }); + render( {}} />); + + const sessions = screen.getByRole("region", { name: "Sessions" }); + expect(sessions.querySelectorAll("[data-activity-row]")).toHaveLength(60); + fireEvent.click(within(sessions).getByRole("button", { name: "Show 1 more" })); + expect(sessions.querySelectorAll("[data-activity-row]")).toHaveLength(61); + }); + + it("never offers the placeholder copy the old center apologised with", () => { + activityStore.setState({ itemsById: { approval: item("approval") } }); + render( {}} />); + + expect(screen.queryByText(/Ready when you are/i)).toBeNull(); + expect(screen.queryByText(/Nothing selected/i)).toBeNull(); + }); + + it("designs the all-clear state instead of leaving a gap", async () => { + render( {}} />); + + // Opening the pane kicks a refresh, so the all-clear is what is left once + // that settles — not what shows while it is in flight. + expect(await screen.findByText("All agents idle")).toBeTruthy(); + expect(screen.getByText("Inbox zero")).toBeTruthy(); + }); + + it("holds placeholders rather than claiming all-clear before the first snapshot", () => { + activityStore.setState({ syncStatus: "syncing" }); + render( {}} />); + + // "All agents idle" is a claim, and before a snapshot lands it is one ADE + // has no grounds for — a user would read it and stop looking. + expect(screen.queryByText("All agents idle")).toBeNull(); + expect( + document.body.querySelectorAll("[data-activity-skeleton]").length, + ).toBeGreaterThan(0); + }); + + it("slides the detail over the columns with the item's real content", () => { + activityStore.setState({ itemsById: { approval: item("approval") } }); + render( {}} />); + + openDetail("Task approval"); + + const sheet = screen.getByRole("dialog", { name: "Task approval detail" }); + expect(within(sheet).getByText("Waiting for a safe decision")).toBeTruthy(); + expect(within(sheet).getByText("GPT-5")).toBeTruthy(); + expect(within(sheet).getByText("Edited AuthService.ts")).toBeTruthy(); + expect(within(sheet).getByRole("button", { name: /Approve/ })).toBeTruthy(); + expect(within(sheet).getByRole("button", { name: /Deny/ })).toBeTruthy(); + + const progress = within(sheet).getByRole("progressbar", { name: "Plan progress" }); + expect(progress.getAttribute("aria-valuenow")).toBe("2"); + expect(progress.getAttribute("aria-valuemax")).toBe("4"); + expect(within(sheet).getByText(/2 of 4 · Verify the approval flow/)).toBeTruthy(); + + // The columns are still mounted underneath — that is the point of a sheet. + expect(screen.getByRole("region", { name: "Sessions" })).toBeTruthy(); + }); + + it("closes the detail before the pane, one layer per Escape", () => { + const onClose = vi.fn(); + activityStore.setState({ itemsById: { approval: item("approval") } }); + render(); + + openDetail("Task approval"); + fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.queryByRole("dialog", { name: "Task approval detail" })).toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + + fireEvent.keyDown(window, { key: "Escape" }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("closes on a click outside and on the backdrop", () => { + const onClose = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Close Activity backdrop" })); + expect(onClose).toHaveBeenCalled(); + + onClose.mockClear(); + fireEvent.mouseDown(document.body); + expect(onClose).toHaveBeenCalled(); + }); + + it("keeps clicks inside the pane from closing it", () => { + const onClose = vi.fn(); + activityStore.setState({ itemsById: { approval: item("approval") } }); + render(); + + fireEvent.mouseDown(screen.getByTestId("activity-pane")); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("opens exact context before acknowledging, and reports a failure honestly", async () => { + const openItem = vi.fn(async () => { + throw new Error("Studio Mac stopped responding."); + }); + installAde({ openItem }); + activityStore.setState({ itemsById: { approval: item("approval") } }); + render( {}} />); + + openDetail("Task approval"); + fireEvent.click(screen.getByRole("button", { name: "Open" })); + + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain("Studio Mac stopped responding."); + }); + expect(activityStore.getState().itemsById.approval?.seenAt).toBeNull(); + }); + + it("marks an item seen only once its destination resolved", async () => { + const onClose = vi.fn(); + const openItem = vi.fn(async () => {}); + installAde({ openItem }); + activityStore.setState({ itemsById: { approval: item("approval") } }); + render(); + + openDetail("Task approval"); + fireEvent.click(screen.getByRole("button", { name: "Open" })); + + await waitFor(() => { + expect(openItem).toHaveBeenCalledWith(expect.objectContaining({ id: "approval" })); + expect(activityStore.getState().itemsById.approval?.seenAt).not.toBeNull(); + }); + expect(onClose).toHaveBeenCalled(); + }); + + it("disables remote actions for last-known state from an offline machine", () => { + activityStore.setState({ + itemsById: { + offline: item("offline", { + machine: { + machineKey: "cloud", + name: "Cloud Mac", + online: false, + lastSeenAt: "2026-07-28T13:00:00.000Z", + }, + }), + }, + }); + render( {}} />); + + openDetail("Task offline"); + const sheet = screen.getByRole("dialog", { name: "Task offline detail" }); + expect(within(sheet).getByText(/Cloud Mac is offline\./)).toBeTruthy(); + expect( + (within(sheet).getByRole("button", { name: /Approve/ }) as HTMLButtonElement).disabled, + ).toBe(true); + // Dismiss is local bookkeeping, so it stays live while the machine is away. + expect( + (within(sheet).getByRole("button", { name: /Dismiss/ }) as HTMLButtonElement).disabled, + ).toBe(false); + }); + + it("files an offline machine's sessions under a last-seen divider", () => { + activityStore.setState({ + itemsById: { + here: item("here"), + gone: item("gone", { + machine: { + machineKey: "cloud", + name: "Cloud Mac", + online: false, + lastSeenAt: new Date(Date.now() - 2 * 60 * 60_000).toISOString(), + }, + }), + }, + }); + render( {}} />); + + // The pane is portalled to the body, so the render container is empty. + const divider = document.body.querySelector('[data-activity-offline-machine="cloud"]'); + expect(divider).toBeTruthy(); + expect(divider!.textContent).toContain("Cloud Mac"); + expect(divider!.textContent).toContain("last seen"); + // The dimmed group holds that machine's row, and only that machine's row. + const group = divider!.closest(".activity-offline-group")!; + expect(group.querySelector('[data-activity-row="gone"]')).toBeTruthy(); + expect(group.querySelector('[data-activity-row="here"]')).toBeNull(); + }); + + it("dismisses one inbox row without touching the rest", async () => { + const acknowledge = vi.fn(async () => {}); + installAde({ acknowledge }); + activityStore.setState({ + itemsById: { first: item("first"), second: item("second") }, + }); + render( {}} />); + + fireEvent.click(screen.getByRole("button", { name: "Dismiss Task first" })); + + await waitFor(() => { + expect(activityStore.getState().itemsById.first?.dismissedAt).not.toBeNull(); + }); + expect(activityStore.getState().itemsById.second?.dismissedAt).toBeNull(); + expect(acknowledge).toHaveBeenCalledTimes(1); + }); + + it("clears the whole inbox from its header", async () => { + installAde(); + activityStore.setState({ + itemsById: { first: item("first"), second: item("second") }, + }); + render( {}} />); + + fireEvent.click(screen.getByRole("button", { name: "Clear all" })); + + await waitFor(() => { + expect(activityStore.getState().itemsById.first?.dismissedAt).not.toBeNull(); + expect(activityStore.getState().itemsById.second?.dismissedAt).not.toBeNull(); + }); + }); + + it("filters both columns by machine and says so when nothing matches", async () => { + activityStore.setState({ + itemsById: { + studio: item("studio"), + laptop: item("laptop", { + machine: { + machineKey: "laptop", + name: "MacBook", + online: true, + lastSeenAt: null, + }, + }), + }, + }); + render( {}} />); + + fireEvent.click(screen.getByRole("button", { name: "Filter by machine" })); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "MacBook" })); + + await waitFor(() => expect(sessionRow("Task studio")).toBeNull()); + expect(sessionRow("Task laptop")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Clear filters" })); + await waitFor(() => expect(sessionRow("Task studio")).toBeTruthy()); + }); + + it("explains an empty column as a filter result, not as all-clear", async () => { + activityStore.setState({ + itemsById: { + studio: item("studio", { model: "GPT-5" }), + }, + }); + render( {}} />); + + fireEvent.click(screen.getByRole("button", { name: "Filter by type" })); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Agents" })); + // Selecting the only kind present keeps everything visible… + expect(sessionRow("Task studio")).toBeTruthy(); + + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Agents" })); + fireEvent.click(screen.getByRole("button", { name: "Filter by model" })); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "GPT-5" })); + expect(sessionRow("Task studio")).toBeTruthy(); + }); + + it("counts machines and sessions in the header", () => { + activityStore.setState({ + itemsById: { + studio: item("studio"), + cloud: item("cloud", { + machine: { + machineKey: "cloud", + name: "Cloud Mac", + online: false, + lastSeenAt: "2026-07-28T13:00:00.000Z", + }, + }), + }, + }); + render( {}} />); + + expect(screen.getByText(/1 of 2 machines online · 2 sessions/)).toBeTruthy(); + }); + + it("renders nothing at all when closed", () => { + activityStore.setState({ itemsById: { approval: item("approval") } }); + render( {}} />); + + expect(screen.queryByTestId("activity-pane")).toBeNull(); + }); + + it("drops the detail when its item leaves the snapshot", async () => { + activityStore.setState({ itemsById: { approval: item("approval") } }); + render( {}} />); + openDetail("Task approval"); + + act(() => { + activityStore.setState({ itemsById: {} }); + }); + + await waitFor(() => { + expect(screen.queryByRole("dialog", { name: "Task approval detail" })).toBeNull(); + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/activity/ActivityPane.tsx b/apps/desktop/src/renderer/components/activity/ActivityPane.tsx new file mode 100644 index 000000000..16c570255 --- /dev/null +++ b/apps/desktop/src/renderer/components/activity/ActivityPane.tsx @@ -0,0 +1,339 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { + ArrowClockwise, + WarningCircle, + WifiHigh, + WifiSlash, + X, +} from "@phosphor-icons/react"; + +import { + attentionDestinationDeepLink, + type AttentionAction, + type AttentionItem, +} from "../../../shared/types"; +import { relativeWhen } from "../../lib/format"; +import { openAdeDeeplink } from "../../lib/openExternal"; +import { + ADE_BROWSER_VIEW_OCCLUSION_END_EVENT, + ADE_BROWSER_VIEW_OCCLUSION_START_EVENT, +} from "../../lib/workSidebarBrowserResize"; +import { + acknowledgeActivityItem, + activityStore, + selectActivityHideDetails, + useActivityStore, +} from "../../state/activityStore"; +import { ActivityDetailSheet } from "./ActivityDetailSheet"; +import { + ActivityFilters, + activityFiltersAreEmpty, + applyActivityFilters, + EMPTY_ACTIVITY_FILTERS, + type ActivityFilterState, +} from "./ActivityFilters"; +import { ActivityInboxColumn } from "./ActivityInboxColumn"; +import { ActivitySessionsColumn } from "./ActivitySessionsColumn"; +import { ActivitySettingsPopover } from "./ActivitySettingsPopover"; +import { summarizeActivity } from "./activityPriority"; +import { refreshActivitySnapshot } from "./useActivitySync"; +import "./Activity.css"; + +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 pluralize(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} + +/** + * The expanded Activity surface: a modal popup over whatever tab is in front, + * modelled on `app/LinearPaneModal` so ADE's two big overlay surfaces feel like + * the same object. It replaces the `/attention` full-page route and the + * tabs-plus-roster-plus-detail IA that route encoded. + * + * Split view, not a master/detail swap. Sessions and Inbox are both always + * visible because they answer different questions — "what is running" and + * "what is waiting on me" — and the detail slides over the top so opening one + * row never costs you your place in either list. + */ +export function ActivityPane({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + const itemsById = useActivityStore((state) => state.itemsById); + const syncStatus = useActivityStore((state) => state.syncStatus); + const syncError = useActivityStore((state) => state.syncError); + const generatedAt = useActivityStore((state) => state.generatedAt); + const availability = useActivityStore((state) => state.availability); + const acknowledgementErrors = useActivityStore((state) => state.acknowledgementErrors); + const hideDetails = useActivityStore(selectActivityHideDetails); + + const paneRef = useRef(null); + const [now, setNow] = useState(() => Date.now()); + const [filters, setFilters] = useState(EMPTY_ACTIVITY_FILTERS); + const [selectedItemId, setSelectedItemId] = useState(null); + const [pendingActionId, setPendingActionId] = useState(null); + const [navigationError, setNavigationError] = useState(null); + + const allItems = useMemo(() => Object.values(itemsById), [itemsById]); + const visibleItems = useMemo( + () => applyActivityFilters(allItems, filters), + [allItems, filters], + ); + const summary = useMemo(() => summarizeActivity(visibleItems, now), [now, visibleItems]); + const selectedItem = selectedItemId ? itemsById[selectedItemId] ?? null : null; + + useEffect(() => { + if (!open) return; + setNavigationError(null); + setNow(Date.now()); + void refreshActivitySnapshot(); + const timer = window.setInterval(() => setNow(Date.now()), 30_000); + return () => window.clearInterval(timer); + }, [open]); + + // The pane is a header surface too: while it is up, presence reporting should + // say the user is looking at Activity. + useEffect(() => { + if (!open) return; + activityStore.getState().setHeaderSurfaceVisible(true); + return () => activityStore.getState().setHeaderSurfaceVisible(false); + }, [open]); + + // An embedded BrowserView paints above the DOM, so tell it to step aside. + useEffect(() => { + if (!open || typeof window === "undefined") return; + window.dispatchEvent(new Event(ADE_BROWSER_VIEW_OCCLUSION_START_EVENT)); + return () => { + window.dispatchEvent(new Event(ADE_BROWSER_VIEW_OCCLUSION_END_EVENT)); + }; + }, [open]); + + const closeSheet = useCallback(() => setSelectedItemId(null), []); + + useEffect(() => { + if (!open) return; + const onKey = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + // The settings popover is a dialog inside this one and owns its own + // Escape; closing both at once would be a single key undoing two steps. + if (document.querySelector(".activity-settings-popover")) return; + event.preventDefault(); + // Escape peels one layer: the detail sheet first, the pane only once + // nothing is stacked on top of it. + if (selectedItemId) closeSheet(); + else onClose(); + }; + const onDown = (event: MouseEvent) => { + const target = event.target as Node | null; + if (!target || paneRef.current?.contains(target)) return; + onClose(); + }; + window.addEventListener("keydown", onKey); + window.addEventListener("mousedown", onDown); + return () => { + window.removeEventListener("keydown", onKey); + window.removeEventListener("mousedown", onDown); + }; + }, [closeSheet, onClose, open, selectedItemId]); + + // A dismissed or expired row cannot keep a sheet open over an empty list. + useEffect(() => { + if (selectedItemId && !itemsById[selectedItemId]) setSelectedItemId(null); + }, [itemsById, selectedItemId]); + + const openItem = useCallback(async (item: AttentionItem) => { + setNavigationError(null); + try { + const bridge = typeof window !== "undefined" ? window.ade?.attention : null; + if (bridge?.openItem) await bridge.openItem(item); + else openAdeDeeplink(attentionDestinationDeepLink(item.destination, item)); + } catch (error) { + setNavigationError(navigationErrorMessage(error)); + return; + } + // Only a destination that actually resolved earns the item leaving unseen. + await acknowledgeActivityItem(item.id, "seen").catch(() => {}); + onClose(); + }, [onClose]); + + const runAction = useCallback(async (item: AttentionItem, action: AttentionAction) => { + if (pendingActionId) return; + if (action.kind === "open") { + await openItem(item); + return; + } + setPendingActionId(action.id); + try { + if (action.kind === "dismiss" || action.kind === "mark_seen") { + await acknowledgeActivityItem( + item.id, + action.kind === "dismiss" ? "dismiss" : "seen", + ); + if (action.kind === "dismiss") closeSheet(); + } else { + // Everything else is a remote mutation ADE cannot yet perform from + // here, so the honest fallback is to take the user to where it can be + // done rather than to pretend the button did it. + await openItem(item); + } + } catch { + // `acknowledgeActivityItem` rolls its own optimistic state back and + // records the message in the store; the sheet renders it. + } finally { + setPendingActionId(null); + } + }, [closeSheet, openItem, pendingActionId]); + + const dismissItem = useCallback((item: AttentionItem) => { + void acknowledgeActivityItem(item.id, "dismiss").catch(() => {}); + }, []); + + const clearInbox = useCallback((items: readonly AttentionItem[]) => { + for (const item of items) { + void acknowledgeActivityItem(item.id, "dismiss").catch(() => {}); + } + }, []); + + if (!open || typeof document === "undefined") return null; + + const degraded = availability != null + && availability.state !== "ready" + && availability.state !== "signed_out"; + const freshness = degraded + ? { tone: "error" as const, label: availability.title, retry: true } + : syncStatus === "error" + ? { tone: "error" as const, label: "Sync failed", retry: true } + : syncStatus === "syncing" + ? { tone: "syncing" as const, label: "Syncing", retry: false } + : generatedAt + ? { tone: "ready" as const, label: `Synced ${relativeWhen(generatedAt)}`, retry: false } + : null; + const machineLine = summary.machinesTotal === 0 + ? "No machines reporting yet" + : summary.machinesOnline === summary.machinesTotal + ? `${pluralize(summary.machinesTotal, "machine")} online` + : `${summary.machinesOnline} of ${pluralize(summary.machinesTotal, "machine")} online`; + const filtered = !activityFiltersAreEmpty(filters); + + return createPortal( + <> + + ) : ( + + {freshness.tone === "syncing" ? ( + + ) : ( + + )} + {freshness.label} + + ) + ) : null} + + + + + + + + {/* While the sheet is up it covers this strip, so the failure is + reported there instead — one alert, wherever the click was. */} + {navigationError && !selectedItem ? ( +
+ + {navigationError} +
+ ) : null} + +
+ setSelectedItemId(item.id)} + /> + setSelectedItemId(item.id)} + onDismissItem={dismissItem} + onClearAll={clearInbox} + /> + {selectedItem ? ( + void openItem(item)} + onAction={(item, action) => void runAction(item, action)} + /> + ) : null} +
+ + , + document.body, + ); +} diff --git a/apps/desktop/src/renderer/components/activity/ActivitySessionsColumn.tsx b/apps/desktop/src/renderer/components/activity/ActivitySessionsColumn.tsx new file mode 100644 index 000000000..5758edb81 --- /dev/null +++ b/apps/desktop/src/renderer/components/activity/ActivitySessionsColumn.tsx @@ -0,0 +1,210 @@ +import React, { useMemo } from "react"; + +import type { AttentionItem } from "../../../shared/types"; +import { relativeWhen } from "../../lib/format"; +import { cn } from "../ui/cn"; +import { ActivityCard } from "./ActivityCard"; +import { ActivityCardSkeleton } from "./ActivityCardSkeleton"; +import { + ACTIVITY_SECTION_TONE, + activitySections, + type ActivitySection, +} from "./activityPriority"; +import { useProgressiveRows } from "./useProgressiveRows"; + +type MachineGroup = { + machineKey: string; + name: string; + lastSeenAt: string | null; + items: AttentionItem[]; +}; + +/** + * Split a section into the work being observed and the work being remembered. + * An offline machine's rows are last-known state, so they sit below a labelled + * divider instead of mixing into a list that otherwise means "right now". + */ +function partitionByPresence(items: readonly AttentionItem[]): { + online: AttentionItem[]; + offline: MachineGroup[]; +} { + const online: AttentionItem[] = []; + const offline = new Map(); + for (const item of items) { + if (item.machine.online) { + online.push(item); + continue; + } + const existing = offline.get(item.machine.machineKey); + if (existing) { + existing.items.push(item); + continue; + } + offline.set(item.machine.machineKey, { + machineKey: item.machine.machineKey, + name: item.machine.name, + lastSeenAt: item.machine.lastSeenAt, + items: [item], + }); + } + return { + online, + offline: [...offline.values()].sort((left, right) => + left.name.localeCompare(right.name)), + }; +} + +function SectionRows({ + section, + hideDetails, + selectedItemId, + onOpenItem, +}: { + section: ActivitySection; + hideDetails: boolean; + selectedItemId: string | null; + onOpenItem: (item: AttentionItem) => void; +}) { + const { online, offline } = useMemo( + () => partitionByPresence(section.items), + [section.items], + ); + + const card = (item: AttentionItem) => ( + + ); + + return ( + <> + {online.map(card)} + {offline.map((group) => ( +
+
+ {group.name} + · + + {group.lastSeenAt + ? `last seen ${relativeWhen(group.lastSeenAt)}` + : "offline"} + +
+ {group.items.map(card)} +
+ ))} + + ); +} + +/** + * The left column: every tracked session, priority-flat across needs-you → + * working → done, with section headings that stay put while the list scrolls. + */ +export function ActivitySessionsColumn({ + items, + hideDetails, + selectedItemId, + filtered, + loading, + onOpenItem, +}: { + /** Already filtered. The column does not know the filter exists. */ + items: readonly AttentionItem[]; + hideDetails: boolean; + selectedItemId: string | null; + /** Changes the all-clear copy: nothing here is not the same as nothing at all. */ + filtered: boolean; + /** No snapshot has landed yet — which is not the same as nothing running. */ + loading: boolean; + onOpenItem: (item: AttentionItem) => void; +}) { + const sections = useMemo(() => activitySections(items), [items]); + const total = sections.reduce((count, section) => count + section.items.length, 0); + // Flatten in section priority order before spending the shared row budget, + // then rebuild headings for the visible slice. Needs-you rows stay first. + const orderedRows = useMemo( + () => sections.flatMap((section) => section.items), + [sections], + ); + const { + visibleRows, + hiddenCount, + nextCount, + showMore, + } = useProgressiveRows(orderedRows); + const budgeted = useMemo(() => activitySections(visibleRows), [visibleRows]); + + return ( +
+
+

Sessions

+ {total} +
+
+ {total === 0 && loading ? ( + // Placeholders, not an all-clear: claiming every agent is idle before + // the first snapshot lands is a lie the user would act on. + Array.from({ length: 4 }, (_, index) => ) + ) : total === 0 ? ( +
+ {filtered ? ( + <> + No sessions match +

Clear a filter to see the rest of your account.

+ + ) : ( + <> + + All agents idle +

Work from every signed-in machine lands here the moment it starts.

+ + )} +
+ ) : ( + <> + {budgeted.map((section) => ( + +

+ + {section.label} + + {sections.find((entry) => entry.id === section.id)?.items.length ?? 0} + +

+ +
+ ))} + {hiddenCount > 0 ? ( + + ) : null} + + )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.test.tsx b/apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.test.tsx new file mode 100644 index 000000000..59acc528c --- /dev/null +++ b/apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.test.tsx @@ -0,0 +1,146 @@ +// @vitest-environment jsdom + +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { DEFAULT_ATTENTION_PREFERENCES } from "../../../shared/types"; +import { publishAccountStatus, SIGNED_OUT_ACCOUNT } from "../../lib/account"; +import { resetActivityStoreForTests } from "../../state/activityStore"; +import { ActivitySettingsPopover } from "./ActivitySettingsPopover"; + +const originalAde = window.ade; +const signedInAccount = { + signedIn: true as const, + userId: "account-a", + email: null, + name: null, + expiresAt: null, + provider: null, + imageUrl: null, +}; + +function installAde() { + const putPreferences = vi.fn(async () => undefined); + const updateSettings = vi.fn(async () => undefined); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + account: { status: vi.fn(async () => signedInAccount) }, + attention: { + getSnapshot: vi.fn(), + acknowledge: vi.fn(), + reportPresence: vi.fn(), + getPreferences: vi.fn(async () => DEFAULT_ATTENTION_PREFERENCES), + putPreferences, + }, + attentionNotch: { updateSettings, publishSnapshot: vi.fn() }, + }, + }); + return { putPreferences, updateSettings }; +} + +beforeEach(() => { + window.localStorage.clear(); + publishAccountStatus(signedInAccount); +}); + +afterEach(() => { + cleanup(); + resetActivityStoreForTests(); + publishAccountStatus(SIGNED_OUT_ACCOUNT); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: originalAde, + }); +}); + +describe("ActivitySettingsPopover", () => { + it("saves as you go, with no Save button to forget", async () => { + const { putPreferences, updateSettings } = installAde(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Activity settings" })); + await screen.findByRole("dialog", { name: "Activity settings" }); + await waitFor(() => expect(screen.getByRole("switch", { name: "Activity sounds" })).toBeTruthy()); + + expect(screen.queryByRole("button", { name: /^save$/i })).toBeNull(); + + fireEvent.click(screen.getByRole("switch", { name: "Activity sounds" })); + + await waitFor(() => { + expect(putPreferences).toHaveBeenCalledWith( + "account-a", + expect.objectContaining({ + account: expect.objectContaining({ soundsEnabled: true }), + }), + ); + expect(updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ soundsEnabled: true }), + ); + }); + }); + + it("keeps the notch enabled flag on this Mac while syncing its presentation", async () => { + const { putPreferences, updateSettings } = installAde(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Activity settings" })); + await screen.findByRole("dialog", { name: "Activity settings" }); + await waitFor(() => expect(screen.getByRole("switch", { name: "ADE notch" })).toBeTruthy()); + + fireEvent.click(screen.getByRole("switch", { name: "ADE notch" })); + await waitFor(() => { + expect(window.localStorage.getItem("ade:attention:notch-enabled")).toBe("false"); + expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ enabled: false })); + }); + // Whether this Mac shows a notch at all is this Mac's business. + const [, savedPreferences] = putPreferences.mock.calls.at(-1) as unknown as [ + string, + { account: Record }, + ]; + expect(savedPreferences.account).not.toHaveProperty("notchEnabled"); + }); + + it("returns focus to the trigger when Escape dismisses it", async () => { + installAde(); + render(); + const trigger = screen.getByRole("button", { name: "Activity settings" }); + fireEvent.click(trigger); + + const dialog = await screen.findByRole("dialog", { name: "Activity settings" }); + expect(document.activeElement).toBe(dialog); + + fireEvent.keyDown(document, { key: "Escape" }); + await waitFor(() => { + expect(screen.queryByRole("dialog", { name: "Activity settings" })).toBeNull(); + }); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + }); + + it("links to Settings through the navigation bus, not the router", async () => { + // Activity mounts outside the router here (and in the notch), so the link + // must dispatch an app-navigation target rather than calling useNavigate — + // which would throw "may be used only in the context of a ". + installAde(); + const targets: unknown[] = []; + const onNavigate = (event: Event) => { + targets.push((event as CustomEvent).detail?.target); + }; + window.addEventListener("ade:navigate-target", onNavigate); + try { + render(); + fireEvent.click(screen.getByRole("button", { name: "Activity settings" })); + await screen.findByRole("dialog", { name: "Activity settings" }); + + fireEvent.click(await screen.findByRole("button", { name: /All Activity settings/ })); + + expect(targets).toEqual([{ kind: "settings", tab: "activity" }]); + } finally { + window.removeEventListener("ade:navigate-target", onNavigate); + } + }); +}); diff --git a/apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.tsx b/apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.tsx new file mode 100644 index 000000000..fb4376916 --- /dev/null +++ b/apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.tsx @@ -0,0 +1,200 @@ +import React, { useEffect, useRef, useState } from "react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { + ArrowSquareOut, + BellRinging, + Check, + GearSix, + WarningCircle, +} from "@phosphor-icons/react"; + +import { navigateToAppTarget } from "../../lib/openExternal"; +import { + ActivitySettingsControls, + useActivitySettings, +} from "../settings/ActivitySettingsControls"; +import "./Activity.css"; + +/** + * The gear in the Activity header popover and pane. + * + * It replaces the old settings popover, whose three toggles sat behind a Save + * button while the settings page saved instantly — the same preference, + * two different contracts. Every row here comes from + * `settings/ActivitySettingsControls`, mounted in its `popover` variant, and + * every change saves the moment it is made. There is no Save button anywhere in + * ADE settings; there is no longer one here either. + */ +export function ActivitySettingsPopover() { + const model = useActivitySettings(); + const [open, setOpen] = useState(false); + const reducedMotion = useReducedMotion() ?? false; + const rootRef = useRef(null); + const triggerRef = useRef(null); + const restoreTriggerFocusRef = useRef(false); + + const dialogElement = () => + rootRef.current?.querySelector('[role="dialog"]') ?? null; + + const closePopover = (returnFocus: boolean) => { + restoreTriggerFocusRef.current = returnFocus; + setOpen(false); + }; + + // An account switch invalidates everything on screen, so the popover closes + // rather than showing the previous account's choices under a new name. + useEffect(() => { + if (!model.accountOwnerId) return; + restoreTriggerFocusRef.current = false; + setOpen(false); + }, [model.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") { + // Stopped here so the surrounding pane or popover does not also close: + // one Escape, one layer. + event.stopPropagation(); + closePopover(true); + } + }; + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + useEffect(() => { + if (open) dialogElement()?.focus(); + }, [open]); + + // Keep Tab inside the popover while it is open; Escape and the Done button + // 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(); + } + }; + + return ( +
+ + { + if (!restoreTriggerFocusRef.current) return; + restoreTriggerFocusRef.current = false; + triggerRef.current?.focus(); + }} + > + {open ? ( + +
+
+ + + + + Activity settings + Account delivery and this Mac’s notch + +
+ Account +
+ + {model.loading ? ( +
+ + Loading your preferences… +
+ ) : ( + + )} + + {model.error ? ( +
+ + {model.error} +
+ ) : null} + +
+ {/* + Routed through the app navigation bus rather than `useNavigate`: + Activity mounts outside the router in tests and in the notch, + and must not take a Router dependency. + */} + +
+ +
+ + {model.saved + ? <> Saved + : "Changes save as you make them"} + + +
+
+ ) : null} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/activity/HeaderActivityControl.css b/apps/desktop/src/renderer/components/activity/HeaderActivityControl.css new file mode 100644 index 000000000..3baa2ecc4 --- /dev/null +++ b/apps/desktop/src/renderer/components/activity/HeaderActivityControl.css @@ -0,0 +1,410 @@ +/* The global-header Activity control. It borrows the shared status tone system + so a phase reads the same colour here as it does on a Work sidebar row, but + keeps its own compact type scale: this lives in a 28px header, not a page. + Every colour resolves through theme tokens so light mode is a token swap + rather than a second stylesheet. + + Rows are `ActivityCard`, i.e. Tailwind. Only the parts of a row that need a + per-tone colour — the hover rail, the focus ring, the unseen dot — live here, + driven by the `--tone-color` variable the tone class sets. */ + +.activity-hdr-trigger, +.activity-hdr-panel { + --tone-color: #a1a1aa; + --activity-hdr-fs-2xs: 9.5px; + --activity-hdr-fs-xs: 10.5px; + --activity-hdr-fs-sm: 11.5px; + --activity-hdr-fs-md: 12.5px; + --activity-hdr-surface: color-mix(in srgb, var(--color-card) 92%, var(--color-bg)); + --activity-hdr-hairline: color-mix(in srgb, var(--color-border) 70%, transparent); + --activity-hdr-shadow: 0 28px 70px -30px rgba(0, 0, 0, 0.8); +} + +/* The `.activity-tone-*` table itself lives in `Activity.css`, which every + Activity surface loads through `ActivityCard`. A second, popover-scoped copy + is how this panel and the pane would end up disagreeing about what amber is. */ + +[data-theme="light"] .activity-hdr-trigger, +[data-theme="light"] .activity-hdr-panel { + --activity-hdr-shadow: 0 22px 55px -26px rgba(15, 23, 42, 0.3); +} + +/* ---- trigger ---------------------------------------------------------- */ + +.activity-hdr-trigger { + height: 22px; + transition: + background-color 150ms ease, + border-color 150ms ease, + box-shadow 150ms ease, + color 150ms ease; +} + +.activity-hdr-trigger-icon { + color: var(--color-muted-fg); + transition: color 150ms ease; +} + +.activity-hdr-trigger[data-state="waiting"] { + border-color: color-mix(in srgb, var(--tone-color) 45%, transparent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--tone-color) 16%, transparent); +} + +/* The one amber in this file that is not a phase tone, and it earns it: a + degraded surface always carries an `availability.recovery` the user has to + perform (retry, sign in, update or restart the host). It is literally "your + move", which is the only meaning amber is allowed to carry — see the one-hue + rule in apps/desktop/src/shared/sessionStatusPresentation.ts. It cannot be + confused with a phase tone either: `data-state` is single-valued and + `degraded` outranks `waiting`. */ +.activity-hdr-trigger[data-state="degraded"] { + border-color: color-mix(in srgb, #f59e0b 42%, transparent); + box-shadow: 0 0 0 1px color-mix(in srgb, #f59e0b 13%, transparent); +} + +.activity-hdr-trigger[data-state="degraded"] .activity-hdr-trigger-icon { + color: #f59e0b; +} + +.activity-hdr-trigger[data-state="waiting"] .activity-hdr-trigger-icon, +.activity-hdr-trigger[data-state="live"] .activity-hdr-trigger-icon { + color: var(--tone-color); +} + +.activity-hdr-trigger[data-state="signed-out"] { + opacity: 0.75; +} + +.activity-hdr-trigger-count { + display: inline-flex; + min-width: 14px; + align-items: center; + justify-content: center; + padding: 0 3px; + border-radius: 999px; + background: var(--tone-color); + color: var(--color-bg); + font-family: var(--font-mono); + font-size: var(--activity-hdr-fs-2xs); + font-weight: 800; + line-height: 14px; + font-variant-numeric: tabular-nums; +} + +.activity-hdr-trigger-live { + width: 6px; + height: 6px; + border-radius: 999px; + background: var(--tone-color); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--tone-color) 18%, transparent); + animation: activity-hdr-pulse 2.4s ease-in-out infinite; +} + +@keyframes activity-hdr-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.42; } +} + +/* ---- popover ---------------------------------------------------------- */ + +.activity-hdr-panel { + position: absolute; + right: 12px; + top: 40px; + display: flex; + width: min(420px, calc(100vw - 24px)); + max-height: min(600px, calc(100vh - 72px)); + flex-direction: column; + overflow: hidden; + border: 1px solid var(--activity-hdr-hairline); + border-radius: 14px; + background: var(--activity-hdr-surface); + box-shadow: var(--activity-hdr-shadow); + color: var(--color-fg); + animation: activity-hdr-enter 140ms ease-out; +} + +@keyframes activity-hdr-enter { + from { opacity: 0; transform: translateY(-6px) scale(0.985); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.activity-hdr-panel:focus-visible { + outline: none; +} + +.activity-hdr-panel-head { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 10px 10px 13px; + border-bottom: 1px solid var(--activity-hdr-hairline); +} + +.activity-hdr-panel-head h2 { + margin: 0; + min-width: 0; + flex: 1; + font-size: var(--activity-hdr-fs-md); + font-weight: 650; + letter-spacing: -0.01em; +} + +.activity-hdr-freshness { + display: inline-flex; + flex-shrink: 0; + align-items: center; + gap: 4px; + padding: 3px 7px; + border: 1px solid var(--activity-hdr-hairline); + border-radius: 999px; + background: color-mix(in srgb, var(--color-card) 60%, transparent); + color: var(--color-muted-fg); + font-size: var(--activity-hdr-fs-2xs); + font-weight: 600; +} + +button.activity-hdr-freshness { + cursor: pointer; +} + +.activity-hdr-freshness.is-error { + border-color: color-mix(in srgb, var(--color-error, #ef4444) 45%, transparent); + color: var(--color-error, #ef4444); +} + +.activity-hdr-spin { + animation: activity-hdr-spin 1.1s linear infinite; +} + +@keyframes activity-hdr-spin { + to { transform: rotate(360deg); } +} + +.activity-hdr-icon-button { + display: inline-flex; + height: 22px; + width: 22px; + flex-shrink: 0; + align-items: center; + justify-content: center; + border-radius: 7px; + color: var(--color-muted-fg); + transition: background-color 120ms ease, color 120ms ease; +} + +.activity-hdr-icon-button:hover { + background: color-mix(in srgb, var(--color-fg) 8%, transparent); + color: var(--color-fg); +} + +.activity-hdr-alert, +.activity-hdr-note { + display: flex; + align-items: flex-start; + gap: 7px; + padding: 8px 13px; + font-size: var(--activity-hdr-fs-xs); + line-height: 1.45; + border-bottom: 1px solid var(--activity-hdr-hairline); +} + +.activity-hdr-alert { + color: var(--color-error, #ef4444); + background: color-mix(in srgb, var(--color-error, #ef4444) 10%, transparent); +} + +.activity-hdr-note { + color: var(--color-muted-fg); + background: color-mix(in srgb, var(--color-fg) 4%, transparent); +} + +.activity-hdr-notch-health span { + flex: 1; +} + +.activity-hdr-notch-health button { + flex: 0 0 auto; + color: var(--color-accent); + font-weight: 650; +} + +.activity-hdr-notch-health button:hover, +.activity-hdr-notch-health button:focus-visible { + text-decoration: underline; + outline: none; +} + +.activity-hdr-body { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 2px; + overflow-y: auto; + padding: 6px; +} + +/* ---- sections --------------------------------------------------------- */ + +.activity-hdr-section { + display: flex; + flex-direction: column; + gap: 1px; +} + +.activity-hdr-section-heading { + display: flex; + align-items: center; + gap: 6px; + margin: 0; + padding: 7px 7px 4px; + font-size: var(--activity-hdr-fs-2xs); + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--color-muted-fg); +} + +.activity-hdr-section-dot { + width: 6px; + height: 6px; + flex-shrink: 0; + border-radius: 999px; + background: var(--tone-color); +} + +.activity-hdr-section-count { + font-family: var(--font-mono); + font-size: var(--activity-hdr-fs-2xs); + font-variant-numeric: tabular-nums; + color: var(--tone-color); +} + +.activity-hdr-overflow { + display: inline-flex; + align-items: center; + gap: 4px; + align-self: flex-start; + margin: 2px 0 4px 8px; + padding: 2px 4px; + border-radius: 6px; + font-size: var(--activity-hdr-fs-xs); + font-weight: 600; + color: var(--color-muted-fg); + transition: color 120ms ease, background-color 120ms ease; +} + +.activity-hdr-overflow:hover, +.activity-hdr-overflow:focus-visible { + color: var(--color-fg); + background: color-mix(in srgb, var(--color-fg) 6%, transparent); + outline: none; +} + +/* ---- empty states and footer ------------------------------------------ */ + +.activity-hdr-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 34px 26px 38px; + text-align: center; + color: var(--color-muted-fg); +} + +.activity-hdr-empty strong { + font-size: var(--activity-hdr-fs-md); + font-weight: 650; + color: var(--color-fg); +} + +.activity-hdr-empty p { + margin: 0; + max-width: 30ch; + font-size: var(--activity-hdr-fs-xs); + line-height: 1.5; +} + +/* All-clear is a state worth designing, not a gap to apologise for: one calm + emerald dot breathing at rest, no icon shouting an absence. */ +.activity-hdr-calm-dot { + position: relative; + width: 9px; + height: 9px; + margin-bottom: 4px; + border-radius: 999px; + background: color-mix(in srgb, #34d399 78%, transparent); +} + +.activity-hdr-calm-dot::after { + content: ""; + position: absolute; + inset: -6px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, #34d399 26%, transparent); + animation: activity-hdr-calm 3.6s ease-in-out infinite; +} + +@keyframes activity-hdr-calm { + 0%, 100% { opacity: 0.55; transform: scale(0.9); } + 50% { opacity: 0.15; transform: scale(1.12); } +} + +.activity-hdr-panel-foot { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 9px 8px 13px; + border-top: 1px solid var(--activity-hdr-hairline); + font-size: var(--activity-hdr-fs-xs); + color: var(--color-muted-fg); + font-variant-numeric: tabular-nums; +} + +.activity-hdr-panel-foot > span { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.activity-hdr-open-all { + display: inline-flex; + flex-shrink: 0; + align-items: center; + gap: 5px; + padding: 4px 9px; + border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--color-accent) 15%, transparent); + color: var(--color-accent); + font-size: var(--activity-hdr-fs-xs); + font-weight: 650; + transition: background-color 120ms ease, border-color 120ms ease; +} + +.activity-hdr-open-all:hover, +.activity-hdr-open-all:focus-visible { + background: color-mix(in srgb, var(--color-accent) 24%, transparent); + border-color: color-mix(in srgb, var(--color-accent) 55%, transparent); + outline: none; +} + +@media (prefers-reduced-motion: reduce) { + .activity-hdr-panel, + .activity-hdr-panel *, + .activity-hdr-trigger, + .activity-hdr-trigger * { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } + + .activity-hdr-trigger-live, + .activity-hdr-calm-dot::after { + animation: none; + } +} diff --git a/apps/desktop/src/renderer/components/activity/HeaderActivityControl.test.tsx b/apps/desktop/src/renderer/components/activity/HeaderActivityControl.test.tsx new file mode 100644 index 000000000..7db1cffb4 --- /dev/null +++ b/apps/desktop/src/renderer/components/activity/HeaderActivityControl.test.tsx @@ -0,0 +1,427 @@ +// @vitest-environment jsdom + +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ATTENTION_CONTRACT_VERSION, + DEFAULT_ATTENTION_PREFERENCES, + type AttentionItem, + type AttentionPhase, +} from "../../../shared/types"; +import { + activityStore, + resetActivityStoreForTests, +} from "../../state/activityStore"; +import { publishAccountStatus, SIGNED_OUT_ACCOUNT } from "../../lib/account"; +import { HeaderActivityControl } from "./HeaderActivityControl"; + +const originalAde = window.ade; +const signedInAccount = { + signedIn: true as const, + userId: "account-a", + email: null, + name: null, + expiresAt: null, + provider: null, + imageUrl: null, +}; + +let openItem: ReturnType; +let acknowledge: ReturnType; +let getSnapshot: ReturnType; +let captureAnalytics: ReturnType; + +beforeEach(() => { + publishAccountStatus(signedInAccount); + openItem = vi.fn(async () => {}); + acknowledge = vi.fn(async () => {}); + captureAnalytics = vi.fn(async () => ({ accepted: true, reason: "accepted" })); + getSnapshot = vi.fn(async () => ({ + contractVersion: ATTENTION_CONTRACT_VERSION, + revision: activityStore.getState().revision, + generatedAt: "2026-08-01T12:00:00.000Z", + items: Object.values(activityStore.getState().itemsById), + })); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + account: { + ...(originalAde?.account ?? {}), + status: vi.fn(async () => signedInAccount), + }, + attention: { + openItem, + acknowledge, + getSnapshot, + getPreferences: vi.fn(async () => DEFAULT_ATTENTION_PREFERENCES), + }, + analytics: { capture: captureAnalytics }, + }, + }); +}); + +afterEach(() => { + cleanup(); + resetActivityStoreForTests(); + publishAccountStatus(SIGNED_OUT_ACCOUNT); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: originalAde, + }); +}); + +function item( + id: string, + phase: AttentionPhase, + patch: Partial = {}, +): AttentionItem { + return { + contractVersion: ATTENTION_CONTRACT_VERSION, + id, + revision: 1, + fingerprint: `fingerprint-${id}`, + kind: "agent", + eventKind: "agent_needs_you", + phase, + machine: { + machineKey: "studio", + name: "Studio Mac", + online: true, + lastSeenAt: "2026-08-01T11:59:00.000Z", + }, + project: { projectId: "ade", name: "ADE", rootPath: "/repo/ade" }, + laneName: `lane-${id}`, + provider: "codex", + model: "gpt-5.6-sol", + title: `Task ${id}`, + preview: "preview", + privacyPreview: "private preview", + destination: { kind: "session", sessionId: `session-${id}` }, + actions: [], + occurredAt: "2026-08-01T11:58:00.000Z", + updatedAt: "2026-08-01T11:58:00.000Z", + seenAt: null, + dismissedAt: null, + expiresAt: null, + ...patch, + }; +} + +function seedItems(items: AttentionItem[]): void { + activityStore.setState({ + itemsById: Object.fromEntries(items.map((entry) => [entry.id, entry])), + generatedAt: "2026-08-01T12:00:00.000Z", + syncStatus: "ready", + }); +} + +function renderControl(onOpenPane = vi.fn()) { + render(); + return onOpenPane; +} + +function openPanel(): HTMLElement { + fireEvent.click(screen.getByTestId("header-activity-trigger")); + return screen.getByRole("dialog", { name: "Activity" }); +} + +describe("HeaderActivityControl", () => { + it("badges only work that needs you, and records a bounded header open", async () => { + seedItems([item("a", "needs_you"), item("b", "running"), item("c", "merge_ready")]); + renderControl(); + + const trigger = screen.getByTestId("header-activity-trigger"); + // merge_ready is a review, not a raised hand: it files under needs-you's + // priority band but the badge itself stays the needs-you count. + expect(trigger.textContent).toContain("2"); + expect(trigger.getAttribute("aria-label")).toBe("Activity · 2 need you · 1 working"); + expect(trigger.getAttribute("data-state")).toBe("waiting"); + + fireEvent.click(trigger); + await waitFor(() => { + // Analytics identity is deliberately unchanged by the rename. + expect(captureAnalytics).toHaveBeenCalledWith({ + event: "ade_feature_used", + properties: { + feature: "attention", + action: "header_opened", + outcome: "opened", + source: "renderer_route", + }, + dedupeKey: "attention_header_opened", + minimumIntervalMs: 60 * 60_000, + }); + }); + }); + + it("shows a live pulse without a count when nothing needs you", () => { + seedItems([item("b", "running")]); + renderControl(); + + const trigger = screen.getByTestId("header-activity-trigger"); + expect(trigger.getAttribute("data-state")).toBe("live"); + expect(trigger.textContent).toBe(""); + expect(trigger.getAttribute("aria-label")).toBe("Activity · 1 working"); + }); + + it("renders the three priority sections in order, and only those", () => { + seedItems([ + item("done", "completed"), + item("live", "running"), + item("asks", "needs_you"), + ]); + renderControl(); + const dialog = openPanel(); + + expect(activityStore.getState().headerSurfaceVisible).toBe(true); + const sections = Array.from( + dialog.querySelectorAll("[data-activity-section]"), + ).map((section) => section.getAttribute("data-activity-section")); + expect(sections).toEqual(["needs-you", "working", "done"]); + expect( + Array.from(dialog.querySelectorAll("[data-activity-row]")).map((row) => + row.getAttribute("data-activity-row"), + ), + ).toEqual(["asks", "live", "done"]); + }); + + it("omits a section with nothing in it rather than showing an empty heading", () => { + seedItems([item("live", "running")]); + renderControl(); + const dialog = openPanel(); + + expect(dialog.querySelectorAll("[data-activity-section]").length).toBe(1); + expect(screen.queryByRole("heading", { name: /Needs you/ })).toBeNull(); + }); + + it("caps a section at six rows and offers the rest to the pane", () => { + seedItems( + Array.from({ length: 8 }, (_unused, index) => item(`n${index}`, "needs_you")), + ); + const onOpenPane = renderControl(); + const dialog = openPanel(); + + expect(dialog.querySelectorAll("[data-activity-row]").length).toBe(6); + const overflow = screen.getByRole("button", { name: /2 more/ }); + fireEvent.click(overflow); + expect(onOpenPane).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("designs the all-clear state instead of apologising for empty space", () => { + seedItems([]); + renderControl(); + const dialog = openPanel(); + + expect(screen.getByText("All agents idle")).toBeTruthy(); + expect(screen.getByText("Nothing needs you.")).toBeTruthy(); + expect(dialog.querySelector(".activity-hdr-calm-dot")).toBeTruthy(); + expect(screen.getByTestId("header-activity-trigger").getAttribute("aria-label")) + .toBe("Activity · all agents idle"); + }); + + it("never restates its own name in a filler caption", () => { + seedItems([item("a", "needs_you")]); + renderControl(); + const dialog = openPanel(); + + const head = dialog.querySelector(".activity-hdr-panel-head"); + expect(head?.querySelector("p")).toBeNull(); + expect(dialog.textContent).not.toContain("Activity is live"); + expect(dialog.textContent).not.toContain("Across every machine"); + }); + + it("counts sessions and machines in the footer and hands off to the pane", () => { + seedItems([ + item("a", "needs_you"), + item("b", "running", { + machine: { + machineKey: "laptop", + name: "MacBook Pro", + online: false, + lastSeenAt: "2026-08-01T10:00:00.000Z", + }, + }), + ]); + const onOpenPane = renderControl(); + const dialog = openPanel(); + + const footer = dialog.querySelector(".activity-hdr-panel-foot") as HTMLElement; + expect(within(footer).getByText("2 sessions · 1 of 2 machines online")).toBeTruthy(); + expect(dialog.textContent).toContain("last-known state from an offline machine"); + + fireEvent.click(within(footer).getByRole("button", { name: /Open all/ })); + expect(onOpenPane).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("opens the exact destination through the Activity bridge, then marks it seen", async () => { + seedItems([item("a", "needs_you")]); + renderControl(); + openPanel(); + + fireEvent.click(screen.getByRole("button", { name: /Task a/ })); + + await waitFor(() => + expect(openItem).toHaveBeenCalledWith(expect.objectContaining({ id: "a" })), + ); + await waitFor(() => + expect(acknowledge).toHaveBeenCalledWith( + expect.objectContaining({ itemIds: ["a"] }), + ), + ); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + }); + + it("keeps the item unseen and explains a failed navigation", async () => { + seedItems([item("a", "needs_you")]); + openItem.mockRejectedValueOnce(new Error("Studio Mac is offline")); + renderControl(); + openPanel(); + + fireEvent.click(screen.getByRole("button", { name: /Task a/ })); + + await waitFor(() => + expect(screen.getByRole("alert").textContent).toContain("Studio Mac is offline"), + ); + expect(acknowledge).not.toHaveBeenCalled(); + expect(activityStore.getState().itemsById.a?.seenAt).toBeNull(); + expect(screen.getByRole("dialog")).toBeTruthy(); + }); + + it("supports keyboard open, roving row navigation, and Escape returning focus", () => { + seedItems([item("a", "needs_you"), item("b", "needs_you")]); + renderControl(); + + const trigger = screen.getByTestId("header-activity-trigger"); + trigger.focus(); + fireEvent.keyDown(trigger, { key: "ArrowDown" }); + const dialog = screen.getByRole("dialog", { name: "Activity" }); + + fireEvent.keyDown(dialog, { key: "ArrowDown" }); + expect(document.activeElement?.getAttribute("data-activity-row")).toBe("a"); + fireEvent.keyDown(dialog, { key: "ArrowDown" }); + expect(document.activeElement?.getAttribute("data-activity-row")).toBe("b"); + fireEvent.keyDown(dialog, { key: "ArrowDown" }); + expect(document.activeElement?.getAttribute("data-activity-row")).toBe("a"); + fireEvent.keyDown(dialog, { key: "End" }); + expect(document.activeElement?.getAttribute("data-activity-row")).toBe("b"); + fireEvent.keyDown(dialog, { key: "Home" }); + expect(document.activeElement?.getAttribute("data-activity-row")).toBe("a"); + + fireEvent.keyDown(dialog, { key: "Escape" }); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + + it("hides agent text on every row when the account asks for hide-details", () => { + seedItems([item("a", "needs_you")]); + activityStore.setState({ + preferences: { + ...DEFAULT_ATTENTION_PREFERENCES, + account: { ...DEFAULT_ATTENTION_PREFERENCES.account, hideDetails: true }, + }, + }); + renderControl(); + openPanel(); + + expect(screen.getByText("private preview")).toBeTruthy(); + expect(screen.queryByText("preview")).toBeNull(); + }); + + it("offers a retry instead of pretending a failed sync is current", async () => { + seedItems([item("a", "needs_you")]); + getSnapshot.mockRejectedValue(new Error("Relay unreachable")); + renderControl(); + + fireEvent.click(screen.getByTestId("header-activity-trigger")); + const retry = await screen.findByRole("button", { + name: /Activity is unavailable · Retry/, + }); + expect(getSnapshot).toHaveBeenCalledTimes(1); + + fireEvent.click(retry); + await waitFor(() => expect(getSnapshot).toHaveBeenCalledTimes(2)); + }); + + it("surfaces a missing native notch helper with recovery guidance", async () => { + seedItems([]); + const retry = vi.fn(async () => ({ + state: "missing" as const, + title: "ADE Notch needs reinstalling", + message: "Reinstall or update ADE, then restart the app.", + recovery: "reinstall_or_update" as const, + surface: null, + })); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(window.ade ?? {}), + attentionNotch: { + publishSnapshot: vi.fn(), + updateSettings: vi.fn(), + getHealth: retry, + retry, + onAcknowledgeRequested: vi.fn(() => () => {}), + }, + }, + }); + renderControl(); + + fireEvent.click(screen.getByTestId("header-activity-trigger")); + + expect(await screen.findByText("ADE Notch needs reinstalling")).toBeTruthy(); + expect(screen.getByText(/Reinstall or update ADE/)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Check again" })); + await waitFor(() => expect(retry).toHaveBeenCalledTimes(2)); + }); + + it("stays honest when signed out instead of showing an empty account", () => { + publishAccountStatus(SIGNED_OUT_ACCOUNT); + renderControl(); + + const trigger = screen.getByTestId("header-activity-trigger"); + expect(trigger.getAttribute("data-state")).toBe("signed-out"); + expect(trigger.getAttribute("aria-label")).toBe( + "Activity · sign in to sync across machines", + ); + + fireEvent.click(trigger); + expect( + screen.getByText(/Sign in to ADE to follow agents and pull requests/), + ).toBeTruthy(); + }); + + it("keeps machine-local work visible while signed out", () => { + publishAccountStatus(SIGNED_OUT_ACCOUNT); + seedItems([item("local", "needs_you")]); + activityStore.setState({ + snapshotScope: "machine", + availability: { + state: "signed_out", + title: "Showing this Mac", + message: "Sign in to combine Activity across every ADE machine.", + recovery: "sign_in", + hostName: "This Mac", + }, + }); + renderControl(); + + const trigger = screen.getByTestId("header-activity-trigger"); + expect(trigger.getAttribute("data-state")).toBe("waiting"); + expect(trigger.textContent).toContain("1"); + expect(trigger.getAttribute("aria-label")).toContain("this machine only"); + + fireEvent.click(trigger); + expect(screen.getByRole("heading", { name: /Needs you/ })).toBeTruthy(); + expect( + screen.getByText(/Sign in to combine Activity across every ADE machine/), + ).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.tsx b/apps/desktop/src/renderer/components/activity/HeaderActivityControl.tsx similarity index 57% rename from apps/desktop/src/renderer/components/attention/HeaderAttentionControl.tsx rename to apps/desktop/src/renderer/components/activity/HeaderActivityControl.tsx index 7c386b084..a213ff101 100644 --- a/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.tsx +++ b/apps/desktop/src/renderer/components/activity/HeaderActivityControl.tsx @@ -5,7 +5,6 @@ import { ArrowRight, BellRinging, BellSimpleSlash, - GitPullRequest, WarningCircle, WifiHigh, WifiSlash, @@ -25,116 +24,76 @@ import { ADE_BROWSER_VIEW_OCCLUSION_START_EVENT, } from "../../lib/workSidebarBrowserResize"; import { - acknowledgeAttentionItem, - attentionStore, - useAttentionStore, -} from "../../state/attentionStore"; + acknowledgeActivityItem, + activityStore, + selectActivityHideDetails, + useActivityStore, +} from "../../state/activityStore"; import { useDialogFocusTrap } from "../app/HeaderSheet"; -import { ProviderLogo } from "../shared/ProviderLogos"; import { cn } from "../ui/cn"; +import { ActivityCard } from "./ActivityCard"; +import { ActivitySettingsPopover } from "./ActivitySettingsPopover"; import { - attentionPhasePresentation, - type AttentionTone, -} from "./attentionPresentation"; -import { - attentionHeaderTriggerLabel, - summarizeAttentionForHeader, - type AttentionHeaderBucket, -} from "./attentionHeaderSummary"; -import { refreshAttentionSnapshot } from "./useAttentionSync"; -import "./HeaderAttentionControl.css"; + ACTIVITY_SECTION_TONE, + activityTriggerLabel, + summarizeActivity, + type ActivitySection, +} from "./activityPriority"; +import { refreshActivitySnapshot } from "./useActivitySync"; +import "./HeaderActivityControl.css"; -/** Rows shown per section before the overflow hands off to the full center. */ -const MAX_ROWS_PER_BUCKET = 4; +/** + * Rows shown per section before the overflow line hands off to the pane. Six, + * not four: the sections are now priority-flat, so a single "Working" section + * routinely carries what three buckets used to split. + */ +const MAX_ROWS_PER_SECTION = 6; const RELATIVE_TIME_TICK_MS = 30_000; const IDLE_TICK_MS = 120_000; -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 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 machineLine(item: AttentionItem): string { - return item.machine.online - ? `${item.project.name} · ${item.machine.name}` - : `${item.project.name} · ${item.machine.name} (offline)`; +function pluralize(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? "" : "s"}`; } -function AttentionHeaderRow({ - item, - onOpen, -}: { - item: AttentionItem; - onOpen: () => void; -}) { - const phase = attentionPhasePresentation(item.phase); - return ( - - ); -} - -function AttentionHeaderSection({ - bucket, +function ActivityHeaderSection({ + section, + hideDetails, onOpenItem, - onOpenCenter, + onOpenPane, }: { - bucket: AttentionHeaderBucket; + section: ActivitySection; + hideDetails: boolean; onOpenItem: (item: AttentionItem) => void; - onOpenCenter: () => void; + onOpenPane: () => void; }) { - const shown = bucket.items.slice(0, MAX_ROWS_PER_BUCKET); - const overflow = bucket.items.length - shown.length; + const shown = section.items.slice(0, MAX_ROWS_PER_SECTION); + const overflow = section.items.length - shown.length; return ( -
-

- - {bucket.label} - {bucket.items.length} +
+

+ + {section.label} + {section.items.length}

{shown.map((item) => ( - onOpenItem(item)} /> + ))} {overflow > 0 ? ( - ) : null} @@ -143,22 +102,23 @@ function AttentionHeaderSection({ } /** - * Account-wide Attention, promoted into the global header so live work, things - * that need you, failures, and finished-but-unreviewed outcomes are one glance - * away from every tab and every project. The full Attention center stays the - * place to triage at length; this is the doorway to it. + * Account-wide Activity, promoted into the global header so live work, things + * that need you, and finished-but-unreviewed outcomes are one glance away from + * every tab and every project. Three priority-flat sections — needs you, + * working, done — and a handoff to the full pane for everything past the cap. */ -export function HeaderAttentionControl({ - onOpenCenter, +export function HeaderActivityControl({ + onOpenPane, }: { - /** Routes to the full Attention center — the shell owns navigation. */ - onOpenCenter: () => void; + /** Opens the full Activity surface — the shell owns how. */ + onOpenPane: () => void; }) { - const itemsById = useAttentionStore((state) => state.itemsById); - const syncStatus = useAttentionStore((state) => state.syncStatus); - const syncError = useAttentionStore((state) => state.syncError); - const generatedAt = useAttentionStore((state) => state.generatedAt); - const availability = useAttentionStore((state) => state.availability); + const itemsById = useActivityStore((state) => state.itemsById); + const syncStatus = useActivityStore((state) => state.syncStatus); + const syncError = useActivityStore((state) => state.syncError); + const generatedAt = useActivityStore((state) => state.generatedAt); + const availability = useActivityStore((state) => state.availability); + const hideDetails = useActivityStore(selectActivityHideDetails); const { status: accountStatus, loading: accountLoading } = useAccountStatus(); const signedIn = accountStatus.signedIn; @@ -169,10 +129,7 @@ export function HeaderAttentionControl({ const triggerRef = useRef(null); const panelRef = useRef(null); - const summary = useMemo( - () => summarizeAttentionForHeader(itemsById, now), - [itemsById, now], - ); + const summary = useMemo(() => summarizeActivity(itemsById, now), [itemsById, now]); const close = useCallback(() => { setOpen(false); @@ -181,6 +138,9 @@ export function HeaderAttentionControl({ const openPopover = useCallback(() => { setOpen(true); + // Event name, properties and dedupe key are deliberately unchanged through + // the Attention → Activity rename: forking them would fork the PostHog + // series and lose every comparison against the surface this replaces. void window.ade?.analytics?.capture({ event: "ade_feature_used", properties: { @@ -216,16 +176,16 @@ export function HeaderAttentionControl({ useEffect(() => { if (!open) return; setNavigationError(null); - void refreshAttentionSnapshot(); + void refreshActivitySnapshot(); void window.ade?.attentionNotch?.getHealth?.() .then(setNotchHealth) .catch(() => setNotchHealth(null)); }, [open]); useEffect(() => { - attentionStore.getState().setHeaderSurfaceVisible(open); + activityStore.getState().setHeaderSurfaceVisible(open); return () => { - attentionStore.getState().setHeaderSurfaceVisible(false); + activityStore.getState().setHeaderSurfaceVisible(false); }; }, [open]); @@ -256,22 +216,26 @@ export function HeaderAttentionControl({ return; } // Only a destination that actually resolved earns the item leaving unseen. - await acknowledgeAttentionItem(item.id, "seen").catch(() => {}); + await acknowledgeActivityItem(item.id, "seen").catch(() => {}); setOpen(false); }, []); - const openCenter = useCallback(() => { + const openPane = useCallback(() => { setOpen(false); - onOpenCenter(); - }, [onOpenCenter]); + onOpenPane(); + }, [onOpenPane]); const onPanelKeyDown = useCallback( (event: React.KeyboardEvent) => { + // The settings popover is a dialog of its own inside this one. While + // focus is in it, its keys are its business — otherwise Escape would + // close both at once and an arrow key would yank focus out to a row. + if ((event.target as HTMLElement | null)?.closest?.(".activity-settings-popover")) { + return; + } const delta = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0; const rows = Array.from( - event.currentTarget.querySelectorAll( - "[data-attention-header-row]", - ), + event.currentTarget.querySelectorAll("[data-activity-row]"), ); if (delta !== 0 && rows.length > 0) { event.preventDefault(); @@ -300,24 +264,25 @@ export function HeaderAttentionControl({ && availability.state !== "ready" && availability.state !== "signed_out"; const signedOutEmpty = signedOut && summary.trackedCount === 0; - const badgeCount = summary.waitingCount; - const hasLiveOnly = badgeCount === 0 && summary.liveCount > 0; + const badgeCount = summary.needsYouCount; + const hasLiveOnly = badgeCount === 0 && summary.workingCount > 0; + const baseLabel = activityTriggerLabel(summary).replace(/^Activity · /, ""); const triggerLabel = signedOut ? signedOutEmpty - ? "Attention · sign in to sync across machines" - : `Attention · this machine only · ${attentionHeaderTriggerLabel(summary).replace(/^Attention · /, "")} · sign in to sync` + ? "Activity · sign in to sync across machines" + : `Activity · this machine only · ${baseLabel} · sign in to sync` : degraded - ? `Attention · ${availability.title} · ${attentionHeaderTriggerLabel(summary).replace(/^Attention · /, "")}` - : attentionHeaderTriggerLabel(summary); + ? `Activity · ${availability.title} · ${baseLabel}` + : activityTriggerLabel(summary); const state = signedOutEmpty ? "signed-out" : degraded ? "degraded" - : badgeCount > 0 - ? "waiting" - : hasLiveOnly - ? "live" - : "clear"; + : badgeCount > 0 + ? "waiting" + : hasLiveOnly + ? "live" + : "clear"; const freshness = degraded ? { @@ -326,30 +291,37 @@ export function HeaderAttentionControl({ retry: availability.recovery === "retry", } : syncStatus === "error" - ? { tone: "error" as const, label: "Sync failed", retry: true } - : syncStatus === "syncing" - ? { tone: "syncing" as const, label: "Syncing", retry: false } - : generatedAt - ? { tone: "ready" as const, label: `Synced ${relativeWhen(generatedAt)}`, retry: false } - : null; + ? { tone: "error" as const, label: "Sync failed", retry: true } + : syncStatus === "syncing" + ? { tone: "syncing" as const, label: "Syncing", retry: false } + : generatedAt + ? { tone: "ready" as const, label: `Synced ${relativeWhen(generatedAt)}`, retry: false } + : null; const notchNeedsAttention = notchHealth != null && notchHealth.state !== "disabled" && notchHealth.state !== "starting" && notchHealth.state !== "running" && notchHealth.state !== "unsupported"; + const populatedSections = summary.sections.filter((section) => section.items.length > 0); + const machineLine = summary.machinesTotal === 0 + ? null + : summary.machinesOnline === summary.machinesTotal + ? pluralize(summary.machinesTotal, "machine") + : `${summary.machinesOnline} of ${pluralize(summary.machinesTotal, "machine")} online`; + return ( <> {open && typeof document !== "undefined" ? createPortal(
setOpen(false)} >
event.stopPropagation()} onKeyDown={onPanelKeyDown} > -
-
-

Attention

-

- {signedOut - ? availability?.title ?? "This machine only" - : availability?.title - ? availability.title - : summary.machinesTotal > 0 - ? `${summary.machinesOnline} of ${summary.machinesTotal} machine${summary.machinesTotal === 1 ? "" : "s"} online` - : "Across every machine on your account"} -

-
+
+ {/* No sub-caption. The line that used to sit here only ever + restated the surface's own name ("Account Activity is + live"), and a header that describes itself is a header + that has nothing to say. */} +

Activity

{freshness ? ( freshness.tone === "error" && freshness.retry ? ( ) : ( - + {freshness.tone === "syncing" ? ( - + ) : ( )} @@ -434,33 +399,34 @@ export function HeaderAttentionControl({ ) ) : null} +
{navigationError ? ( -
+
{navigationError}
) : null} {degraded ? ( -
+
{availability.message}
) : null} {signedOut && !signedOutEmpty ? ( -
+
{availability?.message @@ -470,7 +436,7 @@ export function HeaderAttentionControl({ ) : null} {notchNeedsAttention ? ( -
+
{notchHealth.title} @@ -484,7 +450,7 @@ export function HeaderAttentionControl({ ) : null} {summary.staleMachineCount > 0 ? ( -
+
{summary.staleMachineCount} item @@ -494,9 +460,9 @@ export function HeaderAttentionControl({
) : null} -
+
{signedOutEmpty ? ( -
+
Signed out

@@ -504,37 +470,35 @@ export function HeaderAttentionControl({ machine on your account.

- ) : summary.buckets.length === 0 ? ( -
- - All clear + ) : populatedSections.length === 0 ? ( +
+ + All agents idle

- {availability?.message - ?? (syncStatus === "error" - ? syncError ?? "Attention couldn’t sync, so this may be stale." - : "Nothing is running, waiting on you, or newly finished.")} + {syncStatus === "error" + ? syncError ?? "Activity couldn’t sync, so this may be stale." + : "Nothing needs you."}

) : ( - summary.buckets.map((bucket) => ( - ( + void openItem(item)} - onOpenCenter={openCenter} + onOpenPane={openPane} /> )) )}
-
+
- {summary.trackedCount} tracked - {summary.liveCount > 0 && badgeCount > 0 - ? ` · ${summary.liveCount} live` - : ""} + {pluralize(summary.trackedCount, "session")} + {machineLine ? ` · ${machineLine}` : ""} - @@ -547,5 +511,3 @@ export function HeaderAttentionControl({ ); } - -export default HeaderAttentionControl; diff --git a/apps/desktop/src/renderer/components/activity/activityNotchLocalSettings.test.ts b/apps/desktop/src/renderer/components/activity/activityNotchLocalSettings.test.ts new file mode 100644 index 000000000..d7f49f783 --- /dev/null +++ b/apps/desktop/src/renderer/components/activity/activityNotchLocalSettings.test.ts @@ -0,0 +1,146 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it } from "vitest"; + +import { DEFAULT_ATTENTION_PREFERENCES } from "../../../shared/types"; +import { + activityNotchSettingsFromPreferences, + activityPreferencesWithNotchPresentation, + onActivityNotchSettingsChanged, + persistActivityNotchSettings, + readActivityNotchEnabled, + readActivityNotchPresentation, + resolveActivityNotchPresentation, + writeActivityNotchEnabled, + writeActivityNotchPresentation, +} from "./activityNotchLocalSettings"; + +/** + * These key strings are the regression guard for the Attention → Activity + * rename. Every symbol around them moved; renaming one of these would silently + * reset the notch on every Mac that has ever configured it. + */ +describe("Activity notch local settings", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("defaults safely and falls back from an unreadable reveal mode", () => { + expect(readActivityNotchEnabled()).toBe(true); + expect(readActivityNotchPresentation()).toEqual({ + revealMode: "hover", + expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, + }); + + window.localStorage.setItem("ade:attention:notch-reveal-mode", "telepathy"); + expect(readActivityNotchPresentation().revealMode).toBe("hover"); + }); + + it("round-trips every presentation mode independently from full disable", () => { + for (const revealMode of ["minimal", "hover", "click"] as const) { + writeActivityNotchPresentation({ + revealMode, + expandedPanelEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: true, + }); + expect(readActivityNotchPresentation()).toEqual({ + revealMode, + expandedPanelEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: true, + }); + } + writeActivityNotchEnabled(false); + + expect(activityNotchSettingsFromPreferences(DEFAULT_ATTENTION_PREFERENCES)) + .toMatchObject({ + enabled: false, + revealMode: "click", + expandedPanelEnabled: false, + }); + }); + + it("persists native context-menu changes and notifies the renderer", () => { + let observed: ReturnType | null = null; + const unsubscribe = onActivityNotchSettingsChanged((settings) => { + observed = settings; + }); + persistActivityNotchSettings({ + enabled: false, + revealMode: "minimal", + expandedPanelEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: false, + preferredDisplayId: null, + hideDetails: true, + celebrationsEnabled: true, + soundsEnabled: false, + }); + + expect(readActivityNotchEnabled()).toBe(false); + expect(readActivityNotchPresentation()).toEqual({ + revealMode: "minimal", + expandedPanelEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: false, + }); + expect(observed).toMatchObject({ + enabled: false, + revealMode: "minimal", + expandedPanelEnabled: false, + }); + unsubscribe(); + }); + + it("prefers the synced presentation and falls back to this Mac's cache", () => { + writeActivityNotchPresentation({ + revealMode: "click", + expandedPanelEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: false, + }); + + // Nothing synced yet: the local cache is the whole answer, so an offline or + // signed-out launch opens the notch the way this Mac last had it. + expect(resolveActivityNotchPresentation(null)).toEqual({ + revealMode: "click", + expandedPanelEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: false, + }); + + const synced = activityPreferencesWithNotchPresentation(DEFAULT_ATTENTION_PREFERENCES, { + revealMode: "minimal", + expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, + }); + expect(resolveActivityNotchPresentation(synced)).toEqual({ + revealMode: "minimal", + expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, + }); + }); + + it("ignores a synced reveal mode this build has never heard of", () => { + writeActivityNotchPresentation({ + revealMode: "minimal", + expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, + }); + const preferences = { + ...DEFAULT_ATTENTION_PREFERENCES, + account: { + ...DEFAULT_ATTENTION_PREFERENCES.account, + notchRevealMode: "telepathy" as never, + }, + }; + + expect(resolveActivityNotchPresentation(preferences).revealMode).toBe("minimal"); + }); +}); diff --git a/apps/desktop/src/renderer/components/activity/activityNotchLocalSettings.ts b/apps/desktop/src/renderer/components/activity/activityNotchLocalSettings.ts new file mode 100644 index 000000000..6d46415ee --- /dev/null +++ b/apps/desktop/src/renderer/components/activity/activityNotchLocalSettings.ts @@ -0,0 +1,224 @@ +import type { + AttentionNotchRevealMode, + AttentionNotchSettings, + AttentionPreferences, +} from "../../../shared/types"; +import { + DEFAULT_ATTENTION_NOTCH_REVEAL_MODE, + DEFAULT_ATTENTION_PREFERENCES, + isAttentionNotchRevealMode, +} from "../../../shared/types"; + +const ATTENTION_NOTCH_ENABLED_KEY = "ade:attention:notch-enabled"; +const ATTENTION_NOTCH_REVEAL_MODE_KEY = "ade:attention:notch-reveal-mode"; +const ATTENTION_NOTCH_EXPANDED_PANEL_KEY = "ade:attention:notch-expanded-panel"; +// New settings get new keys: the three above are frozen wire for anyone who +// has already made a choice on this Mac. +const ATTENTION_NOTCH_AUTO_REVEAL_KEY = "ade:attention:notch-auto-reveal"; +const ATTENTION_NOTCH_TICKER_KEY = "ade:attention:notch-ticker"; +const ATTENTION_NOTCH_SETTINGS_CHANGED_EVENT = "ade:attention-notch-settings-changed"; + +/** + * How the notch presents itself. Account preferences are authoritative when + * loaded; this Mac keeps the same shape in localStorage as its offline cache. + */ +export type ActivityNotchPresentation = { + revealMode: AttentionNotchRevealMode; + expandedPanelEnabled: boolean; + automaticRevealEnabled: boolean; + tickerEnabled: boolean; +}; + +/** What a Mac that has never been configured gets: today's behaviour. */ +export const DEFAULT_ACTIVITY_NOTCH_PRESENTATION: ActivityNotchPresentation = { + revealMode: DEFAULT_ATTENTION_NOTCH_REVEAL_MODE, + expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, +}; + +/** + * A property read is not a capability check on the hosted web adapter: its + * fallback proxy fabricates callable namespaces for missing properties. The + * `in` probe reaches the real exposed surface (or the proxy target), so web + * renderers do not build and stringify native-only snapshots on every update. + */ +export function activityNotchSupported(): boolean { + return typeof window !== "undefined" + && window.ade != null + && "attentionNotch" in window.ade; +} + +function readLocalItem(key: string): string | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage.getItem(key); + } catch { + return null; + } +} + +function writeLocalItem(key: string, value: string): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(key, value); + } catch { + // A restricted renderer still keeps the setting for the current process + // through the native helper update issued by the caller. + } +} + +export function readActivityNotchEnabled(): boolean { + return readLocalItem(ATTENTION_NOTCH_ENABLED_KEY) !== "false"; +} + +export function writeActivityNotchEnabled(enabled: boolean): void { + writeLocalItem(ATTENTION_NOTCH_ENABLED_KEY, String(enabled)); +} + +/** A value this build has never heard of falls back to the shipped behaviour. */ +export function readActivityNotchPresentation(): ActivityNotchPresentation { + const revealMode = readLocalItem(ATTENTION_NOTCH_REVEAL_MODE_KEY); + return { + revealMode: isAttentionNotchRevealMode(revealMode) + ? revealMode + : DEFAULT_ACTIVITY_NOTCH_PRESENTATION.revealMode, + expandedPanelEnabled: readLocalItem(ATTENTION_NOTCH_EXPANDED_PANEL_KEY) !== "false", + automaticRevealEnabled: readLocalItem(ATTENTION_NOTCH_AUTO_REVEAL_KEY) !== "false", + tickerEnabled: readLocalItem(ATTENTION_NOTCH_TICKER_KEY) !== "false", + }; +} + +export function writeActivityNotchPresentation( + presentation: ActivityNotchPresentation, +): void { + writeLocalItem(ATTENTION_NOTCH_REVEAL_MODE_KEY, presentation.revealMode); + writeLocalItem( + ATTENTION_NOTCH_EXPANDED_PANEL_KEY, + String(presentation.expandedPanelEnabled), + ); + writeLocalItem( + ATTENTION_NOTCH_AUTO_REVEAL_KEY, + String(presentation.automaticRevealEnabled), + ); + writeLocalItem(ATTENTION_NOTCH_TICKER_KEY, String(presentation.tickerEnabled)); +} + +/** + * Notch presentation now lives in account preferences so a second Mac inherits + * it, with localStorage kept as the offline cache. Read synced-else-local: a + * signed-out or not-yet-loaded window still shows the choice this Mac made + * rather than snapping back to the shipped default for a frame. + */ +export function resolveActivityNotchPresentation( + preferences: AttentionPreferences | null | undefined, + local: ActivityNotchPresentation = readActivityNotchPresentation(), +): ActivityNotchPresentation { + const account = preferences?.account; + return { + revealMode: isAttentionNotchRevealMode(account?.notchRevealMode) + ? account.notchRevealMode + : local.revealMode, + expandedPanelEnabled: typeof account?.notchExpandedPanel === "boolean" + ? account.notchExpandedPanel + : local.expandedPanelEnabled, + automaticRevealEnabled: typeof account?.notchAutomaticReveal === "boolean" + ? account.notchAutomaticReveal + : local.automaticRevealEnabled, + tickerEnabled: typeof account?.notchTicker === "boolean" + ? account.notchTicker + : local.tickerEnabled, + }; +} + +/** The account half of a write-both. The caller still writes localStorage. */ +export function activityPreferencesWithNotchPresentation( + preferences: AttentionPreferences, + presentation: ActivityNotchPresentation, +): AttentionPreferences { + return { + ...preferences, + account: { + ...preferences.account, + notchRevealMode: presentation.revealMode, + notchExpandedPanel: presentation.expandedPanelEnabled, + notchAutomaticReveal: presentation.automaticRevealEnabled, + notchTicker: presentation.tickerEnabled, + }, + }; +} + +export function persistActivityNotchSettings( + settings: AttentionNotchSettings, +): void { + writeActivityNotchEnabled(settings.enabled); + writeActivityNotchPresentation({ + revealMode: settings.revealMode, + expandedPanelEnabled: settings.expandedPanelEnabled, + automaticRevealEnabled: settings.automaticRevealEnabled, + tickerEnabled: settings.tickerEnabled, + }); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent( + ATTENTION_NOTCH_SETTINGS_CHANGED_EVENT, + { detail: settings }, + )); + } +} + +export function onActivityNotchSettingsChanged( + callback: (settings: AttentionNotchSettings) => void, +): () => void { + if (typeof window === "undefined") return () => {}; + const listener = (event: Event) => { + if (!(event instanceof CustomEvent)) return; + callback(event.detail as AttentionNotchSettings); + }; + window.addEventListener(ATTENTION_NOTCH_SETTINGS_CHANGED_EVENT, listener); + return () => + window.removeEventListener(ATTENTION_NOTCH_SETTINGS_CHANGED_EVENT, listener); +} + +export function normalizeActivityPreferences( + 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 ?? {}, + machines: preferences.machines ?? {}, + projects: preferences.projects ?? {}, + mutedSessionIds: preferences.mutedSessionIds ?? [], + }; +} + +export function activityNotchSettingsFromPreferences( + preferences: AttentionPreferences, + enabled = readActivityNotchEnabled(), + presentation: ActivityNotchPresentation = readActivityNotchPresentation(), +): AttentionNotchSettings { + const normalized = normalizeActivityPreferences(preferences); + return { + enabled, + revealMode: presentation.revealMode, + expandedPanelEnabled: presentation.expandedPanelEnabled, + automaticRevealEnabled: presentation.automaticRevealEnabled, + tickerEnabled: presentation.tickerEnabled, + preferredDisplayId: null, + hideDetails: normalized.account.hideDetails, + celebrationsEnabled: normalized.account.celebrationsEnabled, + soundsEnabled: normalized.account.soundsEnabled, + }; +} diff --git a/apps/desktop/src/renderer/components/attention/attentionPresentation.test.ts b/apps/desktop/src/renderer/components/activity/activityPresentation.test.ts similarity index 61% rename from apps/desktop/src/renderer/components/attention/attentionPresentation.test.ts rename to apps/desktop/src/renderer/components/activity/activityPresentation.test.ts index a3af91fb3..765f4a1bf 100644 --- a/apps/desktop/src/renderer/components/attention/attentionPresentation.test.ts +++ b/apps/desktop/src/renderer/components/activity/activityPresentation.test.ts @@ -1,15 +1,19 @@ import { describe, expect, it } from "vitest"; -import { attentionPhasePriority, type AttentionPhase } from "../../../shared/types"; +import { + attentionPhasePriority, + type AttentionItem, + type AttentionPhase, +} from "../../../shared/types"; import type { CanonicalSessionPhase } from "../../../shared/sessionCanonicalState"; import { sessionStatusPresentation } from "../../../shared/sessionStatusPresentation"; import { - attentionPhaseIsSessionDerived, - attentionPhasePresentation, - attentionViewEmptyCopy, - SESSION_DERIVED_ATTENTION_PHASES, + activityPhaseIsSessionDerived, + activityPhasePresentation, + activityItemPresentation, + SESSION_DERIVED_ACTIVITY_PHASES, type AttentionTone, -} from "./attentionPresentation"; +} from "./activityPresentation"; /** * Written as an exhaustive record rather than an array so a new `AttentionPhase` @@ -41,7 +45,7 @@ const ALL_ATTENTION_PHASES = Object.keys({ */ const AMBER_ALLOWLIST: AttentionPhase[] = ["needs_you"]; -describe("attention phase presentation", () => { +describe("Activity phase presentation", () => { it("speaks the sidebar's words for every session-derived phase", () => { // Not a hand-copied table: each expectation is the phase's canonical // counterpart, so a wording change in sessionStatusPresentation.ts either @@ -55,37 +59,66 @@ describe("attention phase presentation", () => { stale: "stale", }; - expect(SESSION_DERIVED_ATTENTION_PHASES.slice().sort()) + expect(SESSION_DERIVED_ACTIVITY_PHASES.slice().sort()) .toEqual(Object.keys(bridge).sort()); for (const [attentionPhase, canonicalPhase] of Object.entries(bridge)) { const canonical = sessionStatusPresentation(canonicalPhase); - const attention = attentionPhasePresentation(attentionPhase as AttentionPhase); + const attention = activityPhasePresentation(attentionPhase as AttentionPhase); expect(canonical).not.toBeNull(); expect(attention.label).toBe(canonical?.label); expect(attention.tone).toBe(canonical?.tone); } }); + it("returns the complete canonical status presentation for every session-derived phase", () => { + const bridge: Record = { + starting: "starting", + running: "running", + needs_you: "needs_you", + completed: "ready", + failed: "failed", + stale: "stale", + }; + + for (const [attentionPhase, canonicalPhase] of Object.entries(bridge)) { + expect(activityItemPresentation({ phase: attentionPhase } as AttentionItem)) + .toEqual(sessionStatusPresentation(canonicalPhase)); + } + }); + + it("returns a complete status presentation for every PR-only phase", () => { + for (const phase of ALL_ATTENTION_PHASES.filter( + (candidate) => !activityPhaseIsSessionDerived(candidate), + )) { + const presentation = activityItemPresentation({ phase } as AttentionItem); + expect(presentation).toMatchObject({ + label: activityPhasePresentation(phase).label, + tone: activityPhasePresentation(phase).tone, + showsElapsed: false, + }); + } + }); + it("labels work in motion 'Working' and clean outcomes 'Done'", () => { // The two renames the sidebar redesign turned on. Asserted literally as // well as via the bridge above, because these exact words appear in // notification copy and in the iOS mirror — a silent drift here desyncs // three surfaces at once. - expect(attentionPhasePresentation("running")).toEqual({ + expect(activityPhasePresentation("running")).toEqual({ label: "Working", tone: "blue", active: true, }); - expect(attentionPhasePresentation("completed")).toEqual({ + expect(activityPhasePresentation("completed")).toEqual({ label: "Done", tone: "emerald", active: false, }); - expect(attentionPhasePresentation("starting").label).toBe("Starting"); - expect(attentionPhasePresentation("needs_you").label).toBe("Needs you"); - expect(attentionPhasePresentation("failed").label).toBe("Failed"); - expect(attentionPhasePresentation("stale").label).toBe("Stale"); + expect(activityPhasePresentation("starting").label).toBe("Starting"); + expect(activityPhasePresentation("needs_you").label).toBe("Needs you"); + expect(activityPhasePresentation("failed").label).toBe("Failed"); + expect(activityPhasePresentation("stale").label).toBe("Stale"); }); /** @@ -96,54 +129,54 @@ describe("attention phase presentation", () => { */ it("spends amber only on states that need the user to act", () => { const amberPhases = ALL_ATTENTION_PHASES.filter( - (phase) => attentionPhasePresentation(phase).tone === "amber", + (phase) => activityPhasePresentation(phase).tone === "amber", ); expect(amberPhases.sort()).toEqual(AMBER_ALLOWLIST.slice().sort()); }); it("keeps every session-derived phase out of amber unless it is a raised hand", () => { - for (const phase of SESSION_DERIVED_ATTENTION_PHASES) { + for (const phase of SESSION_DERIVED_ACTIVITY_PHASES) { if (phase === "needs_you") { - expect(attentionPhasePresentation(phase).tone).toBe("amber"); + expect(activityPhasePresentation(phase).tone).toBe("amber"); continue; } - expect(attentionPhasePresentation(phase).tone).not.toBe("amber"); + expect(activityPhasePresentation(phase).tone).not.toBe("amber"); } }); it("keeps the deliberate deviations from the old vocabulary", () => { // `completed` is emerald, not amber: "finished, go look" must not wear the // same colour as "blocked, go act". - expect(attentionPhasePresentation("completed").tone).toBe("emerald"); + expect(activityPhasePresentation("completed").tone).toBe("emerald"); // `stale` is neutral, not amber and not blue: a silent process is true but // not actionable, and calling it live was the lie the old green dot told. - expect(attentionPhasePresentation("stale").tone).toBe("neutral"); - expect(attentionPhasePresentation("stale").active).toBe(false); + expect(activityPhasePresentation("stale").tone).toBe("neutral"); + expect(activityPhasePresentation("stale").active).toBe(false); // `failed` keeps red, so red still means exactly one thing: it broke. - expect(attentionPhasePresentation("failed").tone).toBe("red"); + expect(activityPhasePresentation("failed").tone).toBe("red"); }); it("returns a presentation for every phase and pulses only live ones", () => { const active = ALL_ATTENTION_PHASES.filter( - (phase) => attentionPhasePresentation(phase).active, + (phase) => activityPhasePresentation(phase).active, ); expect(active.sort()).toEqual(["needs_you", "running", "starting"]); for (const phase of ALL_ATTENTION_PHASES) { - expect(attentionPhasePresentation(phase).label.length).toBeGreaterThan(0); + expect(activityPhasePresentation(phase).label.length).toBeGreaterThan(0); } }); it("knows which phases came from a session and which are pull-request only", () => { - expect(attentionPhaseIsSessionDerived("running")).toBe(true); - expect(attentionPhaseIsSessionDerived("completed")).toBe(true); - expect(attentionPhaseIsSessionDerived("merge_ready")).toBe(false); - expect(attentionPhaseIsSessionDerived("blocked")).toBe(false); + expect(activityPhaseIsSessionDerived("running")).toBe(true); + expect(activityPhaseIsSessionDerived("completed")).toBe(true); + expect(activityPhaseIsSessionDerived("merge_ready")).toBe(false); + expect(activityPhaseIsSessionDerived("blocked")).toBe(false); }); it("never colours a pull-request phase amber", () => { const prTones: AttentionTone[] = ALL_ATTENTION_PHASES - .filter((phase) => !attentionPhaseIsSessionDerived(phase)) - .map((phase) => attentionPhasePresentation(phase).tone); + .filter((phase) => !activityPhaseIsSessionDerived(phase)) + .map((phase) => activityPhasePresentation(phase).tone); expect(prTones).not.toContain("amber"); }); @@ -151,7 +184,7 @@ describe("attention phase presentation", () => { // `blocked` is the phase most likely to be re-promoted to amber by someone // reading the word alone. Its priority tier is the argument against that: // it sits with review_requested and merge_ready, not with needs_you. - expect(attentionPhasePresentation("blocked")).toEqual({ + expect(activityPhasePresentation("blocked")).toEqual({ label: "Blocked", tone: "neutral", active: false, @@ -161,10 +194,4 @@ describe("attention phase presentation", () => { attentionPhasePriority("needs_you"), ); }); - - it("uses the row's own word for finished work in empty-state copy", () => { - const recent = attentionViewEmptyCopy("recent"); - expect(recent.body).toContain("Done"); - expect(recent.body).not.toMatch(/completed/i); - }); }); diff --git a/apps/desktop/src/renderer/components/attention/attentionPresentation.ts b/apps/desktop/src/renderer/components/activity/activityPresentation.ts similarity index 64% rename from apps/desktop/src/renderer/components/attention/attentionPresentation.ts rename to apps/desktop/src/renderer/components/activity/activityPresentation.ts index 462503d51..603f9b1c3 100644 --- a/apps/desktop/src/renderer/components/attention/attentionPresentation.ts +++ b/apps/desktop/src/renderer/components/activity/activityPresentation.ts @@ -1,41 +1,26 @@ -import type { AttentionActionKind, AttentionPhase } from "../../../shared/types"; +import type { + AttentionActionKind, + AttentionItem, + AttentionPhase, + AttentionTone, +} from "../../../shared/types"; import type { CanonicalSessionPhase } from "../../../shared/sessionCanonicalState"; import { sessionStatusPresentation, + type SessionStatusGlyph, type SessionStatusPresentation, + type SessionStatusTone, } from "../../../shared/sessionStatusPresentation"; /** - * Attention's tone vocabulary is `sessionStatusPresentation`'s five hues plus - * two that only pull requests ever use. The session five keep their meanings - * exactly — see the one-hue-one-meaning rule in - * `apps/desktop/src/shared/sessionStatusPresentation.ts`: - * - * blue work is happening, nothing is asked of you - * amber YOUR MOVE — and nothing else, ever - * emerald finished cleanly, you have not looked yet - * red it broke - * neutral true, but not actionable - * - * Exactly one phase in this module is amber: `needs_you`. - * - * `violet` carries "a human review is outstanding" — neither "your move" (it is - * usually someone else's) nor an outcome, and without its own hue it would have - * to borrow amber, which is precisely the erosion the rule forbids. `cyan` is - * currently unused by any phase; it stays in the union and the stylesheets as - * the spare for the next PR-side distinction, and must never be handed to a - * session state — those five hues are settled. + * The tone vocabulary itself moved to `shared/types/attention.ts` — the native + * notch protocol carries it on the wire, so the main process has to name it + * too. Its meanings, and the reason `violet` and `cyan` exist at all, are + * documented there. Exactly one phase in this module is amber: `needs_you`. */ -export type AttentionTone = - | "amber" - | "red" - | "violet" - | "blue" - | "cyan" - | "emerald" - | "neutral"; - -export type AttentionPhasePresentation = { +export type { AttentionTone }; + +export type ActivityPhasePresentation = { label: string; tone: AttentionTone; /** @@ -68,16 +53,16 @@ const SESSION_PHASE_BY_ATTENTION_PHASE = { stale: "stale", } as const satisfies Partial>; -export type SessionDerivedAttentionPhase = keyof typeof SESSION_PHASE_BY_ATTENTION_PHASE; +export type SessionDerivedActivityPhase = keyof typeof SESSION_PHASE_BY_ATTENTION_PHASE; /** * Every session-derived attention phase, exported so the regression test that * guards the one-hue rule enumerates the real list instead of a copy that can * silently fall behind. */ -export const SESSION_DERIVED_ATTENTION_PHASES = Object.keys( +export const SESSION_DERIVED_ACTIVITY_PHASES = Object.keys( SESSION_PHASE_BY_ATTENTION_PHASE, -) as SessionDerivedAttentionPhase[]; +) as SessionDerivedActivityPhase[]; /** * Only these pulse: a session that is in motion or is actively holding for the @@ -102,8 +87,8 @@ function requireSessionPresentation(phase: CanonicalSessionPhase): SessionStatus } function sessionDerivedPresentation( - phase: SessionDerivedAttentionPhase, -): AttentionPhasePresentation { + phase: SessionDerivedActivityPhase, +): ActivityPhasePresentation { const presentation = requireSessionPresentation(SESSION_PHASE_BY_ATTENTION_PHASE[phase]); // This annotated assignment is what enforces `SessionStatusTone ⊆ AttentionTone` // at compile time: add a hue to the session vocabulary that Attention has no @@ -114,8 +99,8 @@ function sessionDerivedPresentation( } const SESSION_DERIVED_PRESENTATION = Object.fromEntries( - SESSION_DERIVED_ATTENTION_PHASES.map((phase) => [phase, sessionDerivedPresentation(phase)]), -) as Record; + SESSION_DERIVED_ACTIVITY_PHASES.map((phase) => [phase, sessionDerivedPresentation(phase)]), +) as Record; /** * Phases with no session counterpart — the pull-request lifecycle. Amber does @@ -139,14 +124,13 @@ const SESSION_DERIVED_PRESENTATION = Object.fromEntries( * branch policy, CI, or someone else's approval is frequently something the * reader cannot clear at all, so it makes no claim on them. * - * NOTE: `attentionHeaderSummary.ts` still files `blocked` into the red - * "Failing or blocked" bucket. That disagreement with the neutral tone here is - * known and deliberately left for now — reconciling a phase that has no - * producer would be two speculative changes instead of one documented one. + * `activityPriority.ts` files it in the needs-you band on phase priority alone, + * which is the closest thing to a decision anyone can make about a phase with + * no producer. Its tone stays neutral here, so it can never paint amber. */ const NON_SESSION_PRESENTATION: Record< - Exclude, - AttentionPhasePresentation + Exclude, + ActivityPhasePresentation & { tone: SessionStatusTone } > = { blocked: { label: "Blocked", tone: "neutral", active: false }, checks_failing: { label: "Checks failing", tone: "red", active: false }, @@ -158,22 +142,60 @@ const NON_SESSION_PRESENTATION: Record< closed: { label: "Closed", tone: "neutral", active: false }, }; -const PHASE_PRESENTATION: Record = { +const PHASE_PRESENTATION: Record = { ...SESSION_DERIVED_PRESENTATION, ...NON_SESSION_PRESENTATION, }; -export function attentionPhasePresentation(phase: AttentionPhase): AttentionPhasePresentation { +export function activityPhasePresentation(phase: AttentionPhase): ActivityPhasePresentation { return PHASE_PRESENTATION[phase]; } -export function attentionPhaseIsSessionDerived( +const NON_SESSION_STATUS_DETAILS: Record< + Exclude, + Pick +> = { + blocked: { glyph: null, showsElapsed: false, prominent: false }, + checks_failing: { glyph: "failed", showsElapsed: false, prominent: true }, + review_requested: { glyph: null, showsElapsed: false, prominent: true }, + changes_requested: { glyph: "failed", showsElapsed: false, prominent: true }, + merge_ready: { glyph: "done", showsElapsed: false, prominent: true }, + open: { glyph: null, showsElapsed: false, prominent: false }, + merged: { glyph: "done", showsElapsed: false, prominent: true }, + closed: { glyph: null, showsElapsed: false, prominent: false }, +}; + +/** + * Projects every Activity item into the same full status vocabulary used by a + * Work session row. Session phases delegate directly; PR-only phases add only + * the glyph/elapsed/prominence fields that the older phase presentation did + * not need. + */ +export function activityItemPresentation( + item: AttentionItem, +): SessionStatusPresentation | null { + if (activityPhaseIsSessionDerived(item.phase)) { + return requireSessionPresentation(SESSION_PHASE_BY_ATTENTION_PHASE[item.phase]); + } + const presentation = NON_SESSION_PRESENTATION[item.phase]; + const details = NON_SESSION_STATUS_DETAILS[item.phase]; + const glyph: SessionStatusGlyph = details.glyph; + return { + label: presentation.label, + tone: presentation.tone, + glyph, + showsElapsed: details.showsElapsed, + prominent: details.prominent, + }; +} + +export function activityPhaseIsSessionDerived( phase: AttentionPhase, -): phase is SessionDerivedAttentionPhase { +): phase is SessionDerivedActivityPhase { return phase in SESSION_PHASE_BY_ATTENTION_PHASE; } -export function attentionActionTone( +export function activityActionTone( kind: AttentionActionKind, ): "primary" | "danger" | "secondary" | "ghost" { if (kind === "approve" || kind === "answer" || kind === "rerun_checks") return "primary"; @@ -181,28 +203,3 @@ export function attentionActionTone( 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 { - // "Done" rather than "Completed": the pill on these rows says Done, and - // prose that uses a different word for the same state is how a vocabulary - // starts to fray. - title: "No recent outcomes", - body: "Done 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/activity/activityPriority.test.ts b/apps/desktop/src/renderer/components/activity/activityPriority.test.ts new file mode 100644 index 000000000..c78c24e38 --- /dev/null +++ b/apps/desktop/src/renderer/components/activity/activityPriority.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from "vitest"; +import { + ATTENTION_CONTRACT_VERSION, + type AttentionItem, + type AttentionPhase, +} from "../../../shared/types/attention"; +import { + ACTIVITY_SECTION_DESCRIPTORS, + activityBadgeCount, + ACTIVITY_SECTION_TONE, + activityHeadline, + activitySections, + activityTriggerLabel, + summarizeActivity, +} from "./activityPriority"; + +const NOW = Date.parse("2026-08-01T12:00:00.000Z"); + +function activityItem( + id: string, + phase: AttentionPhase, + patch: Partial = {}, +): AttentionItem { + return { + contractVersion: ATTENTION_CONTRACT_VERSION, + id, + revision: 1, + fingerprint: `fingerprint-${id}`, + kind: "agent", + eventKind: "agent_running", + phase, + machine: { machineKey: "studio", name: "Studio Mac", online: true, lastSeenAt: null }, + project: { projectId: "ade", name: "ADE" }, + title: id, + preview: "preview", + privacyPreview: "private preview", + destination: { kind: "session", sessionId: id }, + actions: [], + occurredAt: "2026-08-01T11:00:00.000Z", + updatedAt: "2026-08-01T11:00:00.000Z", + seenAt: null, + dismissedAt: null, + expiresAt: null, + ...patch, + }; +} + +describe("activity priority", () => { + it("always exposes the three reusable descriptors in priority order", () => { + expect(ACTIVITY_SECTION_DESCRIPTORS.map(({ id }) => id)).toEqual([ + "needs-you", + "working", + "done", + ]); + expect(activitySections([], NOW).map(({ id, items }) => [id, items])).toEqual([ + ["needs-you", []], + ["working", []], + ["done", []], + ]); + }); + + it("maps phases into needs-you, working, and done bands", () => { + const sections = activitySections([ + activityItem("done", "completed"), + activityItem("working", "running"), + activityItem("review", "review_requested"), + activityItem("needs", "needs_you"), + activityItem("open", "open"), + activityItem("closed", "closed"), + ], NOW); + + expect(sections.map((section) => [ + section.id, + section.items.map((item) => item.id), + ])).toEqual([ + ["needs-you", ["needs", "review"]], + ["working", ["working", "open"]], + ["done", ["done", "closed"]], + ]); + }); + + it("files explicit idle rows in the done ambient tail", () => { + const sections = activitySections([ + activityItem("idle-running", "running", { activityTier: "idle" }), + activityItem("fresh-done", "completed", { + updatedAt: "2026-08-01T10:00:00.000Z", + }), + activityItem("idle-stale", "stale", { + activityTier: "idle", + updatedAt: "2026-08-01T11:30:00.000Z", + }), + ], NOW); + + expect(sections[1]?.items).toEqual([]); + expect(sections[2]?.items.map((item) => item.id)).toEqual([ + "fresh-done", + "idle-running", + "idle-stale", + ]); + }); + + it("filters dismissed and expired rows before deriving badge and headline", () => { + const items = { + visible: activityItem("visible", "needs_you"), + dismissed: activityItem("dismissed", "failed", { + dismissedAt: "2026-08-01T11:30:00.000Z", + }), + expired: activityItem("expired", "needs_you", { + expiresAt: "2026-08-01T11:59:00.000Z", + }), + }; + + expect(activityBadgeCount(items, NOW)).toBe(1); + expect(activityHeadline(items, NOW)).toBe("1 needs you"); + expect(activityHeadline([activityItem("work", "running")], NOW)).toBe("1 working"); + expect(activityHeadline([activityItem("done", "completed")], NOW)).toBe("1 done"); + expect(activityHeadline([], NOW)).toBe("All clear"); + }); +}); + +describe("activity header summary", () => { + it("derives counts, machine presence, and the trigger label from one pass", () => { + const summary = summarizeActivity( + [ + activityItem("needs", "needs_you"), + activityItem("work", "running"), + activityItem("done", "completed"), + activityItem("offline", "running", { + machine: { + machineKey: "laptop", + name: "MacBook Pro", + online: false, + lastSeenAt: "2026-08-01T10:00:00.000Z", + }, + }), + ], + NOW, + ); + + expect(summary.needsYouCount).toBe(1); + expect(summary.workingCount).toBe(2); + expect(summary.doneCount).toBe(1); + expect(summary.trackedCount).toBe(4); + expect(summary.machinesOnline).toBe(1); + expect(summary.machinesTotal).toBe(2); + expect(summary.staleMachineCount).toBe(1); + expect(summary.tone).toBe("amber"); + expect(activityTriggerLabel(summary)).toBe( + "Activity · 1 needs you · 2 working · 1 done", + ); + }); + + /** + * Amber is the badge's only colour, and it may only mean "your move". Work in + * motion is blue and a finished run is emerald — neither may borrow it. + */ + it("reserves amber for needs-you and falls back through working then done", () => { + expect(summarizeActivity([activityItem("work", "running")], NOW).tone).toBe("blue"); + expect(summarizeActivity([activityItem("done", "completed")], NOW).tone).toBe("emerald"); + expect(summarizeActivity([], NOW).tone).toBe("neutral"); + expect(ACTIVITY_SECTION_TONE["needs-you"]).toBe("amber"); + }); + + it("says all agents are idle rather than enumerating zeroes", () => { + expect(activityTriggerLabel(summarizeActivity([], NOW))).toBe( + "Activity · all agents idle", + ); + }); + + it("counts a dismissed row out of tracked while still knowing its machine", () => { + const summary = summarizeActivity( + [ + activityItem("visible", "needs_you"), + activityItem("dismissed", "failed", { dismissedAt: "2026-08-01T11:30:00.000Z" }), + ], + NOW, + ); + + expect(summary.trackedCount).toBe(1); + expect(summary.needsYouCount).toBe(1); + expect(summary.machinesTotal).toBe(1); + }); +}); diff --git a/apps/desktop/src/renderer/components/activity/activityPriority.ts b/apps/desktop/src/renderer/components/activity/activityPriority.ts new file mode 100644 index 000000000..544cce95f --- /dev/null +++ b/apps/desktop/src/renderer/components/activity/activityPriority.ts @@ -0,0 +1,193 @@ +import { + ATTENTION_PHASE_PRIORITY, + activityItemTier, + sortAttentionItems, + type AttentionItem, +} from "../../../shared/types/attention"; + +export type ActivitySectionId = "needs-you" | "working" | "done"; + +export type ActivitySectionDescriptor = { + id: ActivitySectionId; + label: string; + order: number; +}; + +export const ACTIVITY_SECTION_DESCRIPTORS = [ + { id: "needs-you", label: "Needs you", order: 0 }, + { id: "working", label: "Working", order: 1 }, + { id: "done", label: "Done", order: 2 }, +] as const satisfies readonly ActivitySectionDescriptor[]; + +export type ActivitySection = ActivitySectionDescriptor & { + items: AttentionItem[]; +}; + +type ActivityItemsInput = + | readonly AttentionItem[] + | Readonly>; + +function activityInputItems(input: ActivityItemsInput): readonly AttentionItem[] { + return Array.isArray(input) + ? input + : Object.values(input as Readonly>); +} + +function activityItemIsExpired(item: AttentionItem, now: number): boolean { + if (!item.expiresAt) return false; + const expiresAt = Date.parse(item.expiresAt); + return Number.isFinite(expiresAt) && expiresAt <= now; +} + +export function activitySectionId(item: AttentionItem): ActivitySectionId { + // Disk-only roster rows are quiet history even when their preserved phase + // (for example stale) would otherwise fall inside the working band. + if (activityItemTier(item) === "idle") return "done"; + + const priority = ATTENTION_PHASE_PRIORITY[item.phase]; + if (priority <= ATTENTION_PHASE_PRIORITY.blocked) return "needs-you"; + if (priority <= ATTENTION_PHASE_PRIORITY.stale) return "working"; + return "done"; +} + +/** + * Priority-flat Activity projection. Every call returns the same three ordered + * descriptors, including empty sections, so popover, pane, and notch views can + * share headings without re-declaring their order. + */ +export function activitySections( + input: ActivityItemsInput, + now = Date.now(), +): ActivitySection[] { + const grouped: Record = { + "needs-you": [], + working: [], + done: [], + }; + + for (const item of activityInputItems(input)) { + if (item.dismissedAt || activityItemIsExpired(item, now)) continue; + grouped[activitySectionId(item)].push(item); + } + + return ACTIVITY_SECTION_DESCRIPTORS.map((descriptor) => { + const sorted = sortAttentionItems(grouped[descriptor.id]); + if (descriptor.id !== "done") return { ...descriptor, items: sorted }; + + // Idle roster history is the ambient tail even when its preserved phase + // has a numerically higher priority than a fresh completed outcome. + const live = sorted.filter((item) => activityItemTier(item) !== "idle"); + const idle = sorted.filter((item) => activityItemTier(item) === "idle"); + return { ...descriptor, items: [...live, ...idle] }; + }); +} + +/** The Activity badge is intentionally only the first, needs-you section. */ +export function activityBadgeCount(input: ActivityItemsInput, now = Date.now()): number { + return activitySections(input, now)[0]?.items.length ?? 0; +} + +export function activityHeadline(input: ActivityItemsInput, now = Date.now()): string { + const sections = activitySections(input, now); + const needsYou = sections[0]?.items.length ?? 0; + if (needsYou > 0) return `${needsYou} need${needsYou === 1 ? "s" : ""} you`; + const working = sections[1]?.items.length ?? 0; + if (working > 0) return `${working} working`; + const done = sections[2]?.items.length ?? 0; + if (done > 0) return `${done} done`; + return "All clear"; +} + +/** + * The one hue per section, and the reason the badge can only ever be amber: + * amber means "your move" and nothing else, blue means work is happening, + * emerald means it finished cleanly. Same table as + * `shared/sessionStatusPresentation.ts` — see the one-hue-one-meaning rule there. + */ +export const ACTIVITY_SECTION_TONE = { + "needs-you": "amber", + working: "blue", + done: "emerald", +} as const satisfies Record; + +export type ActivitySummary = { + /** All three sections, always, in priority order. */ + sections: ActivitySection[]; + needsYouCount: number; + workingCount: number; + doneCount: number; + /** Every non-expired, non-dismissed item — what "Open all" leads to. */ + trackedCount: number; + /** Filed items whose machine is offline, i.e. last-known state only. */ + staleMachineCount: number; + machinesOnline: number; + machinesTotal: number; + tone: "amber" | "blue" | "emerald" | "neutral"; + headline: string; +}; + +/** + * Everything the Activity header claims, derived once so the trigger, its + * accessible label, the sections, and the footer can never disagree. + */ +export function summarizeActivity( + input: ActivityItemsInput, + now = Date.now(), +): ActivitySummary { + const sections = activitySections(input, now); + const machinesOnline = new Set(); + const machinesTotal = new Set(); + let trackedCount = 0; + + for (const item of activityInputItems(input)) { + if (activityItemIsExpired(item, now)) continue; + machinesTotal.add(item.machine.machineKey); + if (item.machine.online) machinesOnline.add(item.machine.machineKey); + if (!item.dismissedAt) trackedCount += 1; + } + + const needsYouCount = sections[0]?.items.length ?? 0; + const workingCount = sections[1]?.items.length ?? 0; + const doneCount = sections[2]?.items.length ?? 0; + // "Working" rows on an offline machine are the normal shape of a machine that + // went away mid-turn, so they count too: the whole point of the note is that + // the state on screen is remembered rather than observed. + const staleMachineCount = sections.reduce( + (total, section) => + total + section.items.filter((item) => !item.machine.online).length, + 0, + ); + + const tone = needsYouCount > 0 + ? "amber" + : workingCount > 0 + ? "blue" + : doneCount > 0 + ? "emerald" + : "neutral"; + + return { + sections, + needsYouCount, + workingCount, + doneCount, + trackedCount, + staleMachineCount, + machinesOnline: machinesOnline.size, + machinesTotal: machinesTotal.size, + tone, + headline: activityHeadline(input, now), + }; +} + +/** Tooltip and accessible name for the Activity header trigger. */ +export function activityTriggerLabel(summary: ActivitySummary): string { + const parts: string[] = []; + if (summary.needsYouCount > 0) { + parts.push(`${summary.needsYouCount} need${summary.needsYouCount === 1 ? "s" : ""} you`); + } + if (summary.workingCount > 0) parts.push(`${summary.workingCount} working`); + if (summary.doneCount > 0) parts.push(`${summary.doneCount} done`); + if (parts.length === 0) return "Activity · all agents idle"; + return `Activity · ${parts.join(" · ")}`; +} diff --git a/apps/desktop/src/renderer/components/attention/useAttentionSync.test.tsx b/apps/desktop/src/renderer/components/activity/useActivitySync.test.tsx similarity index 51% rename from apps/desktop/src/renderer/components/attention/useAttentionSync.test.tsx rename to apps/desktop/src/renderer/components/activity/useActivitySync.test.tsx index b730783b6..71d9d5582 100644 --- a/apps/desktop/src/renderer/components/attention/useAttentionSync.test.tsx +++ b/apps/desktop/src/renderer/components/activity/useActivitySync.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom // React hook integration coverage is kept under a distinct basename so TypeScript -// includes it alongside the transport-only useAttentionSync tests. +// includes it alongside the transport-only useActivitySync tests. import React from "react"; import { act, cleanup, render, waitFor } from "@testing-library/react"; @@ -13,18 +13,24 @@ import { type AttentionNotchSettings, type AttentionSnapshot, } from "../../../shared/types"; +import { parseAttentionNotchSnapshot } from "../../../main/services/attention/attentionNotchRouter"; import { - attentionStore, - resetAttentionStoreForTests, -} from "../../state/attentionStore"; + activityStore, + resetActivityStoreForTests, +} from "../../state/activityStore"; import { publishAccountStatus, SIGNED_OUT_ACCOUNT } from "../../lib/account"; import { - attentionNotchSettingsFromPreferences, - attentionNotchSnapshotSignature, - materializeAttentionNotchSnapshot, - refreshAttentionSnapshot, - useAttentionSync, -} from "./useAttentionSync"; + activityNotchSettingsFromPreferences, + activityNotchSnapshotSignature, + activityToastForTransition, + materializeActivityNotchSnapshot, + refreshActivitySnapshot, + useActivitySync, + MAX_NOTCH_PROJECTION_ITEMS, + MAX_NOTCH_SNAPSHOT_BYTES, + TOAST_ITEM_COOLDOWN_MS, + TOAST_MIN_INTERVAL_MS, +} from "./useActivitySync"; const originalAde = window.ade; const originalVisibilityState = Object.getOwnPropertyDescriptor( @@ -52,7 +58,7 @@ const runningItem: AttentionItem = { rootPath: "/projects/ADE", }, title: "Running", - preview: "Implementing Attention", + preview: "Implementing Activity", privacyPreview: "Agent is working", destination: { kind: "session", @@ -96,8 +102,41 @@ function liveItem(): AttentionItem { }; } +function readySnapshot( + items: AttentionItem[], + revision = 1, +): AttentionSnapshot { + return { + contractVersion: ATTENTION_CONTRACT_VERSION, + scope: "account", + availability: { + state: "ready", + title: "Account Activity", + message: "Live across your ADE account.", + recovery: null, + }, + streamId: "account:test", + revision, + generatedAt: `2026-07-28T14:00:0${revision}.000Z`, + items, + tombstones: [], + }; +} + +function signedInStatus(userId: string) { + return { + signedIn: true as const, + userId, + email: null, + name: null, + expiresAt: null, + provider: null, + imageUrl: null, + }; +} + function Harness({ surfaceVisible = true }: { surfaceVisible?: boolean }) { - useAttentionSync(surfaceVisible); + useActivitySync(surfaceVisible); return null; } @@ -113,7 +152,7 @@ afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.useRealTimers(); - resetAttentionStoreForTests(); + resetActivityStoreForTests(); publishAccountStatus(SIGNED_OUT_ACCOUNT); Object.defineProperty(window, "ade", { configurable: true, @@ -127,7 +166,7 @@ afterEach(() => { } }); -describe("useAttentionSync", () => { +describe("useActivitySync", () => { it("applies privacy settings before publishing the first native snapshot", async () => { publishAccountStatus({ signedIn: true, @@ -198,7 +237,7 @@ describe("useAttentionSync", () => { availability: { state: "signed_out", title: "This machine only", - message: "Sign in to combine Attention across every ADE machine.", + message: "Sign in to combine Activity across every ADE machine.", recovery: "sign_in", }, streamId: "machine:studio", @@ -288,6 +327,8 @@ describe("useAttentionSync", () => { enabled: false, revealMode: "click", expandedPanelEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: false, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: true, @@ -319,7 +360,7 @@ describe("useAttentionSync", () => { scope: "account", availability: { state: "ready", - title: "Account Attention", + title: "Account Activity", message: "Live across your ADE account.", recovery: null, }, @@ -352,12 +393,12 @@ describe("useAttentionSync", () => { render(); - await waitFor(() => expect(attentionStore.getState().itemsById["live-account-item"]).toBeTruthy()); + await waitFor(() => expect(activityStore.getState().itemsById["live-account-item"]).toBeTruthy()); shouldFail = true; await act(async () => { - await refreshAttentionSnapshot(); + await refreshActivitySnapshot(); }); - await waitFor(() => expect(attentionStore.getState().availability).toMatchObject({ + await waitFor(() => expect(activityStore.getState().availability).toMatchObject({ state: "degraded", recovery: "retry", })); @@ -368,7 +409,7 @@ describe("useAttentionSync", () => { items: [expect.objectContaining({ id: "live-account-item" })], }), )); - expect(attentionStore.getState().syncStatus).toBe("error"); + expect(activityStore.getState().syncStatus).toBe("error"); }); it("times out a wedged snapshot as retryable degradation and allows a later refresh", async () => { @@ -398,25 +439,25 @@ describe("useAttentionSync", () => { }, }); - const timedOutRefresh = refreshAttentionSnapshot(); - expect(attentionStore.getState().syncStatus).toBe("syncing"); + const timedOutRefresh = refreshActivitySnapshot(); + expect(activityStore.getState().syncStatus).toBe("syncing"); await vi.advanceTimersByTimeAsync(75_000); await timedOutRefresh; - expect(attentionStore.getState()).toMatchObject({ + expect(activityStore.getState()).toMatchObject({ syncStatus: "error", - syncError: "Attention took too long to respond. Retry to restore live updates.", + syncError: "Activity took too long to respond. Retry to restore live updates.", availability: { state: "degraded", recovery: "retry", }, }); - await refreshAttentionSnapshot(); + await refreshActivitySnapshot(); expect(getSnapshot).toHaveBeenCalledTimes(2); - expect(attentionStore.getState()).toMatchObject({ + expect(activityStore.getState()).toMatchObject({ syncStatus: "ready", syncError: null, revision: 1, @@ -632,7 +673,7 @@ describe("useAttentionSync", () => { it("requests incremental snapshots from the latest account cursor", async () => { const current = liveItem(); - attentionStore.setState({ + activityStore.setState({ revision: 9, itemsById: { [current.id]: current }, }); @@ -658,11 +699,11 @@ describe("useAttentionSync", () => { }, }); - await refreshAttentionSnapshot(); + await refreshActivitySnapshot(); expect(getSnapshot).toHaveBeenCalledWith(9, null); - expect(attentionStore.getState().itemsById[current.id]).toBe(current); - expect(attentionStore.getState().revision).toBe(10); + expect(activityStore.getState().itemsById[current.id]).toBe(current); + expect(activityStore.getState().revision).toBe(10); }); it("hydrates the account snapshot and counts a running native notch as visible presence", async () => { @@ -747,7 +788,7 @@ describe("useAttentionSync", () => { await waitFor(() => { expect(getSnapshot).toHaveBeenCalledWith(0, null); - expect(attentionStore.getState().itemsById["live-account-item"]).toBeTruthy(); + expect(activityStore.getState().itemsById["live-account-item"]).toBeTruthy(); }); await waitFor(() => { expect(reportPresence).toHaveBeenCalled(); @@ -838,36 +879,423 @@ describe("useAttentionSync", () => { expect(getSnapshot).toHaveBeenCalledTimes(hiddenRefreshBaseline + 1); }); + + it("uses account automatic-reveal settings for both helper settings and toasts", async () => { + window.localStorage.clear(); + window.localStorage.setItem("ade:attention:notch-auto-reveal", "true"); + const accountStatus = signedInStatus("user-account-reveal"); + publishAccountStatus(accountStatus); + const initial = { ...liveItem(), activityTier: "signal" as const }; + const preferences = { + ...DEFAULT_ATTENTION_PREFERENCES, + account: { + ...DEFAULT_ATTENTION_PREFERENCES.account, + hideDetails: false, + notchAutomaticReveal: false, + }, + }; + const updateSettings = vi.fn(async () => undefined); + const publishToast = vi.fn(async () => undefined); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + attention: { + getSnapshot: vi.fn(async () => readySnapshot([initial])), + acknowledge: vi.fn(), + reportPresence: vi.fn(), + getPreferences: vi.fn(async () => preferences), + putPreferences: vi.fn(), + }, + attentionNotch: { + publishSnapshot: vi.fn(async () => undefined), + publishToast, + updateSettings, + onAcknowledgeRequested: vi.fn(() => () => {}), + }, + account: { + ...(originalAde?.account ?? {}), + status: vi.fn(async () => accountStatus), + }, + }, + }); + + render(); + await waitFor(() => expect( + activityStore.getState().preferences?.account.notchAutomaticReveal, + ).toBe(false)); + await waitFor(() => expect(updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ automaticRevealEnabled: false }), + )); + await waitFor(() => expect(activityStore.getState().itemsById[initial.id]).toBeTruthy()); + + act(() => { + activityStore.getState().applySnapshot(readySnapshot([{ + ...initial, + revision: initial.revision + 1, + fingerprint: "account-fingerprint:needs-you", + eventKind: "agent_needs_you", + phase: "needs_you", + }], 2)); + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(publishToast).not.toHaveBeenCalled(); + }); + + it("clamps toast copy and rolls back cooldown after a failed publish", async () => { + window.localStorage.clear(); + window.localStorage.setItem("ade:attention:notch-auto-reveal", "true"); + const accountStatus = signedInStatus("user-toast-publish"); + publishAccountStatus(accountStatus); + const items = ["first", "second", "third"].map((suffix, index) => ({ + ...liveItem(), + id: `toast-${suffix}`, + revision: index + 1, + fingerprint: `toast-${suffix}:running`, + activityTier: "signal" as const, + destination: { kind: "session" as const, sessionId: `session-${suffix}` }, + })); + const publishToast = vi.fn() + .mockRejectedValueOnce(new Error("native helper unavailable")) + .mockResolvedValue(undefined); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + attention: { + getSnapshot: vi.fn(async () => readySnapshot(items)), + acknowledge: vi.fn(), + reportPresence: vi.fn(), + getPreferences: vi.fn(async () => ({ + ...DEFAULT_ATTENTION_PREFERENCES, + account: { + ...DEFAULT_ATTENTION_PREFERENCES.account, + hideDetails: false, + notchAutomaticReveal: true, + }, + })), + putPreferences: vi.fn(), + }, + attentionNotch: { + publishSnapshot: vi.fn(async () => undefined), + publishToast, + updateSettings: vi.fn(async () => undefined), + onAcknowledgeRequested: vi.fn(() => () => {}), + }, + account: { + ...(originalAde?.account ?? {}), + status: vi.fn(async () => accountStatus), + }, + }, + }); + + render(); + await waitFor(() => expect(activityStore.getState().itemsById[items[0]!.id]).toBeTruthy()); + await waitFor(() => expect(activityStore.getState().preferences?.account.hideDetails) + .toBe(false)); + + const transition = (index: number, revision: number) => { + const next = items.map((item, itemIndex) => itemIndex === index + ? { + ...item, + revision: item.revision + 10, + fingerprint: `${item.id}:needs-you`, + eventKind: "agent_needs_you" as const, + phase: "needs_you" as const, + title: index === 0 ? "T".repeat(400) : item.title, + preview: index === 0 ? "S".repeat(700) : item.preview, + } + : item); + activityStore.getState().applySnapshot(readySnapshot(next, revision)); + items.splice(0, items.length, ...next); + }; + + act(() => transition(0, 2)); + await waitFor(() => expect(publishToast).toHaveBeenCalledTimes(1)); + expect(publishToast.mock.calls[0]?.[0]).toMatchObject({ + itemId: "toast-first", + title: "T".repeat(256), + }); + expect(publishToast.mock.calls[0]?.[0]?.subtitle).toHaveLength(512); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + act(() => transition(1, 3)); + await waitFor(() => expect(publishToast).toHaveBeenCalledTimes(2)); + await act(async () => { + await Promise.resolve(); + }); + + act(() => transition(2, 4)); + await act(async () => { + await Promise.resolve(); + }); + expect(publishToast).toHaveBeenCalledTimes(2); + }); + + it("optimistically rate-limits distinct signal items in one native round trip", async () => { + window.localStorage.clear(); + window.localStorage.setItem("ade:attention:notch-auto-reveal", "true"); + const items = ["first", "second"].map((suffix, index) => ({ + ...liveItem(), + id: `burst-${suffix}`, + revision: index + 1, + fingerprint: `burst-${suffix}:running`, + activityTier: "signal" as const, + destination: { kind: "session" as const, sessionId: `session-${suffix}` }, + })); + let resolveToast: () => void = () => {}; + const publishToast = vi.fn(() => new Promise((resolve) => { + resolveToast = resolve; + })); + const publishSnapshot = vi.fn(async () => undefined); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + attention: { + getSnapshot: vi.fn(async () => readySnapshot(items)), + acknowledge: vi.fn(), + reportPresence: vi.fn(), + getPreferences: vi.fn(), + putPreferences: vi.fn(), + }, + attentionNotch: { + publishSnapshot, + publishToast, + updateSettings: vi.fn(async () => undefined), + onAcknowledgeRequested: vi.fn(() => () => {}), + }, + }, + }); + + render(); + await waitFor(() => expect(publishSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ id: "burst-first" }), + expect.objectContaining({ id: "burst-second" }), + ]), + }), + )); + + const firstTransition = [ + { + ...items[0]!, + revision: 10, + fingerprint: "burst-first:needs-you", + eventKind: "agent_needs_you" as const, + phase: "needs_you" as const, + }, + items[1]!, + ]; + const secondTransition = [ + firstTransition[0]!, + { + ...items[1]!, + revision: 11, + fingerprint: "burst-second:needs-you", + eventKind: "agent_needs_you" as const, + phase: "needs_you" as const, + }, + ]; + act(() => { + activityStore.getState().applySnapshot(readySnapshot(firstTransition, 2)); + activityStore.getState().applySnapshot(readySnapshot(secondTransition, 3)); + }); + await waitFor(() => expect(publishToast).toHaveBeenCalledTimes(1)); + expect(publishToast).toHaveBeenCalledWith(expect.objectContaining({ + itemId: "burst-first", + })); + + resolveToast(); + await act(async () => { + await Promise.resolve(); + }); + }); + + it("does not toast a transition before the notch is prepared", async () => { + window.localStorage.clear(); + window.localStorage.setItem("ade:attention:notch-auto-reveal", "true"); + const initial = { ...liveItem(), activityTier: "signal" as const }; + let resolveSettings: () => void = () => {}; + const updateSettings = vi.fn(() => new Promise((resolve) => { + resolveSettings = resolve; + })); + const publishSnapshot = vi.fn(async () => undefined); + const publishToast = vi.fn(async () => undefined); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + attention: { + getSnapshot: vi.fn(async () => readySnapshot([initial])), + acknowledge: vi.fn(), + reportPresence: vi.fn(), + getPreferences: vi.fn(), + putPreferences: vi.fn(), + }, + attentionNotch: { + publishSnapshot, + publishToast, + updateSettings, + onAcknowledgeRequested: vi.fn(() => () => {}), + }, + }, + }); + + render(); + await waitFor(() => expect(activityStore.getState().itemsById[initial.id]).toBeTruthy()); + await waitFor(() => expect(updateSettings).toHaveBeenCalled()); + act(() => { + activityStore.getState().applySnapshot(readySnapshot([{ + ...initial, + revision: initial.revision + 1, + fingerprint: "account-fingerprint:needs-you", + eventKind: "agent_needs_you", + phase: "needs_you", + }], 2)); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(publishToast).not.toHaveBeenCalled(); + + resolveSettings(); + await waitFor(() => expect(publishSnapshot).toHaveBeenCalled()); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(publishToast).not.toHaveBeenCalled(); + }); + + it("retries lazy notch preparation on the next store change", async () => { + const publishSnapshot = vi.fn() + .mockRejectedValueOnce(new Error("first helper write failed")) + .mockResolvedValue(undefined); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + attention: { + getSnapshot: vi.fn(() => new Promise(() => {})), + acknowledge: vi.fn(), + reportPresence: vi.fn(), + getPreferences: vi.fn(), + putPreferences: vi.fn(), + }, + attentionNotch: { + publishSnapshot, + updateSettings: vi.fn(async () => undefined), + onAcknowledgeRequested: vi.fn(() => () => {}), + }, + }, + }); + + render(); + await waitFor(() => expect(publishSnapshot).toHaveBeenCalledTimes(1)); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + act(() => { + activityStore.setState({ + revision: 1, + generatedAt: "2026-07-28T14:00:01.000Z", + itemsById: { [runningItem.id]: runningItem }, + }); + }); + + await waitFor(() => expect(publishSnapshot).toHaveBeenCalledTimes(2)); + expect(publishSnapshot.mock.calls[1]?.[0]).toMatchObject({ + items: [expect.objectContaining({ id: runningItem.id })], + }); + }); }); -describe("Attention Notch renderer bridge", () => { - beforeEach(() => resetAttentionStoreForTests()); +describe("Activity renderer-to-notch bridge", () => { + beforeEach(() => resetActivityStoreForTests()); it("materializes the merged renderer state rather than forwarding a delta", () => { - attentionStore.setState({ + activityStore.setState({ revision: 8, generatedAt: "2026-07-28T12:00:02.000Z", itemsById: { [runningItem.id]: runningItem }, }); - expect(materializeAttentionNotchSnapshot()).toEqual({ + expect(materializeActivityNotchSnapshot()).toEqual({ contractVersion: 1, scope: "machine", availability: { state: "signed_out", title: "This machine only", - message: "Sign in to combine Attention across every ADE machine.", + message: "Sign in to combine Activity across every ADE machine.", recovery: "sign_in", }, streamId: null, revision: 8, generatedAt: "2026-07-28T12:00:02.000Z", - items: [runningItem], + // `recentActivity` is dropped from the projection; `runningItem` has none. + items: [{ ...runningItem, detail: null }], + itemsTruncated: false, + counts: { + needsYou: 0, + working: 1, + done: 0, + total: 1, + machinesOnline: 1, + machinesTotal: 1, + }, tombstones: [], }); }); + it("skips UTF-8 byte measurement for an ordinary small snapshot", () => { + const OriginalTextEncoder = globalThis.TextEncoder; + let encoderConstructions = 0; + class CountingTextEncoder extends OriginalTextEncoder { + constructor() { + super(); + encoderConstructions += 1; + } + } + Object.defineProperty(globalThis, "TextEncoder", { + configurable: true, + writable: true, + value: CountingTextEncoder, + }); + try { + activityStore.setState({ + revision: 8, + generatedAt: "2026-07-28T12:00:02.000Z", + itemsById: { [runningItem.id]: runningItem }, + }); + + materializeActivityNotchSnapshot(); + + expect(encoderConstructions).toBe(0); + } finally { + Object.defineProperty(globalThis, "TextEncoder", { + configurable: true, + writable: true, + value: OriginalTextEncoder, + }); + } + }); + it("maps account privacy, celebration, sound, and local presentation settings", () => { - expect(attentionNotchSettingsFromPreferences({ + expect(activityNotchSettingsFromPreferences({ ...DEFAULT_ATTENTION_PREFERENCES, account: { ...DEFAULT_ATTENTION_PREFERENCES.account, @@ -878,10 +1306,14 @@ describe("Attention Notch renderer bridge", () => { }, true, { revealMode: "click", expandedPanelEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: true, })).toEqual({ enabled: true, revealMode: "click", expandedPanelEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: true, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: false, @@ -895,7 +1327,7 @@ describe("Attention Notch renderer bridge", () => { scope: "account", availability: { state: "ready", - title: "Account Attention", + title: "Account Activity", message: "Live across your ADE account.", recovery: null, }, @@ -923,17 +1355,289 @@ describe("Attention Notch renderer bridge", () => { ...base, availability: { state: "degraded" as const, - title: "Account Attention is reconnecting", + title: "Account Activity is reconnecting", message: "Last-known work remains available.", recovery: "retry" as const, }, }; - expect(attentionNotchSnapshotSignature(routingChanged)) - .not.toBe(attentionNotchSnapshotSignature(base)); - expect(attentionNotchSnapshotSignature(availabilityChanged)) - .not.toBe(attentionNotchSnapshotSignature(base)); + expect(activityNotchSnapshotSignature(routingChanged)) + .not.toBe(activityNotchSnapshotSignature(base)); + expect(activityNotchSnapshotSignature(availabilityChanged)) + .not.toBe(activityNotchSnapshotSignature(base)); expect(routingChanged.items[0]?.machine.accountMachineKey) .toBe("canonical-machine-1"); }); + + it("publishes a bounded, priority-ordered projection with full-set counts", () => { + const itemsById: Record = {}; + // 60 ambient rows plus 5 that need you: more than the projection carries, + // so the ordering and the counts both have to be doing real work. + for (let index = 0; index < 60; index += 1) { + const id = `working-${String(index).padStart(3, "0")}`; + itemsById[id] = { + ...runningItem, + id, + fingerprint: `${id}:1`, + preview: `x`.repeat(400), + recentActivity: ["Read package.json", "Ran tests"], + }; + } + for (let index = 0; index < 5; index += 1) { + const id = `needs-${index}`; + itemsById[id] = { + ...runningItem, + id, + fingerprint: `${id}:1`, + eventKind: "agent_needs_you", + phase: "needs_you", + activityTier: "signal", + }; + } + activityStore.setState({ + revision: 9, + generatedAt: "2026-07-28T12:00:02.000Z", + itemsById, + }); + + const snapshot = materializeActivityNotchSnapshot(); + expect(snapshot.items).toHaveLength(MAX_NOTCH_PROJECTION_ITEMS); + expect(snapshot.itemsTruncated).toBe(true); + // Needs-you first, always: the slice is the top of Activity's own order. + expect(snapshot.items.slice(0, 5).map((entry) => entry.id).sort()).toEqual([ + "needs-0", + "needs-1", + "needs-2", + "needs-3", + "needs-4", + ]); + for (const entry of snapshot.items) { + expect(entry.preview.length).toBeLessThanOrEqual(160); + expect(entry).not.toHaveProperty("recentActivity"); + } + // The store's own objects must be untouched by the projection. + expect(activityStore.getState().itemsById["working-000"]?.preview).toHaveLength(400); + expect(activityStore.getState().itemsById["working-000"]?.recentActivity) + .toHaveLength(2); + // Counts describe the whole account, not the 48 rows that travelled. + expect(snapshot.counts).toEqual({ + needsYou: 5, + working: 60, + done: 0, + total: 65, + machinesOnline: 1, + machinesTotal: 1, + }); + }); + + it("drops detail and tail rows until an oversized projection clears the byte budget", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const itemsById: Record = {}; + for (let index = 0; index < MAX_NOTCH_PROJECTION_ITEMS; index += 1) { + const needsYou = index < 4; + const id = `${needsYou ? "needs" : "working"}-${String(index).padStart(2, "0")}`; + itemsById[id] = { + ...runningItem, + id, + revision: index + 1, + fingerprint: `${id}:1`, + eventKind: needsYou ? "agent_needs_you" : "agent_running", + phase: needsYou ? "needs_you" : "running", + activityTier: needsYou ? "signal" : "ambient", + title: "t".repeat(1_024), + privacyPreview: "p".repeat(1_024), + detail: "d".repeat(8_192), + model: "m".repeat(512), + laneName: "l".repeat(512), + project: { + ...runningItem.project, + rootPath: `/${"r".repeat(4_095)}`, + }, + }; + } + activityStore.setState({ itemsById }); + + const snapshot = materializeActivityNotchSnapshot(); + + expect(snapshot.items.length).toBeLessThan(MAX_NOTCH_PROJECTION_ITEMS); + expect(snapshot.itemsTruncated).toBe(true); + expect(snapshot.items.slice(0, 4).every((item) => item.phase === "needs_you")) + .toBe(true); + expect(snapshot.items.filter((item) => item.phase === "needs_you").map((item) => item.id).sort()) + .toEqual([ + "needs-00", + "needs-01", + "needs-02", + "needs-03", + ]); + expect(snapshot.items.every((item) => item.detail === null)).toBe(true); + expect(new TextEncoder().encode(JSON.stringify(snapshot)).byteLength) + .toBeLessThanOrEqual(MAX_NOTCH_SNAPSHOT_BYTES); + expect(parseAttentionNotchSnapshot(snapshot)).not.toBeNull(); + expect(warn).toHaveBeenCalledWith(expect.stringMatching( + /^\[useActivitySync\] activity\.notch_snapshot_truncated \{"reason":"byte_budget"/, + )); + }); + + it("measures potentially oversized non-ASCII snapshots before publishing", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const itemsById: Record = {}; + for (let index = 0; index < MAX_NOTCH_PROJECTION_ITEMS; index += 1) { + const id = `unicode-${String(index).padStart(2, "0")}`; + itemsById[id] = { + ...runningItem, + id, + revision: index + 1, + fingerprint: `${id}:1`, + title: "界".repeat(1_000), + }; + } + activityStore.setState({ itemsById }); + + const snapshot = materializeActivityNotchSnapshot(); + + expect(snapshot.items.length).toBeLessThan(MAX_NOTCH_PROJECTION_ITEMS); + expect(snapshot.itemsTruncated).toBe(true); + expect(new TextEncoder().encode(JSON.stringify(snapshot)).byteLength) + .toBeLessThanOrEqual(MAX_NOTCH_SNAPSHOT_BYTES); + expect(warn).toHaveBeenCalledWith(expect.stringContaining( + "[useActivitySync] activity.notch_snapshot_truncated", + )); + }); + + it("republishes when only the counts changed", () => { + const base = materializeActivityNotchSnapshot(); + expect(activityNotchSnapshotSignature({ + ...base, + counts: { + needsYou: 1, + working: 0, + done: 0, + total: 1, + machinesOnline: 1, + machinesTotal: 1, + }, + })).not.toBe(activityNotchSnapshotSignature({ ...base, counts: undefined })); + }); +}); + +describe("Activity notch toast decisions", () => { + const signalItem: AttentionItem = { + ...runningItem, + id: "agent-needs-you", + eventKind: "agent_needs_you", + phase: "needs_you", + activityTier: "signal", + title: "Approve the command", + preview: "rm -rf ./build", + privacyPreview: "Agent needs your attention", + }; + + const decide = ( + overrides: Partial[0]> = {}, + ) => activityToastForTransition({ + items: [signalItem], + previousPhases: new Map([[signalItem.id, "running"]]), + lastToastAtByItem: new Map(), + lastToastAt: 0, + availabilityState: "ready", + automaticRevealEnabled: true, + hideDetails: false, + now: 1_000_000, + ...overrides, + }); + + it("fires once on a phase transition and never on first sighting", () => { + expect(decide()).toMatchObject({ + itemId: "agent-needs-you", + eventKind: "agent_needs_you", + treatment: "alert", + title: "Approve the command", + subtitle: "rm -rf ./build", + }); + expect(decide({ previousPhases: new Map() })).toBeNull(); + // Same phase twice is not a transition. + expect(decide({ + previousPhases: new Map([[signalItem.id, "needs_you"]]), + })).toBeNull(); + }); + + it("holds an item quiet for ten minutes after its own toast", () => { + const lastToastAtByItem = new Map([[signalItem.id, 1_000_000 - 60_000]]); + expect(decide({ lastToastAtByItem })).toBeNull(); + expect(decide({ + lastToastAtByItem, + now: 1_000_000 - 60_000 + TOAST_ITEM_COOLDOWN_MS, + })).not.toBeNull(); + }); + + it("rate-limits the account to one toast per five seconds", () => { + expect(decide({ lastToastAt: 1_000_000 - (TOAST_MIN_INTERVAL_MS - 1) })).toBeNull(); + expect(decide({ lastToastAt: 1_000_000 - TOAST_MIN_INTERVAL_MS })).not.toBeNull(); + }); + + it("emits only the highest-priority transition in a burst, and drops the rest", () => { + const failed: AttentionItem = { + ...signalItem, + id: "agent-failed", + eventKind: "agent_failed", + phase: "failed", + activityTier: "signal", + title: "Build failed", + }; + const toast = decide({ + items: [failed, signalItem], + previousPhases: new Map([ + [failed.id, "running"], + [signalItem.id, "running"], + ]), + }); + // needs_you outranks failed in ATTENTION_PHASE_PRIORITY. + expect(toast?.itemId).toBe(signalItem.id); + }); + + it("stays silent when suppressed", () => { + expect(decide({ automaticRevealEnabled: false })).toBeNull(); + expect(decide({ availabilityState: "degraded" })).toBeNull(); + expect(decide({ availabilityState: null })).toBeNull(); + // Ambient rows never interrupt, however much they change. + expect(decide({ + items: [{ ...signalItem, activityTier: "ambient" }], + })).toBeNull(); + expect(decide({ + items: [{ ...signalItem, seenAt: "2026-07-28T12:00:00.000Z" }], + })).toBeNull(); + expect(decide({ + items: [{ ...signalItem, dismissedAt: "2026-07-28T12:00:00.000Z" }], + })).toBeNull(); + }); + + it("uses privacy copy when hide-details is on", () => { + expect(decide({ hideDetails: true })).toMatchObject({ + title: "Agent update", + subtitle: "Agent needs your attention", + }); + expect(decide({ + hideDetails: true, + items: [{ ...signalItem, kind: "pull_request", destination: { + kind: "pull_request", + number: 42, + tab: "overview", + } }], + })).toMatchObject({ title: "Pull request update" }); + }); + + it("maps a merge to the celebration treatment", () => { + expect(decide({ + items: [{ + ...signalItem, + kind: "pull_request", + eventKind: "pr_merged", + phase: "merged", + activityTier: "signal", + destination: { kind: "pull_request", number: 42, tab: "overview" }, + }], + previousPhases: new Map([[signalItem.id, "merge_ready"]]), + })).toMatchObject({ treatment: "celebration" }); + }); }); diff --git a/apps/desktop/src/renderer/components/activity/useActivitySync.ts b/apps/desktop/src/renderer/components/activity/useActivitySync.ts new file mode 100644 index 000000000..3790caa2f --- /dev/null +++ b/apps/desktop/src/renderer/components/activity/useActivitySync.ts @@ -0,0 +1,855 @@ +import { useEffect, useMemo, useRef } from "react"; + +import { + ATTENTION_CONTRACT_VERSION, + activityItemTier, + attentionPhasePriority, + sanitizeAttentionPreview, + type AttentionCounts, + type AttentionEventKind, + type AttentionItem, + type AttentionNotchSettings, + type AttentionNotchToast, + type AttentionNotchToastTreatment, + type AttentionPhase, + type AttentionPresence, + type AttentionSnapshot, +} from "../../../shared/types"; +import { + acknowledgeActivityItem, + activityStore, + useActivityStore, +} from "../../state/activityStore"; +import { useAccountStatus } from "../../lib/account"; +import { activitySections, summarizeActivity } from "./activityPriority"; +import { + activityNotchSupported, + activityNotchSettingsFromPreferences, + persistActivityNotchSettings, + readActivityNotchEnabled, + readActivityNotchPresentation, + resolveActivityNotchPresentation, +} from "./activityNotchLocalSettings"; + +export { activityNotchSettingsFromPreferences } from "./activityNotchLocalSettings"; + +const POLL_INTERVAL_MS = 15_000; +const PRESENCE_INTERVAL_MS = 30_000; +const HIDDEN_PRESENCE_INTERVAL_MS = 120_000; +// This backstop must clear a 15s relay request, one 401 retry, and the 30s +// local-runtime fallback so legitimate host failures retain their real error. +const ACTIVITY_SNAPSHOT_TIMEOUT_MS = 75_000; +const NOTCH_SETTINGS_REFRESH_MS = 60_000; +const MAX_VISIBLE_PRESENCE_ITEMS = 64; +/** + * The notch receives a projection, not the store. The pipe has a byte budget + * the router enforces at 192KB, and a 400-row account blows through it — so we + * ship the top-priority slice and let `counts` carry the honest totals. + */ +export const MAX_NOTCH_PROJECTION_ITEMS = 48; +export const MAX_NOTCH_SNAPSHOT_BYTES = 160 * 1024; +const MAX_NOTCH_PREVIEW_LENGTH = 160; +const MAX_TOAST_TITLE_LENGTH = 256; +const MAX_TOAST_SUBTITLE_LENGTH = 512; +/** One toast per item per 10 minutes, however many times it flaps. */ +export const TOAST_ITEM_COOLDOWN_MS = 600_000; +/** And at most one toast every 5s across the whole account. */ +export const TOAST_MIN_INTERVAL_MS = 5_000; + +type ActivityAccountScope = { + generation: number; + ownerId: string | null; +}; + +let activityAccountGeneration = 0; +let activityAccountOwnerId: string | null = null; +let refreshPromise: { generation: number; promise: Promise } | null = null; +let identityPromise: Promise<{ deviceId: string; deviceName: string }> | null = null; +let notchSettingsRefreshPromise: { + scope: ActivityAccountScope; + promise: Promise; +} | null = null; +let notchSettingsRefreshed: { + scope: ActivityAccountScope; + at: number; +} | null = null; +let notchSettingsUpdateQueue: Promise = Promise.resolve(); + +function sameAccountScope( + left: ActivityAccountScope, + right: ActivityAccountScope, +): boolean { + return left.generation === right.generation && left.ownerId === right.ownerId; +} + +function isCurrentAccountScope(scope: ActivityAccountScope): boolean { + return scope.generation === activityAccountGeneration + && scope.ownerId === activityAccountOwnerId; +} + +function unavailableActivitySnapshot(error: unknown): { + snapshotScope: AttentionSnapshot["scope"]; + availability: NonNullable; +} { + const message = errorMessage(error); + const signedIn = Boolean(activityAccountOwnerId); + const incompatible = /(?:unsupported|method not found|update .* then restart|needs upgrading)/i + .test(message); + if (incompatible) { + return { + snapshotScope: activityStore.getState().snapshotScope ?? (signedIn ? "account" : "machine"), + availability: { + state: "incompatible", + title: "Update the connected ADE host", + message: "This host cannot refresh Activity yet. Update ADE, restart its brain, then retry. Last-known work remains available.", + recovery: "update_host", + }, + }; + } + return { + snapshotScope: activityStore.getState().snapshotScope ?? (signedIn ? "account" : "machine"), + availability: { + state: "degraded", + title: signedIn + ? "Account Activity is reconnecting" + : "This machine’s Activity is unavailable", + message: signedIn + ? "ADE couldn’t refresh the account stream. Last-known work remains available while you retry." + : "ADE couldn’t refresh this machine. Retry to restore live updates.", + recovery: "retry", + }, + }; +} + +function failClosedActivityNotchSettings(): AttentionNotchSettings { + const presentation = readActivityNotchPresentation(); + return { + enabled: readActivityNotchEnabled(), + // Presentation is a layout choice, not a privacy one: keeping the user's + // chosen mode here stops a lost account load from re-covering the menu bar. + revealMode: presentation.revealMode, + expandedPanelEnabled: presentation.expandedPanelEnabled, + automaticRevealEnabled: presentation.automaticRevealEnabled, + tickerEnabled: presentation.tickerEnabled, + preferredDisplayId: null, + hideDetails: true, + celebrationsEnabled: false, + soundsEnabled: false, + }; +} + +function enqueueActivityNotchSettingsUpdate( + scope: ActivityAccountScope, + 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 Activity."; +} + +export async function refreshActivitySnapshot(): Promise { + const generation = activityAccountGeneration; + const ownerId = activityAccountOwnerId; + if (refreshPromise?.generation === generation) return refreshPromise.promise; + const api = typeof window !== "undefined" ? window.ade?.attention : null; + if (!api) { + activityStore.getState().setSyncStatus("ready"); + return; + } + + activityStore.getState().setSyncStatus("syncing"); + let timeoutId: ReturnType | null = null; + const snapshotPromise = Promise.resolve().then(() => api.getSnapshot( + activityStore.getState().revision, + activityStore.getState().streamId, + )); + const timeoutPromise = new Promise((_resolve, reject) => { + timeoutId = setTimeout(() => { + reject(new Error( + "Activity took too long to respond. Retry to restore live updates.", + )); + }, ACTIVITY_SNAPSHOT_TIMEOUT_MS); + }); + const promise = Promise.race([snapshotPromise, timeoutPromise]) + .then((snapshot) => { + if ( + generation !== activityAccountGeneration + || ownerId !== activityAccountOwnerId + ) return; + activityStore.getState().applySnapshot(snapshot); + if (ownerId) { + void refreshActivityNotchSettings({ generation, ownerId }); + } + }) + .catch((error) => { + if (generation !== activityAccountGeneration) return; + activityStore.setState(unavailableActivitySnapshot(error)); + activityStore.getState().setSyncStatus("error", errorMessage(error)); + }) + .finally(() => { + if (timeoutId !== null) { + clearTimeout(timeoutId); + timeoutId = null; + } + if (refreshPromise?.promise === promise) refreshPromise = null; + }); + refreshPromise = { generation, promise }; + return promise; +} + +/** + * One projected row: bounded preview, no `recentActivity` (the notch never + * renders it and it is the single largest field on a busy agent row). Built + * fresh so the store's objects are never mutated. + */ +function projectActivityNotchItem(item: AttentionItem): AttentionItem { + const { recentActivity: _recentActivity, ...rest } = item; + return { + ...rest, + detail: null, + preview: item.preview.length > MAX_NOTCH_PREVIEW_LENGTH + ? `${item.preview.slice(0, MAX_NOTCH_PREVIEW_LENGTH - 1)}…` + : item.preview, + }; +} + +function activityNotchCounts(items: readonly AttentionItem[]): AttentionCounts { + const summary = summarizeActivity(items); + return { + needsYou: summary.needsYouCount, + working: summary.workingCount, + done: summary.doneCount, + total: summary.trackedCount, + machinesOnline: summary.machinesOnline, + machinesTotal: summary.machinesTotal, + }; +} + +export function materializeActivityNotchSnapshot(): AttentionSnapshot { + const state = activityStore.getState(); + const allItems = Object.values(state.itemsById); + // Activity's own order, flattened: needs-you, then working, then done. + // `activitySections` already drops dismissed and expired rows. + const ordered = activitySections(allItems).flatMap((section) => section.items); + const projected = ordered + .slice(0, MAX_NOTCH_PROJECTION_ITEMS) + .map(projectActivityNotchItem); + const projectedItemCount = projected.length; + const snapshot: AttentionSnapshot = { + contractVersion: ATTENTION_CONTRACT_VERSION, + scope: state.snapshotScope ?? (activityAccountOwnerId ? "account" : "machine"), + availability: state.availability ?? { + state: activityAccountOwnerId ? "ready" : "signed_out", + title: activityAccountOwnerId ? "Account Activity" : "This machine only", + message: activityAccountOwnerId + ? "Live across your ADE account." + : "Sign in to combine Activity across every ADE machine.", + recovery: activityAccountOwnerId ? null : "sign_in", + }, + streamId: state.streamId, + revision: state.revision, + generatedAt: state.generatedAt ?? new Date().toISOString(), + items: [...projected], + itemsTruncated: projected.length < ordered.length, + counts: activityNotchCounts(allItems), + tombstones: [], + }; + const serializedSnapshot = JSON.stringify(snapshot); + const mightExceedByteBudget = serializedSnapshot.length > MAX_NOTCH_SNAPSHOT_BYTES / 2 + || ( + serializedSnapshot.length > MAX_NOTCH_SNAPSHOT_BYTES / 3 + && /[^\u0000-\u007f]/.test(serializedSnapshot) + ); + if (mightExceedByteBudget) { + const encoder = new TextEncoder(); + const bytesBeforeBudget = encoder.encode(serializedSnapshot).byteLength; + let bytesAfterBudget = bytesBeforeBudget; + while (snapshot.items.length > 0 && bytesAfterBudget > MAX_NOTCH_SNAPSHOT_BYTES) { + snapshot.items.pop(); + snapshot.itemsTruncated = true; + bytesAfterBudget = encoder.encode(JSON.stringify(snapshot)).byteLength; + } + if (snapshot.items.length < projectedItemCount) { + console.warn(`[useActivitySync] activity.notch_snapshot_truncated ${JSON.stringify({ + reason: "byte_budget", + budgetBytes: MAX_NOTCH_SNAPSHOT_BYTES, + bytesBeforeBudget, + bytesAfterBudget, + projectedItems: projectedItemCount, + publishedItems: snapshot.items.length, + totalItems: ordered.length, + })}`); + } + } + return snapshot; +} + +export function activityNotchSnapshotSignature( + snapshot = materializeActivityNotchSnapshot(), +): string { + return JSON.stringify([ + snapshot.scope ?? null, + snapshot.availability ?? null, + snapshot.streamId ?? null, + snapshot.revision, + // Counts derive from the full set, so a row falling off the projection can + // change them without changing a single published row. + snapshot.counts ?? null, + ...[...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 publishActivityNotchSnapshot( + snapshot = materializeActivityNotchSnapshot(), +): Promise { + const api = typeof window !== "undefined" ? window.ade?.attentionNotch : null; + if (!api) return; + await api.publishSnapshot(snapshot); +} + +/** + * Every event kind, mapped. Left exhaustive on purpose: a new kind added to + * `AttentionEventKind` must fail the build here rather than silently arrive as + * an `info` toast for something that broke. + */ +const TOAST_TREATMENT_BY_EVENT: Record = { + agent_running: "info", + agent_needs_you: "alert", + agent_failed: "alert", + agent_completed: "info", + pr_checks_failing: "alert", + pr_review_requested: "info", + pr_changes_requested: "alert", + pr_merge_ready: "success", + pr_merged: "celebration", + pr_opened: "info", + pr_closed: "info", +}; + +const PRIVACY_TOAST_TITLE: Record = { + agent: "Agent update", + pull_request: "Pull request update", +}; + +export type ActivityToastDecisionInput = { + items: readonly AttentionItem[]; + /** Phase each item carried the last time this window looked at it. */ + previousPhases: ReadonlyMap; + lastToastAtByItem: ReadonlyMap; + lastToastAt: number; + availabilityState: string | null; + automaticRevealEnabled: boolean; + hideDetails: boolean; + now?: number; +}; + +/** + * The whole "should this interrupt" decision, pure and injectable so cooldown + * and rate-limit behaviour can be driven deterministically in tests. + * + * When several items transition inside one merge exactly one toast is emitted — + * the highest-priority one — and the rest are dropped rather than queued: a + * queue would still be announcing the last burst when the next one arrives. + */ +export function activityToastForTransition( + input: ActivityToastDecisionInput, +): AttentionNotchToast | null { + const now = input.now ?? Date.now(); + if (!input.automaticRevealEnabled) return null; + // Degraded/signed-out snapshots carry last-known rows; announcing one as if + // it just happened would be a lie about freshness. + if (input.availabilityState !== "ready") return null; + if (now - input.lastToastAt < TOAST_MIN_INTERVAL_MS) return null; + + let best: AttentionItem | null = null; + for (const item of input.items) { + const previous = input.previousPhases.get(item.id); + // First sighting is not a transition: a window that just opened would + // otherwise toast the entire backlog. + if (previous === undefined || previous === item.phase) continue; + if (activityItemTier(item) !== "signal") continue; + if (item.seenAt != null || item.dismissedAt != null) continue; + const lastForItem = input.lastToastAtByItem.get(item.id); + if (lastForItem !== undefined && now - lastForItem < TOAST_ITEM_COOLDOWN_MS) continue; + if ( + best === null + || attentionPhasePriority(item.phase) < attentionPhasePriority(best.phase) + || ( + attentionPhasePriority(item.phase) === attentionPhasePriority(best.phase) + && item.id.localeCompare(best.id) < 0 + ) + ) { + best = item; + } + } + if (!best) return null; + + const treatment = TOAST_TREATMENT_BY_EVENT[best.eventKind] ?? "info"; + const unclampedTitle = input.hideDetails ? PRIVACY_TOAST_TITLE[best.kind] : best.title; + const unclampedSubtitle = input.hideDetails + ? best.privacyPreview + : sanitizeAttentionPreview(best.preview, MAX_TOAST_SUBTITLE_LENGTH); + return { + itemId: best.id, + eventKind: best.eventKind, + treatment, + title: unclampedTitle.slice(0, MAX_TOAST_TITLE_LENGTH), + subtitle: unclampedSubtitle.slice(0, MAX_TOAST_SUBTITLE_LENGTH), + tone: null, + durationMs: null, + }; +} + +const notchToastPhases = new Map(); +const notchToastCooldownByItem = new Map(); +let notchLastToastAt = 0; + +function resetActivityToastState(): void { + notchToastPhases.clear(); + notchToastCooldownByItem.clear(); + notchLastToastAt = 0; +} + +/** + * Runs on the same store subscription that publishes the snapshot, so a merge + * can never publish rows without having considered whether one of them earned + * an announcement. + */ +function emitActivityNotchToast(snapshot: AttentionSnapshot): void { + const items = Object.values(activityStore.getState().itemsById); + const presentation = resolveActivityNotchPresentation( + activityStore.getState().preferences, + ); + const toast = activityToastForTransition({ + items, + previousPhases: notchToastPhases, + lastToastAtByItem: notchToastCooldownByItem, + lastToastAt: notchLastToastAt, + availabilityState: snapshot.availability?.state ?? null, + automaticRevealEnabled: presentation.automaticRevealEnabled, + // The notch takes hide-details' fail-closed default, exactly like + // `failClosedActivityNotchSettings`: it paints over the menu bar of a Mac + // whose owner may have walked away. + hideDetails: activityStore.getState().preferences?.account?.hideDetails !== false, + }); + // Record every phase seen, toast or not, so a suppressed transition is not + // re-detected as new on the next merge. + const liveIds = new Set(); + for (const item of items) { + liveIds.add(item.id); + notchToastPhases.set(item.id, item.phase); + } + for (const id of [...notchToastPhases.keys()]) { + if (!liveIds.has(id)) notchToastPhases.delete(id); + } + const now = Date.now(); + for (const [id, at] of [...notchToastCooldownByItem]) { + if (now - at >= TOAST_ITEM_COOLDOWN_MS) notchToastCooldownByItem.delete(id); + } + if (!toast?.itemId) return; + const notchApi = window.ade?.attentionNotch; + if (typeof notchApi?.publishToast !== "function") return; + const publishToast = notchApi.publishToast; + const itemId = toast.itemId; + const publishedAt = Date.now(); + const accountGeneration = activityAccountGeneration; + const previousLastToastAt = notchLastToastAt; + const previousItemToastAt = notchToastCooldownByItem.get(itemId); + notchLastToastAt = publishedAt; + notchToastCooldownByItem.set(itemId, publishedAt); + void Promise.resolve() + .then(() => accountGeneration === activityAccountGeneration + ? publishToast(toast) + : undefined) + .catch(() => { + if (accountGeneration !== activityAccountGeneration) return; + if (notchLastToastAt === publishedAt) { + notchLastToastAt = previousLastToastAt; + } + if (notchToastCooldownByItem.get(itemId) !== publishedAt) return; + if (previousItemToastAt === undefined) { + notchToastCooldownByItem.delete(itemId); + } else { + notchToastCooldownByItem.set(itemId, previousItemToastAt); + } + }); +} + +async function refreshActivityNotchSettings( + scope: ActivityAccountScope, + force = false, +): Promise { + if (!isCurrentAccountScope(scope)) return; + if (!scope.ownerId) 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; + // The notch is optional — it does not exist on the web client at all — but the + // preferences behind it are not: hide-details and the dock-badge scope govern + // the Activity surfaces on every platform, so the fetch must not be gated on + // the native helper being present. + if (typeof attentionApi?.getPreferences !== "function") return; + const ownerId = scope.ownerId; + // `Promise.resolve().then(…)` rather than a bare call: a host that answers + // synchronously (or with nothing at all) must land in this chain's own catch + // instead of throwing past it as an unhandled rejection. + const promise = Promise.resolve() + .then(() => attentionApi.getPreferences(ownerId)) + .then(async (preferences) => { + if (!isCurrentAccountScope(scope) || !preferences) return; + activityStore.getState().setPreferences(preferences); + if (!notchApi) return; + await enqueueActivityNotchSettingsUpdate( + scope, + activityNotchSettingsFromPreferences( + preferences, + readActivityNotchEnabled(), + resolveActivityNotchPresentation(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 prepareActivityNotchForAccount( + scope: ActivityAccountScope, +): Promise { + const notchApi = typeof window !== "undefined" ? window.ade?.attentionNotch : null; + if (!notchApi || !isCurrentAccountScope(scope)) return null; + 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 enqueueActivityNotchSettingsUpdate(scope, failClosedActivityNotchSettings()); + if (!isCurrentAccountScope(scope)) return null; + const snapshot = materializeActivityNotchSnapshot(); + await publishActivityNotchSnapshot(snapshot); + const publishedSignature = activityNotchSnapshotSignature(snapshot); + if (!isCurrentAccountScope(scope)) return null; + if (scope.ownerId) await refreshActivityNotchSettings(scope, true); + return isCurrentAccountScope(scope) ? publishedSignature : null; + } catch { + return null; + } +} + +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; + let nativeSurfaceVisible = false; + try { + const health = await window.ade?.attentionNotch?.getHealth?.(); + nativeSurfaceVisible = health?.state === "running" && health.surface != null; + } catch { + // Presence remains useful when the optional native helper cannot report. + } + const effectiveSurfaceVisible = ambientSurfaceVisible || nativeSurfaceVisible; + const identity = await resolveDesktopIdentity(); + await api.reportPresence({ + ...identity, + platform: desktopPlatform(), + appForeground: foreground, + ambientSurfaceVisible: effectiveSurfaceVisible, + visibleItemIds: effectiveSurfaceVisible + ? visibleItemIds.slice(0, MAX_VISIBLE_PRESENCE_ITEMS) + : [], + observedAt: new Date().toISOString(), + }); +} + +/** + * Keeps the account-wide Activity snapshot and desktop presence warm even + * before the user opens Activity, so badges and native surfaces remain truthful. + */ +export function useActivitySync(routeSurfaceVisible: boolean): void { + const { status: accountStatus, loading: accountLoading } = useAccountStatus(); + const accountUserId = accountStatus.signedIn ? accountStatus.userId : null; + const itemsById = useActivityStore((state) => state.itemsById); + const headerSurfaceVisible = useActivityStore((state) => state.headerSurfaceVisible); + const visibleItemIds = useMemo( + () => Object.keys(itemsById), + [itemsById], + ); + const visibleItemIdsKey = visibleItemIds.join("\u001f"); + const ambientSurfaceVisible = routeSurfaceVisible || headerSurfaceVisible; + 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 (activityAccountOwnerId !== accountUserId) { + activityAccountGeneration += 1; + activityAccountOwnerId = accountUserId; + identityPromise = null; + notchSettingsRefreshPromise = null; + notchSettingsRefreshed = null; + resetActivityToastState(); + activityStore.getState().resetStream(); + } + const accountScope = { + generation: activityAccountGeneration, + ownerId: accountUserId, + }; + let lastNotchSignature = ""; + let notchPrepared = false; + let prepareNotchPromise: Promise | null = null; + let notchPublishInFlight = false; + let notchPublishQueued = false; + let active = true; + let unsubscribe = () => {}; + const prepareNotch = () => { + if ( + notchPrepared + || prepareNotchPromise + || !active + || !isCurrentAccountScope(accountScope) + ) return; + const pending = prepareActivityNotchForAccount(accountScope) + .then((publishedSignature) => { + if (!active || !publishedSignature || !isCurrentAccountScope(accountScope)) return; + notchPrepared = true; + lastNotchSignature = publishedSignature; + publishNotchIfChanged(); + }) + .finally(() => { + if (prepareNotchPromise === pending) prepareNotchPromise = null; + }); + prepareNotchPromise = pending; + }; + const publishNotchIfChanged = () => { + const snapshot = materializeActivityNotchSnapshot(); + const nextSignature = activityNotchSnapshotSignature(snapshot); + if (!notchPrepared) { + prepareNotch(); + return; + } + // Toasts ride this same pass, and deliberately before the signature + // gate: a phase transition is exactly what earns an announcement, and a + // republish-suppressed frame must still be able to carry one. + emitActivityNotchToast(snapshot); + if (nextSignature === lastNotchSignature) return; + if (notchPublishInFlight) { + notchPublishQueued = true; + return; + } + notchPublishInFlight = true; + void publishActivityNotchSnapshot(snapshot) + .then(() => { + if (active && isCurrentAccountScope(accountScope)) { + lastNotchSignature = nextSignature; + } + }) + .catch(() => { + // Keep the prior signature: the next store update retries this state. + }) + .finally(() => { + notchPublishInFlight = false; + if (notchPublishQueued && active) { + notchPublishQueued = false; + publishNotchIfChanged(); + } + }); + }; + if (activityNotchSupported()) { + unsubscribe = activityStore.subscribe(publishNotchIfChanged); + publishNotchIfChanged(); + } + const removeNotchAcknowledgeListener = + window.ade?.attentionNotch?.onAcknowledgeRequested((request) => { + void acknowledgeActivityItem(request.itemId, request.mode) + .finally(publishNotchIfChanged); + }) ?? (() => {}); + const removeNotchRefreshListener = + window.ade?.attentionNotch?.onRefreshRequested?.((request) => { + if (request?.force !== true && document.visibilityState === "visible") return; + void refreshActivitySnapshot(); + }) ?? (() => {}); + const removeNotchSettingsListener = + window.ade?.attentionNotch?.onSettingsChanged?.((settings) => { + persistActivityNotchSettings(settings); + }) ?? (() => {}); + void refreshActivitySnapshot(); + const interval = window.setInterval(() => { + if (document.visibilityState === "visible") void refreshActivitySnapshot(); + }, POLL_INTERVAL_MS); + const onVisibilityChange = () => { + foregroundRef.current = document.visibilityState === "visible" && document.hasFocus(); + if (foregroundRef.current) void refreshActivitySnapshot(); + }; + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + active = false; + window.clearInterval(interval); + document.removeEventListener("visibilitychange", onVisibilityChange); + removeNotchAcknowledgeListener(); + removeNotchRefreshListener(); + removeNotchSettingsListener(); + unsubscribe(); + }; + }, [accountLoading, accountUserId]); + + useEffect(() => { + if (accountLoading || !accountUserId) return; + const send = () => { + void reportPresence( + ambientSurfaceVisibleRef.current, + visibleItemIdsRef.current, + foregroundRef.current, + ).catch(() => {}); + }; + let timer: number | null = null; + const schedule = () => { + if (timer !== null) window.clearTimeout(timer); + const delay = document.visibilityState === "visible" + ? PRESENCE_INTERVAL_MS + : HIDDEN_PRESENCE_INTERVAL_MS; + timer = window.setTimeout(() => { + timer = null; + send(); + schedule(); + }, delay); + }; + send(); + schedule(); + const onVisibilityChange = () => { + // Coming back reports at once: presence is how other devices learn this + // machine is being watched, and a 120s-stale "hidden" claim right as the + // user returns is the one case that misleads. Going hidden waits — `blur` + // has already reported the foreground change. + if (document.visibilityState === "visible") send(); + schedule(); + }; + const onFocus = () => { + foregroundRef.current = true; + send(); + }; + const onBlur = () => { + foregroundRef.current = false; + send(); + }; + document.addEventListener("visibilitychange", onVisibilityChange); + window.addEventListener("focus", onFocus); + window.addEventListener("blur", onBlur); + return () => { + if (timer !== null) window.clearTimeout(timer); + document.removeEventListener("visibilitychange", onVisibilityChange); + 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/components/activity/useProgressiveRows.ts b/apps/desktop/src/renderer/components/activity/useProgressiveRows.ts new file mode 100644 index 000000000..f60b8c83c --- /dev/null +++ b/apps/desktop/src/renderer/components/activity/useProgressiveRows.ts @@ -0,0 +1,16 @@ +import { useCallback, useMemo, useState } from "react"; + +const INITIAL_ROW_BUDGET = 60; +const ROW_BUDGET_STEP = 60; + +/** Keep long Activity columns cheap while exposing the next bounded page. */ +export function useProgressiveRows(rows: readonly T[]) { + const [budget, setBudget] = useState(INITIAL_ROW_BUDGET); + const visibleRows = useMemo(() => rows.slice(0, budget), [budget, rows]); + const hiddenCount = Math.max(0, rows.length - visibleRows.length); + const nextCount = Math.min(hiddenCount, ROW_BUDGET_STEP); + const showMore = useCallback(() => { + setBudget((value) => value + ROW_BUDGET_STEP); + }, []); + return { visibleRows, hiddenCount, nextCount, showMore }; +} diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index 21e836c33..70096afe7 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -99,9 +99,6 @@ 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 })) ); @@ -727,8 +724,6 @@ 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 isWebHubRoute = location.pathname === "/hub"; const isExternalFilesRoute = location.pathname === "/files" && new URLSearchParams(location.search).has("externalPath"); @@ -777,7 +772,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 || isAttentionRoute || isAccountRoute || isWebHubRoute) return; + if (isPersonalChatsRoute || isAccountRoute || isWebHubRoute) return; const previousSurfaceKey = previousActiveSurfaceKeyRef.current; if (previousSurfaceKey === activeSurfaceKey) return; const currentRoute = serializeStoredProjectRoute(location); @@ -800,7 +795,7 @@ function ProjectTabHost() { if (currentRoute !== nextRoute) { navigate(nextRoute, { replace: true }); } - }, [activeSurfaceKey, isAccountRoute, isAttentionRoute, isPersonalChatsRoute, isWebHubRoute, location, navigate, routesBySurfaceKey]); + }, [activeSurfaceKey, isAccountRoute, isPersonalChatsRoute, isWebHubRoute, location, navigate, routesBySurfaceKey]); React.useEffect(() => { if (!activeSurfaceKey) return; @@ -950,11 +945,11 @@ function ProjectTabHost() { ); } - if (!isWebHubRoute && !isAttentionRoute && !projectHydrated && !activeProject) { + if (!isWebHubRoute && !projectHydrated && !activeProject) { return GuardLoadingFallback; } - if (!isWebHubRoute && !isPersonalChatsRoute && !isAttentionRoute && !isAccountRoute && (!activeProject || showWelcome || mountedProjects.length === 0)) { + if (!isWebHubRoute && !isPersonalChatsRoute && !isAccountRoute && (!activeProject || showWelcome || mountedProjects.length === 0)) { return ( @@ -993,7 +988,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 668aca150..3dd1bb4e4 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -83,7 +83,9 @@ import { } from "../analytics/ProductAnalyticsLifecycle"; import { useAppWideSessionAttention } from "../../hooks/useAppWideSessionAttention"; import { useCtoAttention } from "../../hooks/useCtoAttention"; -import { useAttentionSync } from "../attention/useAttentionSync"; +import { ActivityPane } from "../activity/ActivityPane"; +import { useActivitySync } from "../activity/useActivitySync"; +import { isActivityRoute } from "../../lib/legacyRoutes"; type PrToast = { id: string; @@ -98,12 +100,13 @@ type AutoLinkToast = { }; function primaryTabPath(pathname: string): string { - const roots = ["/hub", "/attention", "/lanes", "/files", "/work", "/graph", "/prs", "/history", "/automations", "/cto", "/settings"]; + const roots = ["/hub", "/activity", "/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 = [ "/hub", + "/activity", "/attention", "/lanes", "/files", @@ -121,6 +124,10 @@ const PRODUCT_ANALYTICS_ROUTE_ROOTS = [ export function productAnalyticsScreenForPathname(pathname: string): string { if (pathname === "/project" || pathname.startsWith("/project/")) return "project"; + // Activity used to be the "/attention" route, and the screen name is derived + // from the path root. Mapping it explicitly keeps one PostHog series across + // the rename instead of forking it into "attention" and "activity". + if (isActivityRoute(pathname)) return "attention"; const root = PRODUCT_ANALYTICS_ROUTE_ROOTS.find( (candidate) => pathname === candidate || pathname.startsWith(`${candidate}/`), ); @@ -344,8 +351,7 @@ 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 activityDeepLink = isActivityRoute(location.pathname); const isAccountRoute = location.pathname === "/account" || location.pathname.startsWith("/account/"); const isWebHubRoute = isWebClientMode() && location.pathname === "/hub"; @@ -358,9 +364,26 @@ export function AppShell({ children }: { children: React.ReactNode }) { }); const isWorkAdjacentRoute = isWorkRoute || isLanesRoute; const isLanesRouteRef = useRef(isLanesRoute); + + // Activity is a modal over whatever tab is in front, not a tab of its own, so + // the shell owns whether it is up. `/activity` (and its `/attention` + // predecessor) stay valid deep links: they open the pane and immediately hand + // the URL back, so the surface underneath is a real tab rather than a blank + // route that exists only to host an overlay. + const [activityPaneOpen, setActivityPaneOpen] = useState(false); + const lastNonActivityRouteRef = useRef("/work"); + if (!activityDeepLink) { + lastNonActivityRouteRef.current = `${location.pathname}${location.search}`; + } + useEffect(() => { + if (!activityDeepLink) return; + setActivityPaneOpen(true); + navigate(lastNonActivityRouteRef.current, { replace: true }); + }, [activityDeepLink, navigate]); + useAppWideSessionAttention(); useCtoAttention(); - useAttentionSync(isAttentionRoute); + useActivitySync(activityPaneOpen); useEffect(() => { isLanesRouteRef.current = isLanesRoute; @@ -1130,6 +1153,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { const tintClass = useMemo(() => { const tintMap: Record = { + "/activity": "tab-tint-work", "/attention": "tab-tint-work", "/lanes": "tab-tint-lanes", "/files": "tab-tint-files", @@ -1187,6 +1211,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { accountRouteActive={isAccountRoute} hubRouteActive={isWebHubRoute} onNavigate={(path, opts) => navigate(path, opts)} + onOpenActivityPane={() => setActivityPaneOpen(true)} />
@@ -1693,6 +1718,8 @@ export function AppShell({ children }: { children: React.ReactNode }) {
+ setActivityPaneOpen(false)} /> +
diff --git a/apps/desktop/src/renderer/components/app/SettingsPage.tsx b/apps/desktop/src/renderer/components/app/SettingsPage.tsx index e93bd576a..55cd71f0c 100644 --- a/apps/desktop/src/renderer/components/app/SettingsPage.tsx +++ b/apps/desktop/src/renderer/components/app/SettingsPage.tsx @@ -12,7 +12,9 @@ import { MagnifyingGlass, Palette, PlugsConnected, + Pulse as PulseIcon, } from "@phosphor-icons/react"; +import { ActivitySection } from "../settings/ActivitySection"; import { AppearanceSection } from "../settings/AppearanceSection"; import { AboutSection } from "../settings/AboutSection"; import { AdeCliSection } from "../settings/AdeCliSection"; @@ -62,6 +64,7 @@ const TAB_ICONS: Record = { "lanes-git": GitBranch, integrations: PlugsConnected, notifications: Bell, + activity: PulseIcon, secrets: Key, storage: HardDrives, stats: ChartLineUp, @@ -117,6 +120,8 @@ function TabContent({ tab }: { tab: SettingsTabId }) { ); case "notifications": return ; + case "activity": + return ; case "secrets": return ; case "storage": diff --git a/apps/desktop/src/renderer/components/app/TabNav.test.tsx b/apps/desktop/src/renderer/components/app/TabNav.test.tsx index a9dd57a8a..93c13f5ce 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.test.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.test.tsx @@ -84,7 +84,7 @@ describe("TabNav", () => { expect(screen.getByRole("link", { name: "Review" }).getAttribute("aria-disabled")).toBe("true"); }); - it("keeps the full Attention center secondary to the global header control", () => { + it("keeps Activity a header control and a modal, never a nav tab", () => { useAppStore.setState({ project: null, projectBinding: null, @@ -97,6 +97,9 @@ describe("TabNav", () => { , ); - expect(screen.queryByRole("link", { name: "Attention" })).toBeNull(); + // Deliberate: deleting this assertion is the quiet path to a tenth tab + // nobody agreed to. Activity lives in the header and opens as a modal. + expect(screen.queryByRole("link", { name: "Activity" })).toBeNull(); + expect(screen.queryByRole("link", { name: "Activity" })).toBeNull(); }); }); diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index 59627ea5f..931c3eaaf 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -9,9 +9,9 @@ import { applyShellHeaderInset } from "../../lib/zoom"; import { openConnectionsPanel } from "../../lib/connectionsPanel"; import { useAppStore } from "../../state/appStore"; import { - attentionStore, - resetAttentionStoreForTests, -} from "../../state/attentionStore"; + activityStore, + resetActivityStoreForTests, +} from "../../state/activityStore"; import { ATTENTION_CONTRACT_VERSION } from "../../../shared/types"; import { publishAccountStatus, SIGNED_OUT_ACCOUNT } from "../../lib/account"; import { requestLinearIssueQuickView } from "../../lib/linearIssueQuickViewNavigation"; @@ -412,11 +412,11 @@ describe("TopBar", () => { } else { globalThis.window.__adeWebClient = originalWebClientMode; } - resetAttentionStoreForTests(); + resetActivityStoreForTests(); publishAccountStatus(SIGNED_OUT_ACCOUNT); }); - it("carries account-wide Attention in the header and routes Open all to the center", () => { + it("carries account-wide Activity in the header and raises the pane from Open all", () => { const needsYou = { contractVersion: ATTENTION_CONTRACT_VERSION, id: "needs-you", @@ -445,7 +445,7 @@ describe("TopBar", () => { dismissedAt: null, expiresAt: null, }; - attentionStore.setState({ itemsById: { [needsYou.id]: needsYou } }); + activityStore.setState({ itemsById: { [needsYou.id]: needsYou } }); publishAccountStatus({ signedIn: true, userId: "account-a", @@ -456,18 +456,23 @@ describe("TopBar", () => { imageUrl: null, }); const onNavigate = vi.fn(); + const onOpenActivityPane = vi.fn(); - render(); + render(); - const trigger = screen.getByTestId("header-attention-trigger"); + const trigger = screen.getByTestId("header-activity-trigger"); // The item belongs to another machine and project entirely — the header is // account-wide, not scoped to whatever project this window has open. - expect(trigger.getAttribute("aria-label")).toBe("Attention · 1 needs you"); + expect(trigger.getAttribute("aria-label")).toBe("Activity · 1 needs you"); fireEvent.click(trigger); fireEvent.click(screen.getByRole("button", { name: /Open all/ })); - expect(onNavigate).toHaveBeenCalledWith("/attention"); + // Activity is a modal over the current tab, so opening it is shell state — + // navigating would cost the user whatever tab they were on. + expect(onOpenActivityPane).toHaveBeenCalledTimes(1); + expect(onNavigate).not.toHaveBeenCalledWith("/attention"); + expect(onNavigate).not.toHaveBeenCalledWith("/activity"); }); it("shows connections before a project is open without immediate polling", async () => { diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index eb790692f..5f3276f7f 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -75,7 +75,7 @@ import { type ConnectionsPanelTab, } from "../../lib/connectionsPanel"; import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; -import { HeaderAttentionControl } from "../attention/HeaderAttentionControl"; +import { HeaderActivityControl } from "../activity/HeaderActivityControl"; import { HeaderUsageControl } from "../usage/HeaderUsageControl"; import { GlobalVoiceCaptureIndicator } from "../voice/GlobalVoiceCaptureIndicator"; import { appResourcePressureLevel, getAppResourceUsageCoalesced, resourcePressureDescription } from "../../lib/resourcePressure"; @@ -951,12 +951,15 @@ export function TopBar({ personalChatsRouteActive = false, accountRouteActive = false, hubRouteActive = false, + onOpenActivityPane, onNavigate, }: { personalChatsRouteActive?: boolean; accountRouteActive?: boolean; hubRouteActive?: boolean; onNavigate?: (path: string, opts?: { replace?: boolean }) => void; + /** Raises the shell's Activity pane over whatever tab is in front. */ + onOpenActivityPane?: () => void; } = {}) { const project = useAppStore((s) => s.project); const hasProject = Boolean(project?.rootPath); @@ -1586,10 +1589,14 @@ export function TopBar({ window.ade.app.newWindow().catch(() => {}); }, [isProjectBusy]); - // Attention is account-wide, so it never depends on a project being open. - const handleOpenAttentionCenter = useCallback(() => { - onNavigate?.("/attention"); - }, [onNavigate]); + // Activity is account-wide, so it never depends on a project being open — and + // it is a modal, not a tab, so opening it flips shell state instead of + // navigating. The `/activity` pathname still works as a deep link; the shell + // turns it back into this same flip. + const handleOpenActivityPane = useCallback(() => { + if (onOpenActivityPane) onOpenActivityPane(); + else onNavigate?.("/activity"); + }, [onNavigate, onOpenActivityPane]); // Clicking a project tab while either the personal-chats or account machine // route is foreground must leave it, or ProjectTabHost's route replay never @@ -2740,11 +2747,11 @@ export function TopBar({
) : null} - {/* Trailing controls: attention · status · updates · utility cluster */} + {/* Trailing controls: activity · status · updates · utility cluster */}
- {/* Account-wide Attention — the one place every machine's work surfaces, + {/* Account-wide Activity — the one place every machine's work surfaces, reachable from every tab and project without a nav detour. */} - + {/* App-global voice capture — visible from any tab while recording. */} diff --git a/apps/desktop/src/renderer/components/attention/AttentionCenter.css b/apps/desktop/src/renderer/components/attention/AttentionCenter.css deleted file mode 100644 index ac9fad76d..000000000 --- a/apps/desktop/src/renderer/components/attention/AttentionCenter.css +++ /dev/null @@ -1,1761 +0,0 @@ -/* 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; - /* No --attn-warn here on purpose. A free-floating amber token is how amber - leaked onto four unrelated things; the only amber left in this file is - .attention-tone-amber, which a phase must earn by meaning "your move". */ - --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-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); -} - -/* The bell badge counts the whole inbox — needs-you, failures, review requests - and unseen outcomes together — so it is an aggregate, not a request. It used - to be amber, which is exactly how amber came to mean four things at once; - neutral-strong keeps it legible and leaves the meaning to the per-row tones. */ -.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: var(--color-bg); - border: 2px solid var(--color-bg); - border-radius: 99px; - background: var(--color-fg); - 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; -} - -/* Jump-to-inbox affordance. Accent, not amber: the number behind it is the - whole inbox (failures and review requests included), and amber is reserved - for states where the user personally has to move. */ -.attention-panel-heading button { - flex: 0 0 auto; - padding: 4px 7px; - color: var(--color-accent-bright, var(--color-accent)); - border-radius: 6px; - background: color-mix(in srgb, var(--color-accent) 14%, 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(--color-accent) 22%, 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; -} - -/* "This machine is offline, you're reading last-known state" is true but not - actionable — the user cannot reconnect it from here. It was one of the five - things amber used to mean; it is neutral now, per the one-hue rule in - apps/desktop/src/shared/sessionStatusPresentation.ts. */ -.attention-offline-banner { - color: var(--color-muted-fg); - border: 1px solid color-mix(in srgb, var(--attn-idle) 30%, transparent); - background: color-mix(in srgb, var(--attn-idle) 10%, 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; -} - -/* Link out to the canonical settings surface. The popover is three quick - toggles; the full delivery model lives in Settings > Notifications. */ -.attention-settings-open-full { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - width: 100%; - margin-top: 4px; - padding: 8px 10px; - font-size: 12px; - font-weight: 500; - color: var(--color-secondary-fg); - background: color-mix(in srgb, var(--color-fg) 4%, transparent); - border: 1px solid color-mix(in srgb, var(--color-border) 85%, transparent); - border-radius: 9px; - cursor: pointer; - transition: background 120ms ease, color 120ms ease; -} - -.attention-settings-open-full:hover { - color: var(--color-fg); - background: color-mix(in srgb, var(--color-fg) 8%, transparent); -} - -/* ── 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 deleted file mode 100644 index 03249efe9..000000000 --- a/apps/desktop/src/renderer/components/attention/AttentionCenter.test.tsx +++ /dev/null @@ -1,667 +0,0 @@ -// @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"; -import { - attentionNotchSettingsFromPreferences, - onAttentionNotchSettingsChanged, - persistAttentionNotchSettings, - readAttentionNotchEnabled, - readAttentionNotchPresentation, - writeAttentionNotchEnabled, - writeAttentionNotchPresentation, -} from "./attentionNotchLocalSettings"; - -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"); - window.localStorage.removeItem("ade:attention:notch-reveal-mode"); - window.localStorage.removeItem("ade:attention:notch-expanded-panel"); - Object.defineProperty(window, "ade", { - configurable: true, - writable: true, - value: originalAde, - }); -}); - -describe("AttentionCenter", () => { - // Notch presentation (reveal mode, expanded panel) moved to - // Settings > Notifications; its coverage lives in - // settings/NotificationsSection.test.tsx. - - 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], - sourceRevisions: { [approval.id]: approval.revision }, - expectedAccountOwnerId: null, - 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.click(screen.getByRole("button", { name: "Save" })); - - // Escalation, quiet hours, and per-event policies moved to - // Settings > Notifications; the popover keeps three quick toggles. - expect(screen.queryByRole("combobox", { name: "Phone escalation" })).toBeNull(); - - await waitFor(() => { - expect(putPreferences).toHaveBeenCalledWith( - "account-a", - expect.objectContaining({ - account: expect.objectContaining({ - soundsEnabled: true, - }), - }), - ); - expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ - enabled: false, - soundsEnabled: true, - })); - expect(window.localStorage.getItem("ade:attention:notch-enabled")).toBe("false"); - }); - }); - - // Off, the three reveal modes, and the expanded-panel switch are the whole - // presentation contract; they only mean anything if they reach the helper. - - - 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 = - () => {}; - 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); - }); - }); -}); - -describe("attention notch local settings", () => { - beforeEach(() => { - window.localStorage.clear(); - }); - - it("defaults safely and falls back from an unreadable reveal mode", () => { - expect(readAttentionNotchEnabled()).toBe(true); - expect(readAttentionNotchPresentation()).toEqual({ - revealMode: "hover", - expandedPanelEnabled: true, - }); - - window.localStorage.setItem("ade:attention:notch-reveal-mode", "telepathy"); - expect(readAttentionNotchPresentation().revealMode).toBe("hover"); - }); - - it("round-trips every presentation mode independently from full disable", () => { - for (const revealMode of ["minimal", "hover", "click"] as const) { - writeAttentionNotchPresentation({ revealMode, expandedPanelEnabled: false }); - expect(readAttentionNotchPresentation()).toEqual({ - revealMode, - expandedPanelEnabled: false, - }); - } - writeAttentionNotchEnabled(false); - - expect(attentionNotchSettingsFromPreferences(DEFAULT_ATTENTION_PREFERENCES)) - .toMatchObject({ - enabled: false, - revealMode: "click", - expandedPanelEnabled: false, - }); - }); - - it("persists native context-menu changes and notifies the renderer", () => { - let observed: ReturnType | null = null; - const unsubscribe = onAttentionNotchSettingsChanged((settings) => { - observed = settings; - }); - persistAttentionNotchSettings({ - enabled: false, - revealMode: "minimal", - expandedPanelEnabled: false, - preferredDisplayId: null, - hideDetails: true, - celebrationsEnabled: true, - soundsEnabled: false, - }); - - expect(readAttentionNotchEnabled()).toBe(false); - expect(readAttentionNotchPresentation()).toEqual({ - revealMode: "minimal", - expandedPanelEnabled: false, - }); - expect(observed).toMatchObject({ - enabled: false, - revealMode: "minimal", - expandedPanelEnabled: false, - }); - unsubscribe(); - }); - it("links to Settings through the navigation bus, not the router", async () => { - // The attention subtree is mounted outside the router here (and in the - // notch), so the link must dispatch an app-navigation target rather than - // calling useNavigate — which would throw "may be used only in the context - // of a ". - const targets: unknown[] = []; - const onNavigate = (event: Event) => { - targets.push((event as CustomEvent).detail?.target); - }; - window.addEventListener("ade:navigate-target", onNavigate); - try { - render(); - fireEvent.click(screen.getByRole("button", { name: "Attention settings" })); - await screen.findByRole("dialog", { name: "Attention settings" }); - - fireEvent.click(await screen.findByRole("button", { name: /All notification settings/ })); - - expect(targets).toEqual([{ kind: "settings", tab: "notifications" }]); - } finally { - window.removeEventListener("ade:navigate-target", onNavigate); - } - }); -}); diff --git a/apps/desktop/src/renderer/components/attention/AttentionCenter.tsx b/apps/desktop/src/renderer/components/attention/AttentionCenter.tsx deleted file mode 100644 index 2a5f06212..000000000 --- a/apps/desktop/src/renderer/components/attention/AttentionCenter.tsx +++ /dev/null @@ -1,1154 +0,0 @@ -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, item)); - } - } 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" ? ( - // "in inbox", not "needs you": this count is the whole inbox — - // failures, review requests and unseen outcomes as well as raised - // hands — and "needs you" is a claim only a needs_you row may - // make. The button jumps to the Inbox tab, which says the same. - - ) : 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 deleted file mode 100644 index 0b68982e7..000000000 --- a/apps/desktop/src/renderer/components/attention/AttentionSettingsPopover.tsx +++ /dev/null @@ -1,443 +0,0 @@ -import React, { useEffect, useRef, useState } from "react"; -import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { - ArrowSquareOut, - ArrowsOutSimple, - BellRinging, - Check, - Confetti, - CursorClick, - DeviceMobile, - GearSix, - HourglassMedium, - LockKey, - Notches, - SpeakerHigh, - WarningCircle, -} from "@phosphor-icons/react"; - -import { - DEFAULT_ATTENTION_PREFERENCES, - isAttentionNotchRevealMode, - type AttentionNotchRevealMode, - type AttentionPreferences, -} from "../../../shared/types"; -import { - attentionNotchSettingsFromPreferences, - normalizeAttentionPreferences, - onAttentionNotchSettingsChanged, - readAttentionNotchEnabled, - readAttentionNotchPresentation, - writeAttentionNotchEnabled, - writeAttentionNotchPresentation, -} from "./attentionNotchLocalSettings"; -import { useAccountStatus } from "../../lib/account"; -import { navigateToAppTarget } from "../../lib/openExternal"; - -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; - -const NOTCH_REVEAL_OPTIONS: ReadonlyArray<{ - value: AttentionNotchRevealMode; - label: string; -}> = [ - { value: "minimal", label: "Compact + peek" }, - { value: "hover", label: "Reveal on hover" }, - { value: "click", label: "Click only" }, -]; - -const NOTCH_REVEAL_HELP: Record = { - minimal: "Keep a tiny status visible; hover or click for a short peek.", - hover: "Hide the surface until the pointer reaches the top-edge hot zone.", - click: "Keep the compact status visible and expand only when clicked.", -}; - -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 [notchPresentation, setNotchPresentation] = useState(readAttentionNotchPresentation); - 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; - } - - useEffect(() => onAttentionNotchSettingsChanged((settings) => { - setNotchEnabled(settings.enabled); - setNotchPresentation({ - revealMode: settings.revealMode, - expandedPanelEnabled: settings.expandedPanelEnabled, - }); - }), []); - - const dialogElement = () => - rootRef.current?.querySelector('[role="dialog"]') ?? null; - - const closePopover = (returnFocus: boolean) => { - requestGenerationRef.current += 1; - restoreTriggerFocusRef.current = returnFocus; - setSaving(false); - 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()); - setNotchPresentation(readAttentionNotchPresentation()); - 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 + 1; - requestGenerationRef.current = generation; - 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); - writeAttentionNotchPresentation(notchPresentation); - await window.ade?.attentionNotch?.updateSettings( - attentionNotchSettingsFromPreferences(preferences, notchEnabled, notchPresentation), - ); - if (!isCurrentRequest()) return; - if (notchEnabled) { - const health = await window.ade?.attentionNotch?.getHealth?.(); - if ( - health - && health.state !== "running" - && health.state !== "starting" - ) { - throw new Error(`${health.title}. ${health.message}`); - } - } - setSaved(true); - window.setTimeout(() => { - if (isCurrentRequest()) 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 - Account delivery and this Mac’s notch - -
- Account -
- - {loading ? ( -
- - Loading your preferences… -
- ) : ( - <> -
-

Quick toggles

- - - updateAccount({ notificationsEnabled })} - /> - updateAccount({ soundsEnabled })} - /> - {/* - The full model — per-event delivery policies, quiet hours, - escalation, previews, celebrations — lives in Settings. - Keeping the popover to three toggles stops the two surfaces - drifting apart the way they did before. - */} - {/* - Routed through the app navigation bus rather than - `useNavigate`: the attention subtree is mounted outside the - router in tests and must not take a Router dependency. - */} - -
- - )} - - {error ? ( -
- - {error} -
- ) : null} - -
- - {saved - ? <> Saved - : "Delivery syncs; notch choices stay on this Mac"} - - - -
-
- ) : null} -
-
- ); -} diff --git a/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.css b/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.css deleted file mode 100644 index 019f9ab77..000000000 --- a/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.css +++ /dev/null @@ -1,542 +0,0 @@ -/* The global-header Attention control. It borrows the Attention center's tone - system so a phase reads the same colour in the header as it does in the full - surface, but keeps its own compact type scale: this lives in a 28px header, - not a page. Every colour resolves through theme tokens so light mode is a - token swap rather than a second stylesheet. */ - -.attn-hdr-trigger, -.attn-hdr-panel { - --tone-color: #a1a1aa; - --attn-hdr-fs-2xs: 9.5px; - --attn-hdr-fs-xs: 10.5px; - --attn-hdr-fs-sm: 11.5px; - --attn-hdr-fs-md: 12.5px; - --attn-hdr-surface: color-mix(in srgb, var(--color-card) 92%, var(--color-bg)); - --attn-hdr-hairline: color-mix(in srgb, var(--color-border) 70%, transparent); - --attn-hdr-shadow: 0 28px 70px -30px rgba(0, 0, 0, 0.8); -} - -.attn-hdr-trigger.attention-tone-amber, -.attn-hdr-panel .attention-tone-amber { --tone-color: #fbbf24; } -.attn-hdr-trigger.attention-tone-red, -.attn-hdr-panel .attention-tone-red { --tone-color: #f87171; } -.attn-hdr-trigger.attention-tone-violet, -.attn-hdr-panel .attention-tone-violet { --tone-color: #a78bfa; } -.attn-hdr-trigger.attention-tone-blue, -.attn-hdr-panel .attention-tone-blue { --tone-color: #60a5fa; } -.attn-hdr-trigger.attention-tone-cyan, -.attn-hdr-panel .attention-tone-cyan { --tone-color: #22d3ee; } -.attn-hdr-trigger.attention-tone-emerald, -.attn-hdr-panel .attention-tone-emerald { --tone-color: #34d399; } -.attn-hdr-trigger.attention-tone-neutral, -.attn-hdr-panel .attention-tone-neutral { --tone-color: #a1a1aa; } - -/* 400-level tones sit near 1.7:1 on a white card; light mode uses the 600/700 - equivalents so pills and dots stay legible instead of washing out. */ -[data-theme="light"] .attn-hdr-trigger, -[data-theme="light"] .attn-hdr-panel { - --attn-hdr-shadow: 0 22px 55px -26px rgba(15, 23, 42, 0.3); -} -[data-theme="light"] .attn-hdr-trigger.attention-tone-amber, -[data-theme="light"] .attn-hdr-panel .attention-tone-amber { --tone-color: #b45309; } -[data-theme="light"] .attn-hdr-trigger.attention-tone-red, -[data-theme="light"] .attn-hdr-panel .attention-tone-red { --tone-color: #dc2626; } -[data-theme="light"] .attn-hdr-trigger.attention-tone-violet, -[data-theme="light"] .attn-hdr-panel .attention-tone-violet { --tone-color: #6d28d9; } -[data-theme="light"] .attn-hdr-trigger.attention-tone-blue, -[data-theme="light"] .attn-hdr-panel .attention-tone-blue { --tone-color: #1d4ed8; } -[data-theme="light"] .attn-hdr-trigger.attention-tone-cyan, -[data-theme="light"] .attn-hdr-panel .attention-tone-cyan { --tone-color: #0e7490; } -[data-theme="light"] .attn-hdr-trigger.attention-tone-emerald, -[data-theme="light"] .attn-hdr-panel .attention-tone-emerald { --tone-color: #047857; } -[data-theme="light"] .attn-hdr-trigger.attention-tone-neutral, -[data-theme="light"] .attn-hdr-panel .attention-tone-neutral { --tone-color: #52525b; } - -/* ---- trigger ---------------------------------------------------------- */ - -.attn-hdr-trigger { - height: 22px; - transition: - background-color 150ms ease, - border-color 150ms ease, - box-shadow 150ms ease, - color 150ms ease; -} - -.attn-hdr-trigger-icon { - color: var(--color-muted-fg); - transition: color 150ms ease; -} - -.attn-hdr-trigger[data-state="waiting"] { - border-color: color-mix(in srgb, var(--tone-color) 45%, transparent); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--tone-color) 16%, transparent); -} - -/* The one amber in this file that is not a phase tone, and it earns it: a - degraded surface always carries an `availability.recovery` the user has to - perform (retry, sign in, update or restart the host). It is literally "your - move", which is the only meaning amber is allowed to carry — see the one-hue - rule in apps/desktop/src/shared/sessionStatusPresentation.ts. It cannot be - confused with a phase tone either: `data-state` is single-valued and - `degraded` outranks `waiting`, so the trigger paints this amber instead of — - never alongside — the leading bucket's colour. */ -.attn-hdr-trigger[data-state="degraded"] { - border-color: color-mix(in srgb, #f59e0b 42%, transparent); - box-shadow: 0 0 0 1px color-mix(in srgb, #f59e0b 13%, transparent); -} - -.attn-hdr-trigger[data-state="degraded"] .attn-hdr-trigger-icon { - color: #f59e0b; -} - -.attn-hdr-trigger[data-state="waiting"] .attn-hdr-trigger-icon, -.attn-hdr-trigger[data-state="live"] .attn-hdr-trigger-icon { - color: var(--tone-color); -} - -.attn-hdr-trigger[data-state="signed-out"] { - opacity: 0.75; -} - -.attn-hdr-trigger-count { - display: inline-flex; - min-width: 14px; - align-items: center; - justify-content: center; - padding: 0 3px; - border-radius: 999px; - background: var(--tone-color); - color: var(--color-bg); - font-family: var(--font-mono); - font-size: var(--attn-hdr-fs-2xs); - font-weight: 800; - line-height: 14px; - font-variant-numeric: tabular-nums; -} - -.attn-hdr-trigger-live { - width: 6px; - height: 6px; - border-radius: 999px; - background: var(--tone-color); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--tone-color) 18%, transparent); - animation: attn-hdr-pulse 2.4s ease-in-out infinite; -} - -@keyframes attn-hdr-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.42; } -} - -/* ---- popover ---------------------------------------------------------- */ - -.attn-hdr-panel { - position: absolute; - right: 12px; - top: 40px; - display: flex; - width: min(400px, calc(100vw - 24px)); - max-height: min(560px, calc(100vh - 72px)); - flex-direction: column; - overflow: hidden; - border: 1px solid var(--attn-hdr-hairline); - border-radius: 14px; - background: var(--attn-hdr-surface); - box-shadow: var(--attn-hdr-shadow); - color: var(--color-fg); - animation: attn-hdr-enter 140ms ease-out; -} - -@keyframes attn-hdr-enter { - from { opacity: 0; transform: translateY(-6px) scale(0.985); } - to { opacity: 1; transform: translateY(0) scale(1); } -} - -.attn-hdr-panel:focus-visible { - outline: none; -} - -.attn-hdr-panel-head { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 10px 10px 13px; - border-bottom: 1px solid var(--attn-hdr-hairline); -} - -.attn-hdr-panel-head h2 { - margin: 0; - font-size: var(--attn-hdr-fs-md); - font-weight: 650; - letter-spacing: -0.01em; -} - -.attn-hdr-panel-head p { - margin: 1px 0 0; - font-size: var(--attn-hdr-fs-xs); - color: var(--color-muted-fg); -} - -.attn-hdr-freshness { - display: inline-flex; - flex-shrink: 0; - align-items: center; - gap: 4px; - padding: 3px 7px; - border: 1px solid var(--attn-hdr-hairline); - border-radius: 999px; - background: color-mix(in srgb, var(--color-card) 60%, transparent); - color: var(--color-muted-fg); - font-size: var(--attn-hdr-fs-2xs); - font-weight: 600; -} - -button.attn-hdr-freshness { - cursor: pointer; -} - -.attn-hdr-freshness.is-error { - border-color: color-mix(in srgb, var(--color-error, #ef4444) 45%, transparent); - color: var(--color-error, #ef4444); -} - -.attn-hdr-spin { - animation: attn-hdr-spin 1.1s linear infinite; -} - -@keyframes attn-hdr-spin { - to { transform: rotate(360deg); } -} - -.attn-hdr-icon-button { - display: inline-flex; - height: 22px; - width: 22px; - flex-shrink: 0; - align-items: center; - justify-content: center; - border-radius: 7px; - color: var(--color-muted-fg); - transition: background-color 120ms ease, color 120ms ease; -} - -.attn-hdr-icon-button:hover { - background: color-mix(in srgb, var(--color-fg) 8%, transparent); - color: var(--color-fg); -} - -.attn-hdr-alert, -.attn-hdr-note { - display: flex; - align-items: flex-start; - gap: 7px; - padding: 8px 13px; - font-size: var(--attn-hdr-fs-xs); - line-height: 1.45; - border-bottom: 1px solid var(--attn-hdr-hairline); -} - -.attn-hdr-alert { - color: var(--color-error, #ef4444); - background: color-mix(in srgb, var(--color-error, #ef4444) 10%, transparent); -} - -.attn-hdr-note { - color: var(--color-muted-fg); - background: color-mix(in srgb, var(--color-fg) 4%, transparent); -} - -.attn-hdr-notch-health span { - flex: 1; -} - -.attn-hdr-notch-health button { - flex: 0 0 auto; - color: var(--color-accent); - font-weight: 650; -} - -.attn-hdr-notch-health button:hover, -.attn-hdr-notch-health button:focus-visible { - text-decoration: underline; - outline: none; -} - -.attn-hdr-body { - display: flex; - min-height: 0; - flex: 1; - flex-direction: column; - gap: 2px; - overflow-y: auto; - padding: 6px; -} - -/* ---- sections and rows ------------------------------------------------ */ - -.attn-hdr-section { - display: flex; - flex-direction: column; - gap: 1px; -} - -.attn-hdr-section-heading { - display: flex; - align-items: center; - gap: 6px; - margin: 0; - padding: 7px 7px 4px; - font-size: var(--attn-hdr-fs-2xs); - font-weight: 700; - letter-spacing: 0.07em; - text-transform: uppercase; - color: var(--color-muted-fg); -} - -.attn-hdr-section-dot { - width: 6px; - height: 6px; - flex-shrink: 0; - border-radius: 999px; - background: var(--tone-color); -} - -.attn-hdr-section-count { - font-family: var(--font-mono); - font-size: var(--attn-hdr-fs-2xs); - font-variant-numeric: tabular-nums; - color: var(--tone-color); -} - -.attn-hdr-row { - position: relative; - display: flex; - width: 100%; - align-items: flex-start; - gap: 8px; - padding: 7px 8px; - border-radius: 9px; - text-align: left; - transition: background-color 120ms ease, box-shadow 120ms ease; -} - -.attn-hdr-row::before { - content: ""; - position: absolute; - left: 0; - top: 8px; - bottom: 8px; - width: 2px; - border-radius: 999px; - background: var(--tone-color); - opacity: 0; - transition: opacity 120ms ease; -} - -.attn-hdr-row:hover, -.attn-hdr-row:focus-visible { - background: color-mix(in srgb, var(--color-fg) 6%, transparent); - outline: none; -} - -.attn-hdr-row:hover::before, -.attn-hdr-row:focus-visible::before { - opacity: 1; -} - -.attn-hdr-row:focus-visible { - box-shadow: 0 0 0 1px color-mix(in srgb, var(--tone-color) 55%, transparent); -} - -.attn-hdr-row-icon { - display: inline-flex; - height: 20px; - width: 20px; - flex-shrink: 0; - align-items: center; - justify-content: center; - border-radius: 6px; - background: color-mix(in srgb, var(--tone-color) 13%, transparent); - color: var(--tone-color); -} - -.attn-hdr-row-copy { - display: flex; - min-width: 0; - flex: 1; - flex-direction: column; - gap: 2px; -} - -.attn-hdr-row-title { - display: flex; - align-items: baseline; - gap: 8px; -} - -.attn-hdr-row-title strong { - min-width: 0; - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: var(--attn-hdr-fs-sm); - font-weight: 600; - color: var(--color-fg); -} - -.attn-hdr-row-title time { - flex-shrink: 0; - font-family: var(--font-mono); - font-size: var(--attn-hdr-fs-2xs); - color: color-mix(in srgb, var(--color-muted-fg) 85%, transparent); -} - -.attn-hdr-row-meta { - display: flex; - min-width: 0; - align-items: center; - gap: 6px; - font-size: var(--attn-hdr-fs-xs); - color: var(--color-muted-fg); -} - -.attn-hdr-phase { - display: inline-flex; - flex-shrink: 0; - align-items: center; - gap: 4px; - color: var(--tone-color); - font-weight: 600; -} - -.attn-hdr-phase-dot { - width: 5px; - height: 5px; - border-radius: 999px; - background: currentColor; -} - -.attn-hdr-phase-dot.is-active { - animation: attn-hdr-pulse 2.4s ease-in-out infinite; -} - -.attn-hdr-row-where { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.attn-hdr-unseen { - margin-top: 6px; - width: 6px; - height: 6px; - flex-shrink: 0; - border-radius: 999px; - background: var(--tone-color); -} - -.attn-hdr-overflow { - display: inline-flex; - align-items: center; - gap: 4px; - align-self: flex-start; - margin: 2px 0 4px 36px; - padding: 2px 4px; - border-radius: 6px; - font-size: var(--attn-hdr-fs-xs); - font-weight: 600; - color: var(--color-muted-fg); - transition: color 120ms ease, background-color 120ms ease; -} - -.attn-hdr-overflow:hover, -.attn-hdr-overflow:focus-visible { - color: var(--color-fg); - background: color-mix(in srgb, var(--color-fg) 6%, transparent); - outline: none; -} - -/* ---- empty and footer ------------------------------------------------- */ - -.attn-hdr-empty { - display: flex; - flex-direction: column; - align-items: center; - gap: 5px; - padding: 30px 26px 34px; - text-align: center; - color: var(--color-muted-fg); -} - -.attn-hdr-empty strong { - font-size: var(--attn-hdr-fs-md); - font-weight: 650; - color: var(--color-fg); -} - -.attn-hdr-empty p { - margin: 0; - max-width: 30ch; - font-size: var(--attn-hdr-fs-xs); - line-height: 1.5; -} - -.attn-hdr-panel-foot { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 9px 8px 13px; - border-top: 1px solid var(--attn-hdr-hairline); - font-size: var(--attn-hdr-fs-xs); - color: var(--color-muted-fg); - font-variant-numeric: tabular-nums; -} - -.attn-hdr-panel-foot > span { - min-width: 0; - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.attn-hdr-open-all { - display: inline-flex; - flex-shrink: 0; - align-items: center; - gap: 5px; - padding: 4px 9px; - border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent); - border-radius: 8px; - background: color-mix(in srgb, var(--color-accent) 15%, transparent); - color: var(--color-accent); - font-size: var(--attn-hdr-fs-xs); - font-weight: 650; - transition: background-color 120ms ease, border-color 120ms ease; -} - -.attn-hdr-open-all:hover, -.attn-hdr-open-all:focus-visible { - background: color-mix(in srgb, var(--color-accent) 24%, transparent); - border-color: color-mix(in srgb, var(--color-accent) 55%, transparent); - outline: none; -} - -@media (prefers-reduced-motion: reduce) { - .attn-hdr-panel, - .attn-hdr-panel *, - .attn-hdr-trigger, - .attn-hdr-trigger * { - animation-duration: 0.001ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.001ms !important; - } - - .attn-hdr-trigger-live, - .attn-hdr-phase-dot.is-active { - animation: none; - } -} diff --git a/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.test.tsx b/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.test.tsx deleted file mode 100644 index ce44e6064..000000000 --- a/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.test.tsx +++ /dev/null @@ -1,518 +0,0 @@ -// @vitest-environment jsdom - -import React from "react"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { - ATTENTION_CONTRACT_VERSION, - type AttentionItem, - type AttentionPhase, -} from "../../../shared/types"; -import { - attentionStore, - resetAttentionStoreForTests, -} from "../../state/attentionStore"; -import { publishAccountStatus, SIGNED_OUT_ACCOUNT } from "../../lib/account"; -import { HeaderAttentionControl } from "./HeaderAttentionControl"; -import { - attentionHeaderTriggerLabel, - summarizeAttentionForHeader, -} from "./attentionHeaderSummary"; - -const originalAde = window.ade; -const NOW = Date.parse("2026-07-29T12:00:00.000Z"); -const signedInAccount = { - signedIn: true as const, - userId: "account-a", - email: null, - name: null, - expiresAt: null, - provider: null, - imageUrl: null, -}; - -let openItem: ReturnType; -let acknowledge: ReturnType; -let getSnapshot: ReturnType; -let captureAnalytics: ReturnType; - -beforeEach(() => { - publishAccountStatus(signedInAccount); - openItem = vi.fn(async () => {}); - acknowledge = vi.fn(async () => {}); - captureAnalytics = vi.fn(async () => ({ accepted: true, reason: "accepted" })); - getSnapshot = vi.fn(async () => ({ - contractVersion: ATTENTION_CONTRACT_VERSION, - revision: attentionStore.getState().revision, - generatedAt: "2026-07-29T12:00:00.000Z", - items: Object.values(attentionStore.getState().itemsById), - })); - Object.defineProperty(window, "ade", { - configurable: true, - writable: true, - value: { - ...(originalAde ?? {}), - account: { - ...(originalAde?.account ?? {}), - status: vi.fn(async () => signedInAccount), - }, - attention: { openItem, acknowledge, getSnapshot }, - analytics: { - capture: captureAnalytics, - }, - }, - }); -}); - -afterEach(() => { - cleanup(); - resetAttentionStoreForTests(); - publishAccountStatus(SIGNED_OUT_ACCOUNT); - Object.defineProperty(window, "ade", { - configurable: true, - writable: true, - value: originalAde, - }); -}); - -function item( - id: string, - phase: AttentionPhase, - patch: Partial = {}, -): AttentionItem { - return { - contractVersion: ATTENTION_CONTRACT_VERSION, - id, - revision: 1, - fingerprint: `fingerprint-${id}`, - kind: "agent", - eventKind: "agent_needs_you", - phase, - machine: { - machineKey: "studio", - name: "Studio Mac", - online: true, - lastSeenAt: "2026-07-29T11:59:00.000Z", - }, - project: { projectId: "ade", name: "ADE", rootPath: "/repo/ade" }, - provider: "codex", - model: "GPT-5", - title: `Task ${id}`, - preview: "preview", - privacyPreview: "private preview", - destination: { kind: "session", sessionId: `session-${id}` }, - actions: [], - occurredAt: "2026-07-29T11:58:00.000Z", - updatedAt: "2026-07-29T11:58:00.000Z", - seenAt: null, - dismissedAt: null, - expiresAt: null, - ...patch, - }; -} - -function seedItems(items: AttentionItem[]): void { - attentionStore.setState({ - itemsById: Object.fromEntries(items.map((entry) => [entry.id, entry])), - generatedAt: "2026-07-29T12:00:00.000Z", - syncStatus: "ready", - }); -} - -function byId(items: AttentionItem[]): Record { - return Object.fromEntries(items.map((entry) => [entry.id, entry])); -} - -function renderControl(onOpenCenter = vi.fn()) { - render(); - return onOpenCenter; -} - -describe("HeaderAttentionControl", () => { - it("badges only work waiting on you and records a bounded header open", async () => { - seedItems([item("a", "needs_you"), item("b", "running"), item("c", "merge_ready")]); - renderControl(); - - const trigger = screen.getByTestId("header-attention-trigger"); - expect(trigger.textContent).toContain("2"); - expect(trigger.getAttribute("aria-label")).toBe( - "Attention · 1 needs you · 1 to review · 1 live", - ); - expect(trigger.getAttribute("data-state")).toBe("waiting"); - fireEvent.click(trigger); - await waitFor(() => { - expect(captureAnalytics).toHaveBeenCalledWith({ - event: "ade_feature_used", - properties: { - feature: "attention", - action: "header_opened", - outcome: "opened", - source: "renderer_route", - }, - dedupeKey: "attention_header_opened", - minimumIntervalMs: 60 * 60_000, - }); - }); - }); - - it("shows a live pulse without a count when nothing is waiting", () => { - seedItems([item("b", "running")]); - renderControl(); - - const trigger = screen.getByTestId("header-attention-trigger"); - expect(trigger.getAttribute("data-state")).toBe("live"); - expect(trigger.textContent).toBe(""); - expect(trigger.getAttribute("aria-label")).toBe("Attention · 1 live"); - }); - - it("groups the popover across machines and projects", () => { - seedItems([ - item("a", "needs_you"), - item("b", "failed", { - machine: { - machineKey: "laptop", - name: "Laptop", - online: false, - lastSeenAt: "2026-07-29T10:00:00.000Z", - }, - project: { projectId: "web", name: "Web", rootPath: "/repo/web" }, - }), - item("c", "running"), - item("d", "completed"), - ]); - renderControl(); - - fireEvent.click(screen.getByTestId("header-attention-trigger")); - - const dialog = screen.getByRole("dialog", { name: "Attention" }); - expect(attentionStore.getState().headerSurfaceVisible).toBe(true); - expect(dialog).toBeTruthy(); - expect(screen.getByRole("heading", { name: /Needs you/ })).toBeTruthy(); - expect(screen.getByRole("heading", { name: /Failing or blocked/ })).toBeTruthy(); - expect(screen.getByRole("heading", { name: /Done, unreviewed/ })).toBeTruthy(); - expect(screen.getByRole("heading", { name: /Live now/ })).toBeTruthy(); - expect(screen.getByText("1 of 2 machines online")).toBeTruthy(); - expect(screen.getByText("Web · Laptop (offline)")).toBeTruthy(); - expect(dialog.textContent).toContain("last-known state from an offline machine"); - }); - - it("opens the exact destination through the attention bridge, then marks it seen", async () => { - seedItems([item("a", "needs_you")]); - renderControl(); - - fireEvent.click(screen.getByTestId("header-attention-trigger")); - fireEvent.click(screen.getByRole("button", { name: /Task a/ })); - - await waitFor(() => - expect(openItem).toHaveBeenCalledWith(expect.objectContaining({ id: "a" })), - ); - await waitFor(() => - expect(acknowledge).toHaveBeenCalledWith( - expect.objectContaining({ itemIds: ["a"] }), - ), - ); - await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - }); - - it("keeps the item unseen and explains a failed navigation", async () => { - seedItems([item("a", "needs_you")]); - openItem.mockRejectedValueOnce(new Error("Studio Mac is offline")); - renderControl(); - - fireEvent.click(screen.getByTestId("header-attention-trigger")); - fireEvent.click(screen.getByRole("button", { name: /Task a/ })); - - await waitFor(() => expect(screen.getByRole("alert").textContent).toContain( - "Studio Mac is offline", - )); - expect(acknowledge).not.toHaveBeenCalled(); - expect(attentionStore.getState().itemsById.a?.seenAt).toBeNull(); - expect(screen.getByRole("dialog")).toBeTruthy(); - }); - - it("hands off to the full center from Open all and from a truncated section", () => { - seedItems([ - item("a", "needs_you"), - item("b", "needs_you"), - item("c", "needs_you"), - item("d", "needs_you"), - item("e", "needs_you"), - ]); - const onOpenCenter = renderControl(); - - fireEvent.click(screen.getByTestId("header-attention-trigger")); - expect(screen.getByText("1 more in Attention")).toBeTruthy(); - - fireEvent.click(screen.getByText("1 more in Attention")); - expect(onOpenCenter).toHaveBeenCalledTimes(1); - expect(screen.queryByRole("dialog")).toBeNull(); - - fireEvent.click(screen.getByTestId("header-attention-trigger")); - fireEvent.click(screen.getByRole("button", { name: /Open all/ })); - expect(onOpenCenter).toHaveBeenCalledTimes(2); - expect(screen.queryByRole("dialog")).toBeNull(); - }); - - it("supports keyboard open, arrow navigation, and Escape returning focus", () => { - seedItems([item("a", "needs_you"), item("b", "needs_you")]); - renderControl(); - - const trigger = screen.getByTestId("header-attention-trigger"); - trigger.focus(); - fireEvent.keyDown(trigger, { key: "ArrowDown" }); - const dialog = screen.getByRole("dialog", { name: "Attention" }); - - fireEvent.keyDown(dialog, { key: "ArrowDown" }); - expect(document.activeElement?.getAttribute("data-attention-header-row")).toBe("a"); - fireEvent.keyDown(dialog, { key: "ArrowDown" }); - expect(document.activeElement?.getAttribute("data-attention-header-row")).toBe("b"); - fireEvent.keyDown(dialog, { key: "ArrowDown" }); - expect(document.activeElement?.getAttribute("data-attention-header-row")).toBe("a"); - fireEvent.keyDown(dialog, { key: "End" }); - expect(document.activeElement?.getAttribute("data-attention-header-row")).toBe("b"); - - fireEvent.keyDown(dialog, { key: "Escape" }); - expect(screen.queryByRole("dialog")).toBeNull(); - expect(document.activeElement).toBe(trigger); - }); - - it("offers a retry instead of pretending a failed sync is current", async () => { - seedItems([item("a", "needs_you")]); - getSnapshot.mockRejectedValue(new Error("Relay unreachable")); - renderControl(); - - fireEvent.click(screen.getByTestId("header-attention-trigger")); - const retry = await screen.findByRole("button", { - name: /Attention is unavailable · Retry/, - }); - expect(getSnapshot).toHaveBeenCalledTimes(1); - - fireEvent.click(retry); - await waitFor(() => expect(getSnapshot).toHaveBeenCalledTimes(2)); - }); - - it("surfaces a missing native notch helper with recovery guidance", async () => { - seedItems([]); - const retry = vi.fn(async () => ({ - state: "missing" as const, - title: "ADE Notch needs reinstalling", - message: "Reinstall or update ADE, then restart the app.", - recovery: "reinstall_or_update" as const, - surface: null, - })); - Object.defineProperty(window, "ade", { - configurable: true, - writable: true, - value: { - ...(window.ade ?? {}), - attentionNotch: { - publishSnapshot: vi.fn(), - updateSettings: vi.fn(), - getHealth: retry, - retry, - onAcknowledgeRequested: vi.fn(() => () => {}), - }, - }, - }); - renderControl(); - - fireEvent.click(screen.getByTestId("header-attention-trigger")); - - expect(await screen.findByText("ADE Notch needs reinstalling")).toBeTruthy(); - expect(screen.getByText(/Reinstall or update ADE/)).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: "Check again" })); - await waitFor(() => expect(retry).toHaveBeenCalledTimes(2)); - }); - - it("stays honest when signed out instead of showing an empty account", () => { - publishAccountStatus(SIGNED_OUT_ACCOUNT); - renderControl(); - - const trigger = screen.getByTestId("header-attention-trigger"); - expect(trigger.getAttribute("data-state")).toBe("signed-out"); - expect(trigger.getAttribute("aria-label")).toBe( - "Attention · sign in to sync across machines", - ); - - fireEvent.click(trigger); - expect( - screen.getByText(/Sign in to ADE to follow agents and pull requests/), - ).toBeTruthy(); - }); - - it("keeps machine-local work visible while signed out", () => { - publishAccountStatus(SIGNED_OUT_ACCOUNT); - seedItems([item("local", "needs_you")]); - attentionStore.setState({ - snapshotScope: "machine", - availability: { - state: "signed_out", - title: "Showing this Mac", - message: "Sign in to combine Attention across every ADE machine.", - recovery: "sign_in", - hostName: "This Mac", - }, - }); - renderControl(); - - const trigger = screen.getByTestId("header-attention-trigger"); - expect(trigger.getAttribute("data-state")).toBe("waiting"); - expect(trigger.textContent).toContain("1"); - expect(trigger.getAttribute("aria-label")).toContain("this machine only"); - - fireEvent.click(trigger); - expect(screen.getByText("Showing this Mac")).toBeTruthy(); - expect(screen.getByRole("heading", { name: /Needs you/ })).toBeTruthy(); - expect( - screen.getByText(/Sign in to combine Attention across every ADE machine/), - ).toBeTruthy(); - }); -}); - -describe("header Attention summary", () => { - it("separates waiting work from ambient live work and leads with urgency", () => { - const summary = summarizeAttentionForHeader( - byId([ - item("needs", "needs_you"), - item("failing", "checks_failing", { kind: "pull_request" }), - item("running", "running"), - item("starting", "starting"), - ]), - NOW, - ); - - expect(summary.waitingCount).toBe(2); - expect(summary.liveCount).toBe(2); - expect(summary.headline).toBe("1 needs you"); - expect(summary.tone).toBe("amber"); - expect(summary.buckets.map((bucket) => bucket.id)).toEqual([ - "needs_you", - "blocked", - "live", - ]); - }); - - it("stops counting reviewed, dismissed, and expired outcomes without losing tracked history", () => { - const summary = summarizeAttentionForHeader( - byId([ - item("reviewed", "completed", { seenAt: "2026-07-29T11:59:00.000Z" }), - item("dismissed", "needs_you", { dismissedAt: "2026-07-29T11:00:00.000Z" }), - item("expired", "needs_you", { expiresAt: "2026-07-29T11:00:00.000Z" }), - item("live", "running"), - ]), - NOW, - ); - - expect(summary.waitingCount).toBe(0); - expect(summary.trackedCount).toBe(2); - expect(summary.buckets.map((bucket) => bucket.id)).toEqual(["live"]); - }); - - it("reports offline ownership and orders each bucket by shared priority", () => { - const summary = summarizeAttentionForHeader( - byId([ - item("older", "failed", { - updatedAt: "2026-07-29T10:00:00.000Z", - machine: { - machineKey: "laptop", - name: "Laptop", - online: false, - lastSeenAt: "2026-07-29T10:00:00.000Z", - }, - }), - item("newer", "failed", { updatedAt: "2026-07-29T11:59:00.000Z" }), - ]), - NOW, - ); - - expect(summary.machinesTotal).toBe(2); - expect(summary.machinesOnline).toBe(1); - expect(summary.staleMachineCount).toBe(1); - expect(summary.buckets[0]?.items.map((entry) => entry.id)).toEqual([ - "newer", - "older", - ]); - }); - - it("enumerates each bucket in the trigger label and stays calm when clear", () => { - const summary = summarizeAttentionForHeader( - byId([ - item("needs", "needs_you"), - item("review", "merge_ready"), - item("live", "running"), - ]), - NOW, - ); - - expect(attentionHeaderTriggerLabel(summary)).toBe( - "Attention · 1 needs you · 1 to review · 1 live", - ); - expect(attentionHeaderTriggerLabel(summarizeAttentionForHeader({}, NOW))) - .toBe("Attention · nothing waiting"); - expect(summarizeAttentionForHeader({}, NOW).tone).toBe("neutral"); - }); - - /** - * The header is the loudest surface ADE has, so amber there has to keep - * meaning exactly one thing. A run that merely finished is an outcome, not a - * request: it may collect in the badge, but it must not colour the bell. - */ - it("lights amber only for work that needs the user, and emerald for finished work", () => { - const doneOnly = summarizeAttentionForHeader( - byId([item("done", "completed"), item("merged", "merged", { kind: "pull_request" })]), - NOW, - ); - - expect(doneOnly.buckets.map((bucket) => bucket.id)).toEqual(["done"]); - expect(doneOnly.tone).toBe("emerald"); - expect(doneOnly.headline).toBe("2 done"); - - const raisedHand = summarizeAttentionForHeader( - byId([item("done", "completed"), item("asks", "needs_you")]), - NOW, - ); - - expect(raisedHand.tone).toBe("amber"); - expect(raisedHand.buckets.map((bucket) => bucket.id)).toEqual(["needs_you", "done"]); - expect(raisedHand.buckets.map((bucket) => bucket.tone)).toEqual(["amber", "emerald"]); - }); - - it("separates an outstanding PR review from work that is simply finished", () => { - const summary = summarizeAttentionForHeader( - byId([ - item("review", "review_requested", { kind: "pull_request" }), - item("done", "completed"), - ]), - NOW, - ); - - expect(summary.buckets.map((bucket) => bucket.id)).toEqual(["review", "done"]); - expect(summary.buckets.map((bucket) => bucket.tone)).toEqual(["violet", "emerald"]); - expect(attentionHeaderTriggerLabel(summary)).toBe("Attention · 1 to review · 1 done"); - }); - - /** - * Stale is a silence, not a signal. It used to share amber with a raised - * hand; it must now neither colour the header nor inflate the badge. - */ - it("keeps stale and already-open work out of every count", () => { - const summary = summarizeAttentionForHeader( - byId([ - item("quiet", "stale"), - item("open", "open", { kind: "pull_request" }), - item("closed", "closed", { kind: "pull_request" }), - ]), - NOW, - ); - - expect(summary.buckets).toEqual([]); - expect(summary.waitingCount).toBe(0); - expect(summary.liveCount).toBe(0); - expect(summary.tone).toBe("neutral"); - expect(summary.headline).toBe("All clear"); - // Still tracked — the full Attention center can show them; the header just - // stays quiet about them. - expect(summary.trackedCount).toBe(3); - }); -}); diff --git a/apps/desktop/src/renderer/components/attention/attentionHeaderSummary.ts b/apps/desktop/src/renderer/components/attention/attentionHeaderSummary.ts deleted file mode 100644 index db6e2d606..000000000 --- a/apps/desktop/src/renderer/components/attention/attentionHeaderSummary.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { - sortAttentionItems, - type AttentionItem, -} from "../../../shared/types"; -import type { AttentionTone } from "./attentionPresentation"; - -/** - * The global header carries one number, so that number has to mean exactly one - * thing: work that is waiting on the person reading it. Live work is real but - * it is not a request, so it rides alongside as an ambient pulse instead of - * inflating the count. Everything the header claims is derived here, once, so - * the trigger, its label, and the popover can never disagree. - */ -export type AttentionHeaderBucketId = - | "needs_you" - | "blocked" - | "review" - | "done" - | "live"; - -export type AttentionHeaderBucket = { - id: AttentionHeaderBucketId; - /** Section heading in the popover. */ - label: string; - /** Screen-reader/tooltip phrasing for one bucket, already pluralized. */ - summary: string; - tone: AttentionTone; - items: AttentionItem[]; -}; - -export type AttentionHeaderSummary = { - /** Non-empty buckets, most urgent first. */ - buckets: AttentionHeaderBucket[]; - /** needs_you + blocked/failing + done-but-unreviewed. Drives the badge. */ - waitingCount: number; - liveCount: number; - /** Every non-expired, non-dismissed item — what "Open all" leads to. */ - trackedCount: number; - /** Waiting items whose machine is offline, i.e. last-known state only. */ - staleMachineCount: number; - tone: AttentionTone; - /** Short truthful phrase: "2 need you", "3 live", "All clear". */ - headline: string; - machinesOnline: number; - machinesTotal: number; -}; - -const BUCKET_ORDER: AttentionHeaderBucketId[] = [ - "needs_you", - "blocked", - "review", - "done", - "live", -]; - -const BUCKET_LABEL: Record = { - needs_you: "Needs you", - blocked: "Failing or blocked", - review: "Waiting on review", - done: "Done, unreviewed", - live: "Live now", -}; - -/** - * Amber appears exactly once in this table, on `needs_you`, and that is the - * whole point: the header is the loudest surface ADE has, so the hue that means - * "your move" must not be shared with anything else. `done` is emerald because - * a finished run you have not looked at yet is an outcome, not a request — - * previously it rode in the same violet bucket as an outstanding PR review, - * which made "go look" and "go review" one indistinguishable colour. - */ -const BUCKET_TONE: Record = { - needs_you: "amber", - blocked: "red", - review: "violet", - done: "emerald", - live: "blue", -}; - -function bucketSummary(id: AttentionHeaderBucketId, count: number): string { - if (id === "needs_you") return `${count} need${count === 1 ? "s" : ""} you`; - if (id === "blocked") return `${count} failing or blocked`; - if (id === "review") return `${count} to review`; - if (id === "done") return `${count} done`; - return `${count} live`; -} - -function isExpired(item: AttentionItem, now: number): boolean { - if (!item.expiresAt) return false; - const expiresAt = Date.parse(item.expiresAt); - return Number.isFinite(expiresAt) && expiresAt <= now; -} - -/** - * Phases the header deliberately stays quiet about: `open`, `stale`, `closed`, - * and outcomes the user already acknowledged. They are neither in motion nor - * asking for anything, so they belong to the full Attention center. - * - * `stale` in particular is a silence, not a signal — it stays out of every - * bucket so it can never inflate the badge. The same holds for a session the - * user stopped: it never reaches the header at all, because a stopped run is an - * outcome the user chose. - * - * The loud tier is exactly `needs_you`. Nothing else may enter it — a resting - * or finished chat lands in `done`, never in the bucket that colours the header - * amber. - */ -export function attentionHeaderBucketFor( - item: AttentionItem, -): AttentionHeaderBucketId | null { - if (item.dismissedAt) return null; - switch (item.phase) { - case "needs_you": - return "needs_you"; - case "blocked": - case "failed": - case "checks_failing": - case "changes_requested": - return "blocked"; - case "review_requested": - case "merge_ready": - return "review"; - case "completed": - case "merged": - return item.seenAt ? null : "done"; - case "starting": - case "running": - return "live"; - default: - return null; - } -} - -export function summarizeAttentionForHeader( - itemsById: Record, - now = Date.now(), -): AttentionHeaderSummary { - const grouped = new Map(); - const machinesOnline = new Set(); - const machinesTotal = new Set(); - let trackedCount = 0; - let staleMachineCount = 0; - - for (const item of Object.values(itemsById)) { - if (isExpired(item, now)) continue; - machinesTotal.add(item.machine.machineKey); - if (item.machine.online) machinesOnline.add(item.machine.machineKey); - if (!item.dismissedAt) trackedCount += 1; - const bucket = attentionHeaderBucketFor(item); - if (!bucket) continue; - const existing = grouped.get(bucket); - if (existing) existing.push(item); - else grouped.set(bucket, [item]); - if (bucket !== "live" && !item.machine.online) staleMachineCount += 1; - } - - const buckets: AttentionHeaderBucket[] = []; - for (const id of BUCKET_ORDER) { - const items = grouped.get(id); - if (!items || items.length === 0) continue; - buckets.push({ - id, - label: BUCKET_LABEL[id], - summary: bucketSummary(id, items.length), - tone: BUCKET_TONE[id], - items: sortAttentionItems(items), - }); - } - - const liveCount = grouped.get("live")?.length ?? 0; - const waitingCount = buckets - .filter((bucket) => bucket.id !== "live") - .reduce((total, bucket) => total + bucket.items.length, 0); - const leading = buckets[0] ?? null; - - return { - buckets, - waitingCount, - liveCount, - trackedCount, - staleMachineCount, - tone: leading?.tone ?? "neutral", - headline: leading ? leading.summary : "All clear", - machinesOnline: machinesOnline.size, - machinesTotal: machinesTotal.size, - }; -} - -/** The tooltip and accessible name for the header trigger. */ -export function attentionHeaderTriggerLabel( - summary: AttentionHeaderSummary, -): string { - if (summary.buckets.length === 0) return "Attention · nothing waiting"; - return `Attention · ${summary.buckets.map((bucket) => bucket.summary).join(" · ")}`; -} diff --git a/apps/desktop/src/renderer/components/attention/attentionNotchLocalSettings.ts b/apps/desktop/src/renderer/components/attention/attentionNotchLocalSettings.ts deleted file mode 100644 index 32bb87d8a..000000000 --- a/apps/desktop/src/renderer/components/attention/attentionNotchLocalSettings.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { - AttentionNotchRevealMode, - AttentionNotchSettings, - AttentionPreferences, -} from "../../../shared/types"; -import { - DEFAULT_ATTENTION_NOTCH_REVEAL_MODE, - DEFAULT_ATTENTION_PREFERENCES, - isAttentionNotchRevealMode, -} from "../../../shared/types"; - -const ATTENTION_NOTCH_ENABLED_KEY = "ade:attention:notch-enabled"; -const ATTENTION_NOTCH_REVEAL_MODE_KEY = "ade:attention:notch-reveal-mode"; -const ATTENTION_NOTCH_EXPANDED_PANEL_KEY = "ade:attention:notch-expanded-panel"; -const ATTENTION_NOTCH_SETTINGS_CHANGED_EVENT = "ade:attention-notch-settings-changed"; - -/** - * How the notch presents itself on *this* Mac. It describes one display's - * chrome, so it stays beside the enabled flag rather than in account - * preferences that follow the user to every machine. - */ -export type AttentionNotchPresentation = { - revealMode: AttentionNotchRevealMode; - expandedPanelEnabled: boolean; -}; - -/** What a Mac that has never been configured gets: today's behaviour. */ -export const DEFAULT_ATTENTION_NOTCH_PRESENTATION: AttentionNotchPresentation = { - revealMode: DEFAULT_ATTENTION_NOTCH_REVEAL_MODE, - expandedPanelEnabled: true, -}; - -function readLocalItem(key: string): string | null { - if (typeof window === "undefined") return null; - try { - return window.localStorage.getItem(key); - } catch { - return null; - } -} - -function writeLocalItem(key: string, value: string): void { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem(key, value); - } catch { - // A restricted renderer still keeps the setting for the current process - // through the native helper update issued by the caller. - } -} - -export function readAttentionNotchEnabled(): boolean { - return readLocalItem(ATTENTION_NOTCH_ENABLED_KEY) !== "false"; -} - -export function writeAttentionNotchEnabled(enabled: boolean): void { - writeLocalItem(ATTENTION_NOTCH_ENABLED_KEY, String(enabled)); -} - -/** A value this build has never heard of falls back to the shipped behaviour. */ -export function readAttentionNotchPresentation(): AttentionNotchPresentation { - const revealMode = readLocalItem(ATTENTION_NOTCH_REVEAL_MODE_KEY); - return { - revealMode: isAttentionNotchRevealMode(revealMode) - ? revealMode - : DEFAULT_ATTENTION_NOTCH_PRESENTATION.revealMode, - expandedPanelEnabled: readLocalItem(ATTENTION_NOTCH_EXPANDED_PANEL_KEY) !== "false", - }; -} - -export function writeAttentionNotchPresentation( - presentation: AttentionNotchPresentation, -): void { - writeLocalItem(ATTENTION_NOTCH_REVEAL_MODE_KEY, presentation.revealMode); - writeLocalItem( - ATTENTION_NOTCH_EXPANDED_PANEL_KEY, - String(presentation.expandedPanelEnabled), - ); -} - -export function persistAttentionNotchSettings( - settings: AttentionNotchSettings, -): void { - writeAttentionNotchEnabled(settings.enabled); - writeAttentionNotchPresentation({ - revealMode: settings.revealMode, - expandedPanelEnabled: settings.expandedPanelEnabled, - }); - if (typeof window !== "undefined") { - window.dispatchEvent(new CustomEvent( - ATTENTION_NOTCH_SETTINGS_CHANGED_EVENT, - { detail: settings }, - )); - } -} - -export function onAttentionNotchSettingsChanged( - callback: (settings: AttentionNotchSettings) => void, -): () => void { - if (typeof window === "undefined") return () => {}; - const listener = (event: Event) => { - if (!(event instanceof CustomEvent)) return; - callback(event.detail as AttentionNotchSettings); - }; - window.addEventListener(ATTENTION_NOTCH_SETTINGS_CHANGED_EVENT, listener); - return () => - window.removeEventListener(ATTENTION_NOTCH_SETTINGS_CHANGED_EVENT, listener); -} - -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(), - presentation: AttentionNotchPresentation = readAttentionNotchPresentation(), -): AttentionNotchSettings { - const normalized = normalizeAttentionPreferences(preferences); - return { - enabled, - revealMode: presentation.revealMode, - expandedPanelEnabled: presentation.expandedPanelEnabled, - preferredDisplayId: null, - hideDetails: normalized.account.hideDetails, - celebrationsEnabled: normalized.account.celebrationsEnabled, - soundsEnabled: normalized.account.soundsEnabled, - }; -} diff --git a/apps/desktop/src/renderer/components/attention/useAttentionSync.ts b/apps/desktop/src/renderer/components/attention/useAttentionSync.ts deleted file mode 100644 index c044818f1..000000000 --- a/apps/desktop/src/renderer/components/attention/useAttentionSync.ts +++ /dev/null @@ -1,521 +0,0 @@ -import { useEffect, useMemo, useRef } from "react"; - -import { - ATTENTION_CONTRACT_VERSION, - type AttentionNotchSettings, - type AttentionPresence, - type AttentionSnapshot, -} from "../../../shared/types"; -import { - acknowledgeAttentionItem, - attentionStore, - useAttentionStore, -} from "../../state/attentionStore"; -import { useAccountStatus } from "../../lib/account"; -import { - attentionNotchSettingsFromPreferences, - persistAttentionNotchSettings, - readAttentionNotchEnabled, - readAttentionNotchPresentation, -} from "./attentionNotchLocalSettings"; - -export { attentionNotchSettingsFromPreferences } from "./attentionNotchLocalSettings"; - -const POLL_INTERVAL_MS = 15_000; -const PRESENCE_INTERVAL_MS = 30_000; -const HIDDEN_PRESENCE_INTERVAL_MS = 120_000; -// This backstop must clear a 15s relay request, one 401 retry, and the 30s -// local-runtime fallback so legitimate host failures retain their real error. -const ATTENTION_SNAPSHOT_TIMEOUT_MS = 75_000; -const NOTCH_SETTINGS_REFRESH_MS = 60_000; -const MAX_VISIBLE_PRESENCE_ITEMS = 64; - -type AttentionAccountScope = { - generation: number; - ownerId: string | null; -}; - -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 unavailableAttentionSnapshot(error: unknown): { - snapshotScope: AttentionSnapshot["scope"]; - availability: NonNullable; -} { - const message = errorMessage(error); - const signedIn = Boolean(attentionAccountOwnerId); - const incompatible = /(?:unsupported|method not found|update .* then restart|needs upgrading)/i - .test(message); - if (incompatible) { - return { - snapshotScope: attentionStore.getState().snapshotScope ?? (signedIn ? "account" : "machine"), - availability: { - state: "incompatible", - title: "Update the connected ADE host", - message: "This host cannot refresh Attention yet. Update ADE, restart its brain, then retry. Last-known work remains available.", - recovery: "update_host", - }, - }; - } - return { - snapshotScope: attentionStore.getState().snapshotScope ?? (signedIn ? "account" : "machine"), - availability: { - state: "degraded", - title: signedIn - ? "Account Attention is reconnecting" - : "This machine’s Attention is unavailable", - message: signedIn - ? "ADE couldn’t refresh the account stream. Last-known work remains available while you retry." - : "ADE couldn’t refresh this machine. Retry to restore live updates.", - recovery: "retry", - }, - }; -} - -function failClosedAttentionNotchSettings(): AttentionNotchSettings { - const presentation = readAttentionNotchPresentation(); - return { - enabled: readAttentionNotchEnabled(), - // Presentation is a layout choice, not a privacy one: keeping the user's - // chosen mode here stops a lost account load from re-covering the menu bar. - revealMode: presentation.revealMode, - expandedPanelEnabled: presentation.expandedPanelEnabled, - 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"); - let timeoutId: ReturnType | null = null; - const snapshotPromise = Promise.resolve().then(() => api.getSnapshot( - attentionStore.getState().revision, - attentionStore.getState().streamId, - )); - const timeoutPromise = new Promise((_resolve, reject) => { - timeoutId = setTimeout(() => { - reject(new Error( - "Attention took too long to respond. Retry to restore live updates.", - )); - }, ATTENTION_SNAPSHOT_TIMEOUT_MS); - }); - const promise = Promise.race([snapshotPromise, timeoutPromise]) - .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.setState(unavailableAttentionSnapshot(error)); - attentionStore.getState().setSyncStatus("error", errorMessage(error)); - }) - .finally(() => { - if (timeoutId !== null) { - clearTimeout(timeoutId); - timeoutId = null; - } - if (refreshPromise?.promise === promise) refreshPromise = null; - }); - refreshPromise = { generation, promise }; - return promise; -} - -export function materializeAttentionNotchSnapshot(): AttentionSnapshot { - const state = attentionStore.getState(); - return { - contractVersion: ATTENTION_CONTRACT_VERSION, - scope: state.snapshotScope ?? (attentionAccountOwnerId ? "account" : "machine"), - availability: state.availability ?? { - state: attentionAccountOwnerId ? "ready" : "signed_out", - title: attentionAccountOwnerId ? "Account Attention" : "This machine only", - message: attentionAccountOwnerId - ? "Live across your ADE account." - : "Sign in to combine Attention across every ADE machine.", - recovery: attentionAccountOwnerId ? null : "sign_in", - }, - 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.scope ?? null, - snapshot.availability ?? null, - 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 (!scope.ownerId) 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; - if (scope.ownerId) 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; - let nativeSurfaceVisible = false; - try { - const health = await window.ade?.attentionNotch?.getHealth?.(); - nativeSurfaceVisible = health?.state === "running" && health.surface != null; - } catch { - // Presence remains useful when the optional native helper cannot report. - } - const effectiveSurfaceVisible = ambientSurfaceVisible || nativeSurfaceVisible; - const identity = await resolveDesktopIdentity(); - await api.reportPresence({ - ...identity, - platform: desktopPlatform(), - appForeground: foreground, - ambientSurfaceVisible: effectiveSurfaceVisible, - visibleItemIds: effectiveSurfaceVisible - ? 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(routeSurfaceVisible: boolean): void { - const { status: accountStatus, loading: accountLoading } = useAccountStatus(); - const accountUserId = accountStatus.signedIn ? accountStatus.userId : null; - const itemsById = useAttentionStore((state) => state.itemsById); - const headerSurfaceVisible = useAttentionStore((state) => state.headerSurfaceVisible); - const visibleItemIds = useMemo( - () => Object.keys(itemsById), - [itemsById], - ); - const visibleItemIdsKey = visibleItemIds.join("\u001f"); - const ambientSurfaceVisible = routeSurfaceVisible || headerSurfaceVisible; - 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(); - } - 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); - }) ?? (() => {}); - const removeNotchRefreshListener = - window.ade?.attentionNotch?.onRefreshRequested?.((request) => { - if (request?.force !== true && document.visibilityState === "visible") return; - void refreshAttentionSnapshot(); - }) ?? (() => {}); - const removeNotchSettingsListener = - window.ade?.attentionNotch?.onSettingsChanged?.((settings) => { - persistAttentionNotchSettings(settings); - }) ?? (() => {}); - 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(); - }; - document.addEventListener("visibilitychange", onVisibilityChange); - return () => { - active = false; - window.clearInterval(interval); - document.removeEventListener("visibilitychange", onVisibilityChange); - removeNotchAcknowledgeListener(); - removeNotchRefreshListener(); - removeNotchSettingsListener(); - unsubscribe(); - }; - }, [accountLoading, accountUserId]); - - useEffect(() => { - if (accountLoading || !accountUserId) return; - const send = () => { - void reportPresence( - ambientSurfaceVisibleRef.current, - visibleItemIdsRef.current, - foregroundRef.current, - ).catch(() => {}); - }; - let timer: number | null = null; - const schedule = () => { - if (timer !== null) window.clearTimeout(timer); - const delay = document.visibilityState === "visible" - ? PRESENCE_INTERVAL_MS - : HIDDEN_PRESENCE_INTERVAL_MS; - timer = window.setTimeout(() => { - timer = null; - send(); - schedule(); - }, delay); - }; - send(); - schedule(); - const onVisibilityChange = () => { - // Coming back reports at once: presence is how other devices learn this - // machine is being watched, and a 120s-stale "hidden" claim right as the - // user returns is the one case that misleads. Going hidden waits — `blur` - // has already reported the foreground change. - if (document.visibilityState === "visible") send(); - schedule(); - }; - const onFocus = () => { - foregroundRef.current = true; - send(); - }; - const onBlur = () => { - foregroundRef.current = false; - send(); - }; - document.addEventListener("visibilitychange", onVisibilityChange); - window.addEventListener("focus", onFocus); - window.addEventListener("blur", onBlur); - return () => { - if (timer !== null) window.clearTimeout(timer); - document.removeEventListener("visibilitychange", onVisibilityChange); - 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/components/chat/ChatComputerUsePanel.tsx b/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx index bad6bd40f..f14603681 100644 --- a/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx @@ -451,7 +451,7 @@ export function ChatProofTimeline({ /** * A drawer tile. The rail is ~322px wide, so this is a two-up thumbnail with * the label underneath rather than the full timeline card at reduced height — - * that layout truncated every title to "ADE Attention Cen…" and collapsed the + * that layout truncated every title to "ADE Activity…" and collapsed the * metadata line to "screens… · 7h ago". */ function DrawerProofTile({ diff --git a/apps/desktop/src/renderer/components/settings/ActivitySection.test.tsx b/apps/desktop/src/renderer/components/settings/ActivitySection.test.tsx new file mode 100644 index 000000000..21fafad56 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/ActivitySection.test.tsx @@ -0,0 +1,185 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { + ATTENTION_CONTRACT_VERSION, + DEFAULT_ATTENTION_PREFERENCES, + type AttentionItem, +} from "../../../shared/types/attention"; +import { + activityStore, + resetActivityStoreForTests, +} from "../../state/activityStore"; +import { ActivitySection } from "./ActivitySection"; +import { settingsEntriesForTab } from "./settingsManifest"; + +vi.mock("../../lib/account", () => ({ + useAccountStatus: () => ({ status: { signedIn: true, userId: "user-1" } }), +})); + +function installAdeMock() { + const getPreferences = vi.fn(async () => DEFAULT_ATTENTION_PREFERENCES); + const putPreferences = vi.fn(async (_ownerId: string, _prefs: unknown) => {}); + const putMachinePreferences = vi.fn( + async (_ownerId: string, _machineKey: string, _partial: unknown) => {}, + ); + const updateSettings = vi.fn(async (_settings: unknown) => {}); + (window as unknown as { ade: unknown }).ade = { + attention: { getPreferences, putPreferences, putMachinePreferences }, + attentionNotch: { updateSettings }, + }; + return { getPreferences, putPreferences, putMachinePreferences, updateSettings }; +} + +function machineItem(machineKey: string, name: string, online = true): AttentionItem { + return { + contractVersion: ATTENTION_CONTRACT_VERSION, + id: `item-${machineKey}`, + revision: 1, + fingerprint: `fingerprint-${machineKey}`, + kind: "agent", + eventKind: "agent_running", + phase: "running", + machine: { machineKey, name, online, lastSeenAt: null }, + project: { projectId: "ade", name: "ADE", rootPath: "/repo/ade" }, + title: `Task on ${name}`, + preview: "", + privacyPreview: "", + destination: { kind: "session", sessionId: `session-${machineKey}` }, + actions: [], + occurredAt: "2026-07-28T14:00:00.000Z", + updatedAt: "2026-07-28T14:00:00.000Z", + seenAt: null, + dismissedAt: null, + expiresAt: null, + }; +} + +beforeEach(() => { + window.localStorage.clear(); +}); + +afterEach(() => { + cleanup(); + resetActivityStoreForTests(); + delete (window as unknown as { ade?: unknown }).ade; +}); + +describe("ActivitySection", () => { + it("renders every anchor the settings manifest promises for this tab", async () => { + installAdeMock(); + const { container } = render(); + await screen.findByText("ADE notch"); + + // A manifest entry whose anchor is never rendered is an invisible break: + // Cmd-K offers the setting, navigates, and lands on nothing. + const rendered = new Set( + [...container.querySelectorAll("[data-settings-anchor]")] + .map((node) => node.getAttribute("data-settings-anchor")), + ); + for (const entry of settingsEntriesForTab("activity")) { + expect( + rendered, + `manifest promises #${entry.anchor} but nothing renders it`, + ).toContain(entry.anchor); + } + }); + + it("saves without a Save button", async () => { + const { putPreferences } = installAdeMock(); + render(); + await screen.findByText("Celebrations"); + + expect(screen.queryByRole("button", { name: /^save$/i })).toBeNull(); + + fireEvent.click(screen.getByRole("switch", { name: "Celebrations" })); + await waitFor(() => expect(putPreferences).toHaveBeenCalledTimes(1)); + expect(putPreferences.mock.calls[0]![1]).toMatchObject({ + account: { celebrationsEnabled: false }, + }); + }); + + it("writes the account-synced dock badge scope", async () => { + const { putPreferences } = installAdeMock(); + render(); + const control = await screen.findByRole("combobox", { name: "Dock badge counts" }); + + expect((control as HTMLSelectElement).value).toBe("local"); + fireEvent.change(control, { target: { value: "account" } }); + + await waitFor(() => expect(putPreferences).toHaveBeenCalledWith( + "user-1", + expect.objectContaining({ + account: expect.objectContaining({ dockBadgeScope: "account" }), + }), + )); + }); + + it("syncs notch presentation and keeps this Mac's cache in step", async () => { + const { putPreferences, updateSettings } = installAdeMock(); + render(); + await screen.findByText("Notch behavior"); + + const behavior = screen.getByRole("combobox", { name: "Notch behavior" }); + fireEvent.change(behavior, { target: { value: "click" } }); + + await waitFor(() => expect(updateSettings).toHaveBeenCalled()); + expect(updateSettings.mock.calls.at(-1)![0]).toMatchObject({ revealMode: "click" }); + // Synced so a second Mac inherits it… + expect(putPreferences.mock.calls.at(-1)![1]).toMatchObject({ + account: { notchRevealMode: "click" }, + }); + // …and cached locally so an offline launch still opens it the same way. + expect(window.localStorage.getItem("ade:attention:notch-reveal-mode")).toBe("click"); + }); + + it("keeps notch presentation reachable but inert while the notch is off", async () => { + installAdeMock(); + render(); + await screen.findByText("ADE notch"); + + fireEvent.click(screen.getByRole("switch", { name: "ADE notch" })); + + await waitFor(() => { + expect( + (screen.getByRole("combobox", { name: "Notch behavior" }) as HTMLSelectElement).disabled, + ).toBe(true); + }); + // Disabled, not unmounted: the manifest promises this anchor exists, and a + // Cmd-K jump to a card that vanished is a dead end. + expect(screen.getByRole("combobox", { name: "Notch behavior" })).toBeTruthy(); + }); + + it("mutes one machine through the per-machine route", async () => { + const { putMachinePreferences, putPreferences } = installAdeMock(); + activityStore.setState({ + itemsById: { + a: machineItem("studio", "Studio Mac"), + b: machineItem("laptop", "MacBook", false), + }, + }); + render(); + await screen.findByText("Studio Mac"); + + fireEvent.click(screen.getByRole("switch", { name: "Notify me about Studio Mac" })); + + await waitFor(() => expect(putMachinePreferences).toHaveBeenCalledTimes(1)); + expect(putMachinePreferences.mock.calls[0]).toEqual([ + "user-1", + "studio", + { notificationsEnabled: false }, + ]); + // Muting a machine must not rewrite the whole account document. + expect(putPreferences).not.toHaveBeenCalled(); + expect(screen.getByText("Muted — visible in Activity, never notifies.")).toBeTruthy(); + }); + + it("says so when no machine has reported yet", async () => { + installAdeMock(); + render(); + expect(await screen.findByText("No machines have reported Activity yet.")).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/renderer/components/settings/ActivitySection.tsx b/apps/desktop/src/renderer/components/settings/ActivitySection.tsx new file mode 100644 index 000000000..0fb883de3 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/ActivitySection.tsx @@ -0,0 +1,54 @@ +import React from "react"; + +import { COLORS, SANS_FONT } from "../lanes/laneDesignTokens"; +import { SavedFlash } from "./primitives"; +import { + ActivitySettingsControls, + useActivitySettings, +} from "./ActivitySettingsControls"; + +/** + * The Activity settings tab. It owns nothing: every control, every string, and + * every write comes from `ActivitySettingsControls`, which the gear in the + * Activity popover and pane mounts too. That is the whole point — the two + * surfaces cannot say different things about the same setting because they are + * literally the same component. + */ +export function ActivitySection() { + const model = useActivitySettings(); + + return ( +
+ {model.signedOut ? ( +
+ Sign in to ADE to sync Activity across your machines. The notch settings + below still apply to this Mac. +
+ ) : null} + + {model.error ? ( +
+ +
+ ) : model.saved ? ( +
+ +
+ ) : null} + + +
+ ); +} + +export default ActivitySection; diff --git a/apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx b/apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx new file mode 100644 index 000000000..8ba1dcc8c --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx @@ -0,0 +1,804 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Confetti, + DesktopTower, + HourglassMedium, + LockKey, + Notches, + SpeakerHigh, + ArrowsOutSimple, + CursorClick, + Eye, + Waveform, +} from "@phosphor-icons/react"; + +import { + DEFAULT_ATTENTION_PREFERENCES, + type AttentionNotchRevealMode, + type AttentionPreferences, +} from "../../../shared/types"; +import { + activityNotchSupported, + activityNotchSettingsFromPreferences, + activityPreferencesWithNotchPresentation, + normalizeActivityPreferences, + onActivityNotchSettingsChanged, + readActivityNotchEnabled, + resolveActivityNotchPresentation, + writeActivityNotchEnabled, + writeActivityNotchPresentation, + type ActivityNotchPresentation, +} from "../activity/activityNotchLocalSettings"; +import { useAccountStatus } from "../../lib/account"; +import { useActivityStore } from "../../state/activityStore"; +import { SettingsCard, SettingsGroup, SettingsSelect, SettingsToggle } from "./primitives"; +import { COLORS, SANS_FONT } from "../lanes/laneDesignTokens"; + +/** + * Every Activity setting, once. + * + * Two surfaces show these: the gear inside the Activity popover and pane, and + * the Activity settings tab. Before this file they were two hand-maintained + * lists that had already drifted — the popover could turn the notch on with a + * Save button while the settings page saved instantly, and only one of them + * knew about hide-previews. Both now mount this component, so a row can only + * exist in one place: here. + * + * The variants differ in chrome, not in content or behaviour. `popover` renders + * the compact icon rows the header uses and includes the quick toggles whose + * canonical card lives on another tab; `page` renders `SettingsCard`s carrying + * the anchors the settings manifest promises. + */ + +const REVEAL_OPTIONS: { value: AttentionNotchRevealMode; label: string }[] = [ + { value: "minimal", label: "Compact + peek" }, + { value: "hover", label: "Reveal on hover" }, + { value: "click", label: "Click only" }, +]; + +const ESCALATION_OPTIONS = [ + { value: "0", label: "Immediately" }, + { value: "30", label: "After 30 seconds" }, + { value: "120", label: "After 2 minutes" }, + { value: "300", label: "After 5 minutes" }, +]; + +const DOCK_BADGE_SCOPE_OPTIONS: { + value: AttentionPreferences["account"]["dockBadgeScope"]; + label: string; +}[] = [ + { value: "local", label: "This Mac" }, + { value: "account", label: "All machines" }, +]; + +const NOTCH_REVEAL_HELP: Record = { + minimal: "Keep a tiny status visible; hover or click for a short peek.", + hover: "Stay hidden until the pointer reaches the top-edge hot zone.", + click: "Keep the compact status visible and expand only when clicked.", +}; + +export type ActivityMachineOption = { + machineKey: string; + name: string; + online: boolean; +}; + +export type ActivitySettingsModel = ReturnType; + +/** + * Load, hold, and persist every Activity preference. Both surfaces call this, + * so "what does saving mean" has exactly one answer no matter which gear the + * user reached for. + */ +export function useActivitySettings() { + const { status: accountStatus } = useAccountStatus(); + const accountOwnerId = accountStatus.signedIn ? accountStatus.userId : null; + const itemsById = useActivityStore((state) => state.itemsById); + + const [preferences, setPreferences] = useState( + DEFAULT_ATTENTION_PREFERENCES, + ); + const [notchEnabled, setNotchEnabled] = useState(() => readActivityNotchEnabled()); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + const mounted = useRef(true); + const savedTimer = useRef(null); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + if (savedTimer.current != null) window.clearTimeout(savedTimer.current); + }; + }, []); + + // The native context menu can change reveal mode behind the app's back. + useEffect(() => onActivityNotchSettingsChanged((settings) => { + setNotchEnabled(settings.enabled); + setPreferences((current) => activityPreferencesWithNotchPresentation(current, { + revealMode: settings.revealMode, + expandedPanelEnabled: settings.expandedPanelEnabled, + automaticRevealEnabled: settings.automaticRevealEnabled, + tickerEnabled: settings.tickerEnabled, + })); + }), []); + + useEffect(() => { + const api = typeof window !== "undefined" ? window.ade?.attention : null; + if (!api || !accountOwnerId) { + setLoading(false); + return; + } + let cancelled = false; + setLoading(true); + void api.getPreferences(accountOwnerId) + .then((next) => { + if (cancelled || !mounted.current) return; + setPreferences(normalizeActivityPreferences(next)); + setError(null); + }) + .catch((loadError: unknown) => { + if (cancelled || !mounted.current) return; + setError(loadError instanceof Error && loadError.message.trim() + ? loadError.message + : "ADE couldn’t load your Activity settings."); + }) + .finally(() => { + if (!cancelled && mounted.current) setLoading(false); + }); + return () => { cancelled = true; }; + }, [accountOwnerId]); + + const flashSaved = useCallback(() => { + setSaved(true); + if (savedTimer.current != null) window.clearTimeout(savedTimer.current); + savedTimer.current = window.setTimeout(() => { + if (mounted.current) setSaved(false); + }, 1_600); + }, []); + + /** + * Persist an explicit next value. Instant-save controls fire before their own + * state update commits, so reading component state here would save the value + * the user just replaced. + */ + const persist = useCallback(async ( + next: AttentionPreferences, + nextNotchEnabled = notchEnabled, + ) => { + const api = typeof window !== "undefined" ? window.ade?.attention : null; + if (!api || !accountOwnerId) { + setError("Sign in to ADE to change Activity settings."); + return; + } + try { + await api.putPreferences(accountOwnerId, next); + // localStorage stays the offline cache of record for notch presentation: + // a signed-out or offline launch still opens the notch the way this Mac + // last had it rather than snapping back to the shipped default. + const presentation = resolveActivityNotchPresentation(next); + writeActivityNotchEnabled(nextNotchEnabled); + writeActivityNotchPresentation(presentation); + await window.ade?.attentionNotch?.updateSettings( + activityNotchSettingsFromPreferences(next, nextNotchEnabled, presentation), + ); + if (!mounted.current) return; + setError(null); + flashSaved(); + } catch (saveError) { + if (!mounted.current) return; + setError(saveError instanceof Error && saveError.message.trim() + ? saveError.message + : "ADE couldn’t save your Activity settings."); + } + }, [accountOwnerId, flashSaved, notchEnabled]); + + const updateAccount = useCallback((patch: Partial) => { + const next: AttentionPreferences = { + ...preferences, + account: { ...preferences.account, ...patch }, + }; + setPreferences(next); + void persist(next); + }, [persist, preferences]); + + const setNotchPresentation = useCallback((patch: Partial) => { + const next = activityPreferencesWithNotchPresentation(preferences, { + ...resolveActivityNotchPresentation(preferences), + ...patch, + }); + setPreferences(next); + void persist(next); + }, [persist, preferences]); + + const toggleNotchEnabled = useCallback((enabled: boolean) => { + setNotchEnabled(enabled); + void persist(preferences, enabled); + }, [persist, preferences]); + + /** + * Muting a machine is per-machine, not per-account, so it goes through its + * own relay route. It lives in the account scope's `machines` map because the + * web client strips `devices` before saving — a per-device home would appear + * to save on web and quietly not persist. + */ + const setMachineMuted = useCallback(async (machineKey: string, muted: boolean) => { + const notificationsEnabled = !muted; + const next: AttentionPreferences = { + ...preferences, + machines: { + ...preferences.machines, + [machineKey]: { ...preferences.machines[machineKey], notificationsEnabled }, + }, + }; + setPreferences(next); + const api = typeof window !== "undefined" ? window.ade?.attention : null; + if (!api?.putMachinePreferences || !accountOwnerId) { + setError("This ADE build can’t change per-machine notifications yet."); + return; + } + try { + await api.putMachinePreferences(accountOwnerId, machineKey, { notificationsEnabled }); + if (!mounted.current) return; + setError(null); + flashSaved(); + } catch (saveError) { + if (!mounted.current) return; + setPreferences(preferences); + setError(saveError instanceof Error && saveError.message.trim() + ? saveError.message + : "ADE couldn’t change notifications for that machine."); + } + }, [accountOwnerId, flashSaved, preferences]); + + // The roster comes from the snapshot on screen, so it can only ever offer + // machines the account actually has. + const machines = useMemo(() => { + const byKey = new Map(); + for (const item of Object.values(itemsById)) { + const existing = byKey.get(item.machine.machineKey); + if (existing) { + existing.online = existing.online || item.machine.online; + continue; + } + byKey.set(item.machine.machineKey, { + machineKey: item.machine.machineKey, + name: item.machine.name, + online: item.machine.online, + }); + } + return [...byKey.values()].sort((left, right) => left.name.localeCompare(right.name)); + }, [itemsById]); + + const notchPresentation = resolveActivityNotchPresentation(preferences); + + return { + accountOwnerId, + signedOut: !accountOwnerId, + loading, + error, + saved, + preferences, + account: preferences.account, + machines, + notchEnabled, + notchPresentation, + notchSupported: activityNotchSupported(), + updateAccount, + toggleNotchEnabled, + setNotchPresentation, + setMachineMuted, + machineMuted: (machineKey: string) => + preferences.machines[machineKey]?.notificationsEnabled === false, + }; +} + +function PopoverRow({ + icon: Icon, + label, + description, + badge, + disabled, + control, +}: { + icon: React.ElementType; + label: string; + description: string; + badge?: string; + disabled?: boolean; + control: React.ReactNode; +}) { + return ( +
+ + + + + + {label} + {badge ? {badge} : null} + + {description} + + {control} +
+ ); +} + +function PopoverSwitch({ + label, + checked, + disabled, + onChange, +}: { + label: string; + checked: boolean; + disabled?: boolean; + onChange: (checked: boolean) => void; +}) { + return ( + + ); +} + +/** + * The rows themselves. `variant` picks the chrome; the copy, the ordering, and + * every `onChange` come from the one model above. + */ +export function ActivitySettingsControls({ + variant, + model, +}: { + variant: "popover" | "page"; + model: ActivitySettingsModel; +}) { + const { + account, + loading, + signedOut, + machines, + notchEnabled, + notchPresentation, + notchSupported, + updateAccount, + toggleNotchEnabled, + setNotchPresentation, + setMachineMuted, + machineMuted, + } = model; + const busy = loading || signedOut; + + if (variant === "popover") { + return ( + <> + {notchSupported ? ( +
+

This Mac

+ + } + /> + setNotchPresentation({ + revealMode: event.target.value as AttentionNotchRevealMode, + })} + > + {REVEAL_OPTIONS.map((option) => ( + + ))} + + } + /> + + setNotchPresentation({ expandedPanelEnabled })} + /> + } + /> + + setNotchPresentation({ automaticRevealEnabled })} + /> + } + /> + setNotchPresentation({ tickerEnabled })} + /> + } + /> +
+ ) : null} + +
+

Account

+ updateAccount({ + dockBadgeScope: event.target.value as AttentionPreferences["account"]["dockBadgeScope"], + })} + > + {DOCK_BADGE_SCOPE_OPTIONS.map((option) => ( + + ))} + + } + /> + updateAccount({ celebrationsEnabled })} + /> + } + /> + updateAccount({ soundsEnabled })} + /> + } + /> + updateAccount({ hideDetails })} + /> + } + /> + updateAccount({ + desktopFirstDelaySeconds: Number(event.target.value), + })} + > + {ESCALATION_OPTIONS.map((option) => ( + + ))} + + } + /> +
+ + {machines.length > 0 ? ( +
+

Machines

+
+ {machines.map((machine) => ( + + void setMachineMuted(machine.machineKey, !enabled)} + /> + } + /> + ))} +
+
+ ) : null} + + ); + } + + return ( + <> + + + } + /> + setNotchPresentation({ revealMode })} + /> + } + /> + setNotchPresentation({ expandedPanelEnabled })} + /> + } + /> + + setNotchPresentation({ automaticRevealEnabled })} + /> + } + /> + setNotchPresentation({ tickerEnabled })} + /> + } + /> + updateAccount({ celebrationsEnabled })} + /> + } + /> + + + + updateAccount({ soundsEnabled })} + /> + } + /> + + + + updateAccount({ hideDetails })} + /> + } + /> + + + + updateAccount({ dockBadgeScope })} + /> + } + /> + + + + + {machines.length === 0 ? ( +

+ No machines have reported Activity yet. +

+ ) : ( +
+ {machines.map((machine, index) => ( +
+
+
+ {machine.name} +
+
+ {machineMuted(machine.machineKey) + ? "Muted — visible in Activity, never notifies." + : machine.online + ? "Online" + : "Offline"} +
+
+ void setMachineMuted(machine.machineKey, !enabled)} + /> +
+ ))} +
+ )} +
+
+ + ); +} diff --git a/apps/desktop/src/renderer/components/settings/NotificationsSection.test.tsx b/apps/desktop/src/renderer/components/settings/NotificationsSection.test.tsx index 4ed3c756b..96e9f510d 100644 --- a/apps/desktop/src/renderer/components/settings/NotificationsSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/NotificationsSection.test.tsx @@ -11,6 +11,8 @@ vi.mock("../../lib/account", () => ({ useAccountStatus: () => ({ status: { signedIn: true, userId: "user-1" } }), })); +// Notch presentation, celebrations, previews, and per-machine mute moved to +// the Activity tab; their coverage moved with them to ActivitySection.test.tsx. function installAdeMock() { const putPreferences = vi.fn(async (_ownerId: string, _prefs: any) => {}); const getPreferences = vi.fn(async () => DEFAULT_ATTENTION_PREFERENCES); @@ -71,6 +73,9 @@ describe("NotificationsSection", () => { // Other events must be untouched by a single-row edit. expect(saved.account.eventPolicies.agent_needs_you) .toBe(DEFAULT_ATTENTION_PREFERENCES.account.eventPolicies.agent_needs_you); + + expect(screen.getByRole("radiogroup", { name: "PR opened" })).toBeTruthy(); + expect(screen.getByRole("radiogroup", { name: "PR closed" })).toBeTruthy(); }); it("saves without a Save button", async () => { @@ -85,18 +90,6 @@ describe("NotificationsSection", () => { expect(putPreferences.mock.calls[0]![1].account.notificationsEnabled).toBe(false); }); - it("pushes notch presentation to the notch process without touching synced prefs", async () => { - const { putPreferences, updateSettings } = installAdeMock(); - render(); - await screen.findByText("Notify me about"); - - fireEvent.click(screen.getByRole("switch", { name: "Celebrations" })); - - await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1)); - expect(putPreferences).toHaveBeenCalledTimes(1); - expect(updateSettings.mock.calls[0]![0].celebrationsEnabled).toBe(false); - }); - it("reveals quiet-hour times only once quiet hours are on", async () => { installAdeMock(); render(); @@ -106,34 +99,4 @@ describe("NotificationsSection", () => { fireEvent.click(screen.getByRole("switch", { name: "Quiet hours" })); expect(await screen.findByLabelText("From")).toBeTruthy(); }); - it("keeps notch presentation on this Mac and pushes it to the native helper", async () => { - const { putPreferences, updateSettings } = installAdeMock(); - render(); - await screen.findByText("Notify me about"); - - // Moved here from the header popover: reveal mode and the expanded panel - // are machine-local, so they must reach the notch process and localStorage - // without being written into the synced account preferences. - const behavior = await screen.findByRole("combobox", { name: "Notch behavior" }); - fireEvent.change(behavior, { target: { value: "click" } }); - - await waitFor(() => expect(updateSettings).toHaveBeenCalled()); - const pushed = updateSettings.mock.calls.at(-1)![0]; - expect(pushed.revealMode).toBe("click"); - expect(window.localStorage.getItem("ade:attention:notch-reveal-mode")).toBe("click"); - // The synced payload carries no notch presentation. - expect(putPreferences.mock.calls.at(-1)![1]).not.toHaveProperty("revealMode"); - }); - - it("hides notch presentation controls while the notch is off", async () => { - installAdeMock(); - render(); - await screen.findByText("Notify me about"); - - expect(screen.queryByRole("combobox", { name: "Notch behavior" })).toBeTruthy(); - fireEvent.click(screen.getByRole("switch", { name: "ADE notch" })); - await waitFor(() => { - expect(screen.queryByRole("combobox", { name: "Notch behavior" })).toBeNull(); - }); - }); }); diff --git a/apps/desktop/src/renderer/components/settings/NotificationsSection.tsx b/apps/desktop/src/renderer/components/settings/NotificationsSection.tsx index 12321da9d..fd04a834f 100644 --- a/apps/desktop/src/renderer/components/settings/NotificationsSection.tsx +++ b/apps/desktop/src/renderer/components/settings/NotificationsSection.tsx @@ -5,15 +5,8 @@ import { type AttentionEventKind, type AttentionPreferences, } from "../../../shared/types/attention"; -import { - attentionNotchSettingsFromPreferences, - normalizeAttentionPreferences, - readAttentionNotchEnabled, - readAttentionNotchPresentation, - writeAttentionNotchEnabled, - writeAttentionNotchPresentation, - type AttentionNotchPresentation, -} from "../attention/attentionNotchLocalSettings"; +import { ACTIVITY_EVENT_CATALOG } from "../../../shared/activityCatalog"; +import { normalizeActivityPreferences } from "../activity/activityNotchLocalSettings"; import { useAccountStatus } from "../../lib/account"; import { DEFAULT_LANE_BANNER_BUDGET } from "../../../shared/types/config"; import { COLORS, SANS_FONT } from "../lanes/laneDesignTokens"; @@ -37,22 +30,22 @@ import { AgentCompletionSoundSection } from "./AgentCompletionSoundSection"; * in the header, behind a Save button. The per-event policies and quiet hours * had no UI at all despite being fully modelled and honored. * - * This section is the canonical home for that model. The popover stays as a - * quick toggle; both write through `window.ade.attention.putPreferences`. + * This section is the canonical home for delivery: what ADE interrupts you + * for, when, and on which device. The surfaces Activity itself paints — the + * notch, celebrations, previews, per-machine mute — live on the Activity tab + * instead, because that is where the thing they describe lives. */ /** The events worth giving a user a dial for, in the order they'll scan them. */ -const EVENT_ROWS: { kind: AttentionEventKind; label: string; description: string }[] = [ - { kind: "agent_needs_you", label: "Agent asks a question", description: "A run is blocked waiting on your answer." }, - { kind: "agent_failed", label: "Agent fails", description: "A run stopped on an error." }, - { kind: "agent_completed", label: "Agent finishes", description: "A run reached the end of its turn." }, - { kind: "agent_running", label: "Agent starts working", description: "A run picked up your request." }, - { kind: "pr_checks_failing", label: "CI fails", description: "Checks went red on one of your PRs." }, - { kind: "pr_review_requested", label: "Review requested", description: "Someone asked you to review." }, - { kind: "pr_changes_requested", label: "Changes requested", description: "A reviewer asked for changes." }, - { kind: "pr_merge_ready", label: "PR ready to merge", description: "Checks passed and reviews are in." }, - { kind: "pr_merged", label: "PR merged", description: "One of your PRs landed." }, -]; +const EVENT_ROWS: readonly { + kind: AttentionEventKind; + label: string; + description: string; +}[] = ACTIVITY_EVENT_CATALOG.map(({ kind, label, description }) => ({ + kind, + label, + description, +})); const POLICY_OPTIONS: { value: AttentionDeliveryPolicy; label: string; hint: string }[] = [ { value: "off", label: "Off", hint: "Don't track" }, @@ -67,12 +60,6 @@ const ESCALATION_OPTIONS = [ { value: "300", label: "After 5 minutes" }, ]; -const REVEAL_OPTIONS: { value: AttentionNotchPresentation["revealMode"]; label: string }[] = [ - { value: "minimal", label: "Compact + peek" }, - { value: "hover", label: "Reveal on hover" }, - { value: "click", label: "Click only" }, -]; - function minutesToTimeValue(minute: number): string { const safe = ((Math.floor(minute) % 1440) + 1440) % 1440; const hours = String(Math.floor(safe / 60)).padStart(2, "0"); @@ -94,10 +81,6 @@ export function NotificationsSection() { const accountOwnerId = accountStatus.signedIn ? accountStatus.userId : null; const [preferences, setPreferences] = useState(DEFAULT_ATTENTION_PREFERENCES); - const [notchEnabled, setNotchEnabled] = useState(() => readAttentionNotchEnabled()); - const [notchPresentation, setNotchPresentation] = useState( - () => readAttentionNotchPresentation(), - ); const [loading, setLoading] = useState(true); const { state: saveState, flash, fail } = useSavedFlash(); const mounted = useRef(true); @@ -117,7 +100,7 @@ export function NotificationsSection() { void api.getPreferences(accountOwnerId) .then((next) => { if (cancelled || !mounted.current) return; - setPreferences(normalizeAttentionPreferences(next)); + setPreferences(normalizeActivityPreferences(next)); }) .catch((error: unknown) => { if (cancelled || !mounted.current) return; @@ -134,10 +117,7 @@ export function NotificationsSection() { * state update commits, so reading component state here would save the * previous value. */ - const persist = useCallback(async ( - nextPreferences: AttentionPreferences, - nextNotch?: { enabled?: boolean; presentation?: AttentionNotchPresentation }, - ) => { + const persist = useCallback(async (nextPreferences: AttentionPreferences) => { const api = window.ade?.attention; if (!api || !accountOwnerId) { fail("Sign in to change notification settings."); @@ -145,22 +125,13 @@ export function NotificationsSection() { } try { await api.putPreferences(accountOwnerId, nextPreferences); - // Notch presentation is deliberately machine-local: the delivery policy - // syncs across devices, but where the HUD sits on *this* screen doesn't. - const enabled = nextNotch?.enabled ?? notchEnabled; - const presentation = nextNotch?.presentation ?? notchPresentation; - writeAttentionNotchEnabled(enabled); - writeAttentionNotchPresentation(presentation); - await window.ade?.attentionNotch?.updateSettings( - attentionNotchSettingsFromPreferences(nextPreferences, enabled, presentation), - ); if (!mounted.current) return; flash(); } catch (error) { if (!mounted.current) return; fail(error instanceof Error ? error.message : String(error)); } - }, [accountOwnerId, notchEnabled, notchPresentation, flash, fail]); + }, [accountOwnerId, flash, fail]); // Build the next value outside the state updater and save it explicitly. // Saving *inside* an updater would fire twice under StrictMode, which @@ -217,7 +188,7 @@ export function NotificationsSection() { @@ -257,7 +228,7 @@ export function NotificationsSection() { @@ -345,35 +316,9 @@ export function NotificationsSection() { /> } /> - updateAccount({ hideDetails })} - /> - } - /> - updateAccount({ soundsEnabled })} - /> - } - /> @@ -381,69 +326,6 @@ export function NotificationsSection() { - - { - setNotchEnabled(enabled); - void persist(preferences, { enabled }); - }} - /> - } - > - {notchEnabled ? ( -
-
- Behavior - ({ value: option.value, label: option.label }))} - onChange={(revealMode) => { - const presentation = { ...notchPresentation, revealMode }; - setNotchPresentation(presentation); - void persist(preferences, { presentation }); - }} - /> -
-
- Expanded panel - { - const presentation = { ...notchPresentation, expandedPanelEnabled }; - setNotchPresentation(presentation); - void persist(preferences, { presentation }); - }} - /> -
-
- ) : null} -
- - updateAccount({ celebrationsEnabled })} - /> - } - /> -
); } diff --git a/apps/desktop/src/renderer/components/settings/settingsManifest.test.ts b/apps/desktop/src/renderer/components/settings/settingsManifest.test.ts index 1ff4877d9..aad6a58be 100644 --- a/apps/desktop/src/renderer/components/settings/settingsManifest.test.ts +++ b/apps/desktop/src/renderer/components/settings/settingsManifest.test.ts @@ -67,6 +67,23 @@ describe("settings manifest", () => { } }); + it("follows the legacy Activity-settings aliases already in the wild", () => { + // `?tab=notifications#attention-notch` shipped in tour steps and deeplinks + // before Activity had a tab of its own. Landing those on Notifications — + // which no longer holds the card — would be an invisible dead end. + for (const [hash, expectedTab] of [ + ["attention-notch", "activity"], + ["celebrations", "activity"], + ["attention-sounds", "activity"], + ["hide-previews", "activity"], + ] as const) { + const entry = resolveSettingsHash(hash); + expect(entry, `hash "${hash}" did not resolve`).not.toBeNull(); + expect(entry!.tab).toBe(expectedTab); + } + expect(resolveSettingsTab("attention")).toBe("activity"); + }); + it("resolves a live anchor directly, without needing an alias", () => { for (const entry of SETTINGS_ENTRIES) { expect(resolveSettingsHash(entry.anchor)?.id).toBe(entry.id); @@ -114,6 +131,7 @@ describe("settings manifest", () => { expect(searchSettingsEntries("api key").map((e) => e.id)).toContain("secrets.secrets"); expect(searchSettingsEntries("banner").map((e) => e.id)).toContain("lanes-git.rebase-suggestions"); expect(searchSettingsEntries("do not disturb").map((e) => e.id)).toContain("notifications.focus-suppression"); + expect(searchSettingsEntries("all machines").map((e) => e.id)).toContain("activity.dock-badge"); }); it("returns nothing for a blank query rather than every setting", () => { diff --git a/apps/desktop/src/renderer/components/settings/settingsManifest.ts b/apps/desktop/src/renderer/components/settings/settingsManifest.ts index ef83c13cb..be0e29af3 100644 --- a/apps/desktop/src/renderer/components/settings/settingsManifest.ts +++ b/apps/desktop/src/renderer/components/settings/settingsManifest.ts @@ -28,6 +28,7 @@ export const SETTINGS_TAB_IDS = [ "lanes-git", "integrations", "notifications", + "activity", "secrets", "storage", "stats", @@ -49,6 +50,7 @@ export const SETTINGS_TABS: readonly SettingsTab[] = [ { id: "lanes-git", label: "Lanes & Git", description: "How lanes start, stay current, and tell you they fell behind." }, { id: "integrations", label: "Integrations", description: "GitHub, Linear, and the ADE command line." }, { id: "notifications", label: "Notifications & Sound", description: "What ADE interrupts you for, and how." }, + { id: "activity", label: "Activity", description: "What's running everywhere, and how ADE shows it." }, // Named "Secrets & Environment" while planning, on the assumption that // `EnvironmentSection` held environment-variable mappings. It doesn't — it // was App version + ADE CLI, which now live in General and Integrations — @@ -422,24 +424,6 @@ export const SETTINGS_ENTRIES: readonly SettingEntry[] = [ scope: "machine", group: "Delivery", }, - { - id: "notifications.hide-previews", - label: "Hide previews", - keywords: ["privacy", "redact", "private", "content", "summary"], - tab: "notifications", - anchor: "hide-previews", - scope: "machine", - group: "Delivery", - }, - { - id: "notifications.attention-sounds", - label: "Attention sounds", - keywords: ["sound", "audio", "cue", "chime"], - tab: "notifications", - anchor: "attention-sounds", - scope: "machine", - group: "Sound", - }, { id: "notifications.completion-sound", label: "Agent completion sound", @@ -449,15 +433,6 @@ export const SETTINGS_ENTRIES: readonly SettingEntry[] = [ scope: "app", group: "Sound", }, - { - id: "notifications.celebrations", - label: "Celebrations", - keywords: ["confetti", "flourish", "animation", "success"], - tab: "notifications", - anchor: "celebrations", - scope: "machine", - group: "Attention notch", - }, { id: "notifications.lane-banners", label: "Lane banner budget", @@ -467,14 +442,99 @@ export const SETTINGS_ENTRIES: readonly SettingEntry[] = [ scope: "machine", group: "On-screen banners", }, + + // ── Activity ───────────────────────────────────────────────────────────── { - id: "notifications.notch", - label: "Attention notch", - keywords: ["notch", "menu bar", "hud", "reveal", "celebration", "overlay"], - tab: "notifications", - anchor: "attention-notch", + id: "activity.notch-enabled", + label: "ADE notch", + keywords: ["notch", "menu bar", "hud", "overlay", "ambient", "attention"], + tab: "activity", + anchor: "activity-notch", + scope: "machine", + showScopeChip: true, + group: "Notch & menu bar", + }, + { + id: "activity.notch-reveal", + label: "Notch behavior", + keywords: ["reveal", "hover", "click", "peek", "compact"], + tab: "activity", + anchor: "activity-notch-reveal", + scope: "machine", + group: "Notch & menu bar", + }, + { + id: "activity.notch-expanded", + label: "Expanded panel", + keywords: ["panel", "expand", "list", "sessions", "tall"], + tab: "activity", + anchor: "activity-notch-expanded", + scope: "machine", + group: "Notch & menu bar", + }, + { + id: "activity.notch-auto-reveal", + label: "Automatic reveal", + keywords: ["reveal", "pop", "auto", "interrupt", "toast", "alert"], + tab: "activity", + anchor: "activity-auto-reveal", + scope: "machine", + group: "Notch & menu bar", + }, + { + id: "activity.notch-ticker", + label: "Live ticker", + keywords: ["ticker", "cycle", "strip", "live", "rotate", "status"], + tab: "activity", + anchor: "activity-ticker", + scope: "machine", + group: "Notch & menu bar", + }, + { + id: "activity.celebrations", + label: "Celebrations", + keywords: ["confetti", "flourish", "animation", "success"], + tab: "activity", + anchor: "activity-celebrations", scope: "machine", - group: "Attention notch", + group: "Notch & menu bar", + }, + { + id: "activity.sounds", + label: "Activity sounds", + keywords: ["sound", "audio", "cue", "chime", "attention"], + tab: "activity", + anchor: "activity-sounds", + scope: "machine", + group: "Sound", + }, + { + id: "activity.hide-details", + label: "Hide previews", + keywords: ["privacy", "redact", "private", "content", "summary", "preview"], + tab: "activity", + anchor: "activity-hide-details", + scope: "machine", + group: "Privacy", + }, + { + id: "activity.dock-badge", + label: "Dock badge counts", + keywords: ["dock", "badge", "count", "this mac", "all machines", "account"], + tab: "activity", + anchor: "activity-dock-badge", + scope: "machine", + group: "Account", + }, + { + id: "activity.machines", + label: "Notify me about", + keywords: ["machine", "mute", "silence", "mac", "device", "per-machine"], + tab: "activity", + anchor: "activity-machines", + scope: "machine", + showScopeChip: true, + group: "Machines", }, // ── Secrets ────────────────────────────────────────────────────────────── @@ -570,6 +630,8 @@ export const LEGACY_TAB_ALIASES: Readonly> = { onboarding: "general", help: "general", tours: "general", + // The Attention center became the Activity pane and tab. + attention: "activity", }; /** @@ -592,6 +654,11 @@ export const LEGACY_HASH_ALIASES: Readonly> = { "auto-updates": "general.auto-updates", "product-analytics": "general.analytics", storage: "storage.usage", + // Moved out of Notifications when Activity got its own tab. + "attention-notch": "activity.notch-enabled", + celebrations: "activity.celebrations", + "attention-sounds": "activity.sounds", + "hide-previews": "activity.hide-details", }; const ENTRIES_BY_ID = new Map(SETTINGS_ENTRIES.map((entry) => [entry.id, entry])); diff --git a/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx b/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx index e0d1b674c..35712642e 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx @@ -161,7 +161,7 @@ export function SessionInfoPopover({ const isChat = isChatToolType(session.toolType); /* The "State" row reads through the ONE presentation vocabulary, same as the - sidebar row, the attention center and iOS: `running` → "Working", + sidebar row, the Activity pane and iOS: `running` → "Working", `ready`/`idle` → "Done", amber only for "your move", `stopped` neutral rather than red. This popover used to keep its own table and was the last surface in the app still saying "Completed". @@ -252,7 +252,7 @@ export function SessionInfoPopover({ session.attentionRequestedAt || session.pendingInputItemId || session.attentionSource === "provider_structured" - ? ["Attention source", lifecycleSourceLabel(session.attentionSource)] + ? ["Activity source", lifecycleSourceLabel(session.attentionSource)] : null, session.settledAt || session.settleOverride === "settled" ? ["Settlement source", lifecycleSourceLabel(session.settleSource)] diff --git a/apps/desktop/src/renderer/components/terminals/SessionStatusLabel.tsx b/apps/desktop/src/renderer/components/terminals/SessionStatusLabel.tsx new file mode 100644 index 000000000..cb1a99bff --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/SessionStatusLabel.tsx @@ -0,0 +1,148 @@ +import React from "react"; +import { + Alarm, + CheckCircle, + Circle, + CircleDashed, + Clock, + NotePencil, + Moon, +} from "@phosphor-icons/react"; +import { + SESSION_TONE_TEXT_CLASS, + formatFutureDuration, + formatWorkingDuration, + type SessionStatusGlyph, + type SessionStatusPresentation, +} from "../../../shared/sessionStatusPresentation"; +import { cn } from "../ui/cn"; + +/** Renderer-only mapping from shared glyph identity to Phosphor icons. */ +function StatusGlyph({ glyph }: { glyph: SessionStatusGlyph }) { + switch (glyph) { + case "working": + return ; + case "planning": + return ; + case "waiting": + return ; + case "done": + return ; + // Filled, not outlined: "your move" is the one state allowed to shout. + case "needs-you": + return ; + case "stale": + return ; + case "woke": + return ; + case "snoozed": + return ; + // `failed` deliberately has no glyph — red plus the word is already the + // loudest thing on the row. + default: + return null; + } +} + +/** + * Live elapsed copy for the states where "how long" is the useful fact. + * The timestamp comes from the caller so this component stays independent of + * terminal-session models and renderer actions. + */ +function useElapsedLabel(sinceIso: string | null | undefined, enabled: boolean): string { + const sinceMs = React.useMemo(() => { + const parsed = sinceIso ? Date.parse(sinceIso) : Number.NaN; + return Number.isFinite(parsed) ? parsed : null; + }, [sinceIso]); + const [nowMs, setNowMs] = React.useState(() => Date.now()); + + React.useEffect(() => { + if (!enabled || sinceMs == null) return undefined; + setNowMs(Date.now()); + const intervalId = window.setInterval(() => setNowMs(Date.now()), 1_000); + return () => window.clearInterval(intervalId); + }, [enabled, sinceMs]); + + if (!enabled || sinceMs == null) return ""; + return formatWorkingDuration(nowMs - sinceMs); +} + +function useFutureLabel(atIso: string | null | undefined, enabled: boolean): string { + const atMs = React.useMemo(() => { + const parsed = atIso ? Date.parse(atIso) : Number.NaN; + return Number.isFinite(parsed) ? parsed : null; + }, [atIso]); + const [nowMs, setNowMs] = React.useState(() => Date.now()); + + React.useEffect(() => { + if (!enabled || atMs == null) return undefined; + setNowMs(Date.now()); + const intervalId = window.setInterval(() => setNowMs(Date.now()), 30_000); + return () => window.clearInterval(intervalId); + }, [atMs, enabled]); + + if (!enabled || atMs == null) return ""; + return formatFutureDuration(atMs, nowMs); +} + +export type SessionStatusLabelProps = { + presentation: SessionStatusPresentation | null; + elapsedSince?: string | null; + futureAt?: string | null; + timestampLabel: string; + compact: boolean; +}; + +/** + * Pure-props status vocabulary shared by session rows and upcoming Activity + * projections. It owns no session model, hover actions, or IPC. + */ +export function SessionStatusLabel({ + presentation, + elapsedSince, + futureAt, + timestampLabel, + compact, +}: SessionStatusLabelProps) { + const waiting = presentation?.glyph === "waiting"; + const elapsed = useElapsedLabel(elapsedSince, Boolean(presentation?.showsElapsed)); + const future = useFutureLabel(futureAt, waiting); + const exactWakeTitle = React.useMemo(() => { + if (!waiting || !futureAt) return undefined; + const wakeAt = Date.parse(futureAt); + return Number.isFinite(wakeAt) + ? `Next run ${new Date(wakeAt).toLocaleString()}` + : undefined; + }, [futureAt, waiting]); + + if (!presentation) { + return ( + + {timestampLabel} + + ); + } + + return ( + + + {/* Keep the ticker outside role=status so screen readers do not announce + the row again every second. */} + {presentation.label} + {elapsed ? {elapsed} : null} + {future ? {future} : null} + + ); +} diff --git a/apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx b/apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx index e519033bd..b88a2bdb1 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx @@ -1,23 +1,10 @@ import React from "react"; import { - Alarm, ArrowUUpLeft, Check, - CheckCircle, - Circle, - CircleDashed, - Clock, - NotePencil, - Moon, } from "@phosphor-icons/react"; import type { OpenProjectBinding, TerminalSessionSummary } from "../../../shared/types"; -import { - SESSION_TONE_TEXT_CLASS, - formatFutureDuration, - formatWorkingDuration, - type SessionStatusGlyph, - type SessionStatusPresentation, -} from "../../../shared/sessionStatusPresentation"; +import type { SessionStatusPresentation } from "../../../shared/sessionStatusPresentation"; import { isChatToolType } from "../../lib/sessions"; import { canonicalInputFromSummary, @@ -25,6 +12,7 @@ import { } from "../../lib/terminalAttention"; import { cn } from "../ui/cn"; import { SessionSnoozeControl } from "./SessionSnoozeControl"; +import { SessionStatusLabel } from "./SessionStatusLabel"; import { settleSession, unsettleSession, @@ -44,82 +32,9 @@ import { * * This is also the only place a session's status is rendered as words. Every * hue and every glyph comes from `sessionStatusPresentation`, so the sidebar, - * the attention center and iOS cannot drift into three different ambers. + * the Activity pane and iOS cannot drift into three different ambers. */ -/** - * Glyph identity → Phosphor. Kept here rather than in the shared presentation - * module: that module is imported by main-process code and must stay free of - * renderer dependencies. - */ -function StatusGlyph({ glyph }: { glyph: SessionStatusGlyph }) { - switch (glyph) { - case "working": - return ; - case "planning": - return ; - case "waiting": - return ; - case "done": - return ; - // Filled, not outlined: "your move" is the one state allowed to shout. - case "needs-you": - return ; - case "stale": - return ; - case "woke": - return ; - case "snoozed": - return ; - // `failed` deliberately has no glyph — red plus the word is already the - // loudest thing on the row. - default: - return null; - } -} - -/** - * Live elapsed copy for the states where "how long" is the question the row - * raises. Active chat turns count from their immutable turn-start timestamp; - * provider activity must not reset them. CLI and stale states retain the - * last-output clock because they do not have a provider turn boundary. - */ -function useElapsedLabel(sinceIso: string | null | undefined, enabled: boolean): string { - const sinceMs = React.useMemo(() => { - const parsed = sinceIso ? Date.parse(sinceIso) : Number.NaN; - return Number.isFinite(parsed) ? parsed : null; - }, [sinceIso]); - const [nowMs, setNowMs] = React.useState(() => Date.now()); - - React.useEffect(() => { - if (!enabled || sinceMs == null) return undefined; - setNowMs(Date.now()); - const intervalId = window.setInterval(() => setNowMs(Date.now()), 1_000); - return () => window.clearInterval(intervalId); - }, [enabled, sinceMs]); - - if (!enabled || sinceMs == null) return ""; - return formatWorkingDuration(nowMs - sinceMs); -} - -function useFutureLabel(atIso: string | null | undefined, enabled: boolean): string { - const atMs = React.useMemo(() => { - const parsed = atIso ? Date.parse(atIso) : Number.NaN; - return Number.isFinite(parsed) ? parsed : null; - }, [atIso]); - const [nowMs, setNowMs] = React.useState(() => Date.now()); - - React.useEffect(() => { - if (!enabled || atMs == null) return undefined; - setNowMs(Date.now()); - const intervalId = window.setInterval(() => setNowMs(Date.now()), 30_000); - return () => window.clearInterval(intervalId); - }, [atMs, enabled]); - - if (!enabled || atMs == null) return ""; - return formatFutureDuration(atMs, nowMs); -} - /** * The row action idiom, shared with `SessionSnoozeControl`'s trigger. * @@ -168,20 +83,10 @@ export function SessionStatusSlot({ if (!actionsEnabled) setSnoozeMenuOpen(false); }, [actionsEnabled]); - const waiting = presentation?.glyph === "waiting"; - const future = useFutureLabel(session.nextWakeAt, waiting); - const exactWakeTitle = React.useMemo(() => { - if (!waiting || !session.nextWakeAt) return undefined; - const wakeAt = Date.parse(session.nextWakeAt); - return Number.isFinite(wakeAt) - ? `Next run ${new Date(wakeAt).toLocaleString()}` - : undefined; - }, [session.nextWakeAt, waiting]); const canonicalPhase = sessionCanonicalUiState(canonicalInputFromSummary(session)).phase; const elapsedSince = canonicalPhase === "running" && isChatToolType(session.toolType) ? session.currentTurnStartedAt ?? session.lastActivityAt ?? session.startedAt : session.lastActivityAt ?? session.startedAt; - const elapsed = useElapsedLabel(elapsedSince, Boolean(presentation?.showsElapsed)); const isActivelyRunning = canonicalPhase === "starting" || canonicalPhase === "running" || canonicalPhase === "stale"; @@ -210,40 +115,13 @@ export function SessionStatusSlot({ compact ? "text-[10px]" : "text-[11px]", )} > - {presentation ? ( - - - {/* role="status" sits on the LABEL alone. Putting it on a wrapper - that also contains the ticking duration makes screen readers - announce the row once per second. */} - {presentation.label} - {elapsed ? ( - - {elapsed} - - ) : null} - {future ? ( - - {future} - - ) : null} - - ) : ( - {timestampLabel} - )} + {actionsEnabled ? ( diff --git a/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.test.tsx b/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.test.tsx new file mode 100644 index 000000000..3c638569f --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.test.tsx @@ -0,0 +1,218 @@ +// @vitest-environment jsdom + +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ATTENTION_CONTRACT_VERSION, + DEFAULT_ATTENTION_PREFERENCES, + type AttentionItem, + type AttentionPhase, +} from "../../shared/types"; +import { activityStore, resetActivityStoreForTests } from "../state/activityStore"; +import { useAppStore } from "../state/appStore"; + +const listSessionsCached = vi.fn(async () => [] as unknown[]); +const invalidateSessionListCache = vi.fn(); +const summarizeTerminalAttention = vi.fn(() => ({ + runningCount: 0, + activeCount: 0, + needsAttentionCount: 0, + indicator: "none" as const, + byLaneId: {}, +})); + +vi.mock("../lib/sessionListCache", () => ({ + listSessionsCached: (...args: unknown[]) => listSessionsCached(...(args as [])), + invalidateSessionListCache: (...args: unknown[]) => + invalidateSessionListCache(...(args as [])), +})); +vi.mock("../lib/terminalAttention", () => ({ + summarizeTerminalAttention: (...args: unknown[]) => + summarizeTerminalAttention(...(args as [])), +})); + +const { useAppWideSessionAttention } = await import("./useAppWideSessionAttention"); + +const originalAde = window.ade; +let setDockBadgeCount: ReturnType; +const noopUnsubscribe = () => {}; + +function needsYouItem(id: string, phase: AttentionPhase = "needs_you"): AttentionItem { + return { + contractVersion: ATTENTION_CONTRACT_VERSION, + id, + revision: 1, + fingerprint: `fingerprint-${id}`, + kind: "agent", + eventKind: "agent_needs_you", + phase, + machine: { machineKey: "studio", name: "Studio Mac", online: true, lastSeenAt: null }, + project: { projectId: "ade", name: "ADE" }, + title: id, + preview: "preview", + privacyPreview: "private preview", + destination: { kind: "session", sessionId: id }, + actions: [], + occurredAt: "2026-08-01T11:00:00.000Z", + updatedAt: "2026-08-01T11:00:00.000Z", + seenAt: null, + dismissedAt: null, + expiresAt: null, + }; +} + +function readyAccountFeed(items: AttentionItem[]): void { + activityStore.setState({ + itemsById: Object.fromEntries(items.map((item) => [item.id, item])), + availability: { + state: "ready", + title: "Account Activity", + message: "Live across your ADE account.", + recovery: null, + }, + }); +} + +function useAccountScope(): void { + activityStore.setState({ + preferences: { + ...DEFAULT_ATTENTION_PREFERENCES, + account: { ...DEFAULT_ATTENTION_PREFERENCES.account, dockBadgeScope: "account" }, + }, + }); +} + +function Probe() { + useAppWideSessionAttention(); + return null; +} + +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + setDockBadgeCount = vi.fn(async () => {}); + summarizeTerminalAttention.mockReturnValue({ + runningCount: 0, + activeCount: 0, + needsAttentionCount: 2, + indicator: "none" as const, + byLaneId: {}, + }); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + app: { setDockBadgeCount }, + pty: { onData: () => noopUnsubscribe, onExit: () => noopUnsubscribe }, + agentChat: { onEvent: () => noopUnsubscribe }, + sessions: { onChanged: () => noopUnsubscribe }, + }, + }); + useAppStore.setState({ + showWelcome: false, + project: { rootPath: "/repo/ade" } as never, + }); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + resetActivityStoreForTests(); + useAppStore.setState({ showWelcome: true, project: null }); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: originalAde, + }); +}); + +async function flushInitialRefresh(): Promise { + await act(async () => { + vi.advanceTimersByTime(3_000); + await Promise.resolve(); + }); +} + +describe("useAppWideSessionAttention dock badge scope", () => { + it("badges this machine's sessions by default", async () => { + readyAccountFeed([needsYouItem("a"), needsYouItem("b"), needsYouItem("c")]); + render(); + await flushInitialRefresh(); + + await waitFor(() => expect(setDockBadgeCount).toHaveBeenCalledWith(2)); + }); + + it("badges the whole account's needs-you tier once the setting is flipped", async () => { + useAccountScope(); + readyAccountFeed([needsYouItem("a"), needsYouItem("b"), needsYouItem("c")]); + render(); + await flushInitialRefresh(); + + await waitFor(() => expect(setDockBadgeCount).toHaveBeenCalledWith(3)); + }); + + it("counts the hidden CTO thread on top of the account tier", async () => { + useAccountScope(); + readyAccountFeed([needsYouItem("a")]); + useAppStore.setState({ ctoAttention: { awaitingInput: true } as never }); + render(); + await flushInitialRefresh(); + + await waitFor(() => expect(setDockBadgeCount).toHaveBeenCalledWith(2)); + useAppStore.setState({ ctoAttention: { awaitingInput: false } as never }); + }); + + /** + * A snapshot that has not landed knows nothing about the other machines, so + * "0 account-wide" would be a claim the data cannot support. Degrade to the + * local count instead of blanking the badge. + */ + it("falls back to the local count while the account feed is not ready", async () => { + useAccountScope(); + activityStore.setState({ + itemsById: { a: needsYouItem("a"), b: needsYouItem("b"), c: needsYouItem("c") }, + availability: { + state: "degraded", + title: "Account Activity is reconnecting", + message: "Retry to restore live updates.", + recovery: "retry", + }, + }); + render(); + await flushInitialRefresh(); + + await waitFor(() => expect(setDockBadgeCount).toHaveBeenCalledWith(2)); + }); + + it("follows the account feed as it changes, without a session refresh", async () => { + useAccountScope(); + readyAccountFeed([needsYouItem("a")]); + render(); + await flushInitialRefresh(); + await waitFor(() => expect(setDockBadgeCount).toHaveBeenCalledWith(1)); + + act(() => { + readyAccountFeed([needsYouItem("a"), needsYouItem("b")]); + }); + + await waitFor(() => expect(setDockBadgeCount).toHaveBeenCalledWith(2)); + }); + + it("keeps badging the account when no project is open", async () => { + useAccountScope(); + readyAccountFeed([needsYouItem("a"), needsYouItem("b")]); + useAppStore.setState({ showWelcome: true }); + render(); + + await waitFor(() => expect(setDockBadgeCount).toHaveBeenCalledWith(2)); + }); + + it("clears the badge when no project is open and the scope is local", async () => { + readyAccountFeed([needsYouItem("a"), needsYouItem("b")]); + useAppStore.setState({ showWelcome: true }); + render(); + + await waitFor(() => expect(setDockBadgeCount).toHaveBeenCalledWith(0)); + }); +}); diff --git a/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.ts b/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.ts index f711d3637..b41e0d0f8 100644 --- a/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.ts +++ b/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.ts @@ -1,11 +1,17 @@ import { useEffect, useRef } from "react"; import type { TerminalSessionSummary } from "../../shared/types"; +import { activityBadgeCount } from "../components/activity/activityPriority"; import { shouldRefreshSessionListForChatEvent } from "../lib/chatSessionEvents"; import { invalidateSessionListCache, listSessionsCached, } from "../lib/sessionListCache"; import { summarizeTerminalAttention } from "../lib/terminalAttention"; +import { + activityStore, + selectDockBadgeScope, + useActivityStore, +} from "../state/activityStore"; import { selectActiveProjectRoot, useAppStore } from "../state/appStore"; const EMPTY_TERMINAL_ATTENTION = { @@ -16,6 +22,20 @@ const EMPTY_TERMINAL_ATTENTION = { byLaneId: {}, }; +/** + * Account-scoped dock badge, or `null` when the account feed cannot answer. + * + * Null is not zero. A snapshot that has not landed, has gone degraded, or + * belongs to a signed-out window knows nothing about the other machines, and a + * badge of 0 in that state is a claim the data does not support — so the caller + * falls back to the local count instead. + */ +function accountDockBadgeCount(): number | null { + const state = activityStore.getState(); + if (state.availability?.state !== "ready") return null; + return activityBadgeCount(state.itemsById); +} + export function useAppWideSessionAttention(): void { const currentProjectRoot = useAppStore(selectActiveProjectRoot); const showWelcome = useAppStore((state) => state.showWelcome); @@ -25,6 +45,9 @@ export function useAppWideSessionAttention(): void { // reach a minimized window. Kept in this hook so `setDockBadgeCount` keeps a // single writer. const ctoAwaitingInput = useAppStore((state) => state.ctoAttention.awaitingInput); + // Account scope is a synced preference, so it can flip from another device + // mid-session; the badge has to follow without a reload. + const dockBadgeScope = useActivityStore(selectDockBadgeScope); const lastDockBadgeCountRef = useRef(null); const trackedProjectRoot = showWelcome ? null : currentProjectRoot; @@ -33,12 +56,26 @@ export function useAppWideSessionAttention(): void { setTerminalAttention(EMPTY_TERMINAL_ATTENTION); // The hook is app-wide, so route changes keep a project root and do not // enter this branch. Reaching it means the project was closed; clear the - // application-wide badge instead of leaking the previous project's count. - if (lastDockBadgeCountRef.current !== 0) { - lastDockBadgeCountRef.current = 0; - void window.ade?.app?.setDockBadgeCount?.(0)?.catch?.(() => {}); + // application-wide badge instead of leaking the previous project's count + // — unless the badge is account-scoped, in which case the open project is + // irrelevant to what it counts. + const accountOnly = dockBadgeScope === "account" ? accountDockBadgeCount() : null; + const projectlessBadge = accountOnly == null + ? 0 + : accountOnly + (ctoAwaitingInput ? 1 : 0); + if (lastDockBadgeCountRef.current !== projectlessBadge) { + lastDockBadgeCountRef.current = projectlessBadge; + void window.ade?.app?.setDockBadgeCount?.(projectlessBadge)?.catch?.(() => {}); } - return; + if (dockBadgeScope !== "account") return; + // Keep following the account feed while no project is open. + return activityStore.subscribe(() => { + const count = accountDockBadgeCount(); + const next = count == null ? 0 : count + (ctoAwaitingInput ? 1 : 0); + if (lastDockBadgeCountRef.current === next) return; + lastDockBadgeCountRef.current = next; + void window.ade?.app?.setDockBadgeCount?.(next)?.catch?.(() => {}); + }); } let refreshTimer: number | null = null; @@ -46,6 +83,26 @@ export function useAppWideSessionAttention(): void { let refreshInFlight = false; let refreshQueued = false; let cancelled = false; + let localNeedsAttention = 0; + + /** + * The single dock-badge write. Account scope counts the whole account's + * needs-you tier; local scope counts this Mac's sessions. Either way the + * CTO thread is added on top, because it is hidden from both feeds. + */ + const pushDockBadge = () => { + const account = dockBadgeScope === "account" ? accountDockBadgeCount() : null; + const badgeCount = (account ?? localNeedsAttention) + (ctoAwaitingInput ? 1 : 0); + // Push on change so a blocked agent reaches the user even with the + // window minimized. + if (lastDockBadgeCountRef.current === badgeCount) return; + lastDockBadgeCountRef.current = badgeCount; + void window.ade?.app?.setDockBadgeCount?.(badgeCount)?.catch?.(() => {}); + }; + + const unsubscribeAttention = dockBadgeScope === "account" + ? activityStore.subscribe(pushDockBadge) + : () => {}; const refreshTerminalAttention = async () => { if (cancelled) return; @@ -64,13 +121,9 @@ export function useAppWideSessionAttention(): void { if (cancelled) return; const attention = summarizeTerminalAttention(sessions); setTerminalAttention(attention); - const badgeCount = attention.needsAttentionCount + (ctoAwaitingInput ? 1 : 0); - // Dock badge mirrors the loud tier only; push on change so a blocked - // agent reaches the user even with the window minimized. - if (lastDockBadgeCountRef.current !== badgeCount) { - lastDockBadgeCountRef.current = badgeCount; - void window.ade?.app?.setDockBadgeCount?.(badgeCount)?.catch?.(() => {}); - } + // Dock badge mirrors the loud tier only. + localNeedsAttention = attention.needsAttentionCount; + pushDockBadge(); } catch { // best effort } finally { @@ -129,6 +182,7 @@ export function useAppWideSessionAttention(): void { return () => { cancelled = true; + unsubscribeAttention(); try { unsubscribeData(); unsubscribeExit(); @@ -142,5 +196,5 @@ export function useAppWideSessionAttention(): void { window.removeEventListener("focus", onFocus); document.removeEventListener("visibilitychange", onVisibilityChange); }; - }, [trackedProjectRoot, setTerminalAttention, ctoAwaitingInput]); + }, [trackedProjectRoot, setTerminalAttention, ctoAwaitingInput, dockBadgeScope]); } diff --git a/apps/desktop/src/renderer/lib/legacyRoutes.test.ts b/apps/desktop/src/renderer/lib/legacyRoutes.test.ts new file mode 100644 index 000000000..107ce559c --- /dev/null +++ b/apps/desktop/src/renderer/lib/legacyRoutes.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { + isActivityRoute, + LEGACY_ROUTE_ALIASES, + resolveLegacyRoute, +} from "./legacyRoutes"; + +describe("legacy routes", () => { + it("resolves every alias to a route the app serves today", () => { + for (const [legacy, target] of Object.entries(LEGACY_ROUTE_ALIASES)) { + expect(resolveLegacyRoute(legacy)).toBe(target); + expect(LEGACY_ROUTE_ALIASES[target]).toBeUndefined(); + } + }); + + it("carries a sub-path across the rename", () => { + expect(resolveLegacyRoute("/attention/inbox")).toBe("/activity/inbox"); + }); + + it("tolerates a trailing slash", () => { + expect(resolveLegacyRoute("/attention/")).toBe("/activity"); + }); + + it("leaves an unknown path alone", () => { + expect(resolveLegacyRoute("/work")).toBe("/work"); + expect(resolveLegacyRoute("/attentiveness")).toBe("/attentiveness"); + }); + + it("recognises Activity under either of its names", () => { + expect(isActivityRoute("/activity")).toBe(true); + expect(isActivityRoute("/attention")).toBe(true); + expect(isActivityRoute("/attention/inbox")).toBe(true); + // The prefix check must not catch a route that merely starts the same way. + expect(isActivityRoute("/attentiveness")).toBe(false); + expect(isActivityRoute("/work")).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/lib/legacyRoutes.ts b/apps/desktop/src/renderer/lib/legacyRoutes.ts new file mode 100644 index 000000000..5abda6c3d --- /dev/null +++ b/apps/desktop/src/renderer/lib/legacyRoutes.ts @@ -0,0 +1,37 @@ +/** + * Renamed renderer routes, and where they now live. + * + * ADE's shell does not use `` elements for its top-level surfaces — they + * are pathname predicates duplicated in `App.tsx` and `AppShell.tsx` — so there + * is no router-level redirect to hang a rename on. This is the same shape as + * `settingsManifest.ts`'s `LEGACY_TAB_ALIASES` / `resolveSettingsTab`: every + * path ADE has ever shipped in a deeplink, a tour step, or a bookmark stays + * resolvable, and the app has one place to look it up. + */ +export const LEGACY_ROUTE_ALIASES: Readonly> = { + // The Attention center became the Activity pane. The pathname survives as a + // deep link that opens the pane over whatever tab is current. + "/attention": "/activity", +}; + +/** + * Resolve a pathname to the route that serves it today. Unknown paths come back + * unchanged so callers can keep treating this as a total function. + */ +export function resolveLegacyRoute(pathname: string): string { + const normalized = pathname.replace(/\/+$/, "") || "/"; + const direct = LEGACY_ROUTE_ALIASES[normalized]; + if (direct) return direct; + for (const [legacy, target] of Object.entries(LEGACY_ROUTE_ALIASES)) { + if (normalized.startsWith(`${legacy}/`)) { + return `${target}${normalized.slice(legacy.length)}`; + } + } + return pathname; +} + +/** Whether a pathname opens the Activity pane, under either of its names. */ +export function isActivityRoute(pathname: string): boolean { + const resolved = resolveLegacyRoute(pathname); + return resolved === "/activity" || resolved.startsWith("/activity/"); +} diff --git a/apps/desktop/src/renderer/state/attentionStore.test.ts b/apps/desktop/src/renderer/state/activityStore.test.ts similarity index 59% rename from apps/desktop/src/renderer/state/attentionStore.test.ts rename to apps/desktop/src/renderer/state/activityStore.test.ts index bfd972be5..6dfbdcd0c 100644 --- a/apps/desktop/src/renderer/state/attentionStore.test.ts +++ b/apps/desktop/src/renderer/state/activityStore.test.ts @@ -5,13 +5,11 @@ import { type AttentionItem, } from "../../shared/types"; import { - acknowledgeAttentionItem, - attentionStore, - resetAttentionStoreForTests, - selectAttentionCounts, - selectAttentionItems, - selectAttentionUnseenCount, -} from "./attentionStore"; + acknowledgeActivityItem, + activityStore, + resetActivityStoreForTests, + selectActivityUnseenCount, +} from "./activityStore"; const originalWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); @@ -52,7 +50,7 @@ function item( } afterEach(() => { - resetAttentionStoreForTests(); + resetActivityStoreForTests(); if (originalWindowDescriptor) { Object.defineProperty(globalThis, "window", originalWindowDescriptor); } else { @@ -60,9 +58,9 @@ afterEach(() => { } }); -describe("attentionStore", () => { +describe("activityStore", () => { it("merges incremental snapshots and removes only explicit tombstones", () => { - attentionStore.getState().applySnapshot({ + activityStore.getState().applySnapshot({ contractVersion: ATTENTION_CONTRACT_VERSION, revision: 10, generatedAt: "2026-07-28T14:00:00.000Z", @@ -71,7 +69,7 @@ describe("attentionStore", () => { item("b", "needs_you", { revision: 2 }), ], }); - attentionStore.getState().applySnapshot({ + activityStore.getState().applySnapshot({ contractVersion: ATTENTION_CONTRACT_VERSION, revision: 11, generatedAt: "2026-07-28T14:01:00.000Z", @@ -81,10 +79,10 @@ describe("attentionStore", () => { ], }); - expect(Object.keys(attentionStore.getState().itemsById).sort()).toEqual(["a", "b", "c"]); - expect(attentionStore.getState().itemsById.a?.phase).toBe("blocked"); + expect(Object.keys(activityStore.getState().itemsById).sort()).toEqual(["a", "b", "c"]); + expect(activityStore.getState().itemsById.a?.phase).toBe("blocked"); - attentionStore.getState().applySnapshot({ + activityStore.getState().applySnapshot({ contractVersion: ATTENTION_CONTRACT_VERSION, revision: 12, generatedAt: "2026-07-28T14:02:00.000Z", @@ -98,36 +96,36 @@ describe("attentionStore", () => { ], }); - expect(Object.keys(attentionStore.getState().itemsById).sort()).toEqual(["a", "c"]); + expect(Object.keys(activityStore.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"); + activityStore.getState().upsertItem(item("a", "running", { revision: 3 })); + activityStore.getState().upsertItem(item("a", "failed", { revision: 2 })); + expect(activityStore.getState().itemsById.a?.phase).toBe("running"); - attentionStore.getState().removeItem({ + activityStore.getState().removeItem({ id: "a", revision: 3, deletedAt: "2026-07-28T14:05:00.000Z", }); - expect(attentionStore.getState().itemsById.a).toBeUndefined(); + expect(activityStore.getState().itemsById.a).toBeUndefined(); - attentionStore.getState().upsertItem(item("a", "failed", { revision: 3 })); - expect(attentionStore.getState().itemsById.a).toBeUndefined(); + activityStore.getState().upsertItem(item("a", "failed", { revision: 3 })); + expect(activityStore.getState().itemsById.a).toBeUndefined(); - attentionStore.getState().upsertItem(item("a", "failed", { revision: 4 })); - expect(attentionStore.getState().itemsById.a?.phase).toBe("failed"); + activityStore.getState().upsertItem(item("a", "failed", { revision: 4 })); + expect(activityStore.getState().itemsById.a?.phase).toBe("failed"); }); it("clears the prior account when the revision stream resets", () => { - attentionStore.getState().applySnapshot({ + activityStore.getState().applySnapshot({ contractVersion: ATTENTION_CONTRACT_VERSION, revision: 40, generatedAt: "2026-07-28T14:00:00.000Z", items: [item("private-account-a", "needs_you")], }); - attentionStore.setState({ + activityStore.setState({ pendingAcknowledgements: { "private-account-a": { previous: item("private-account-a", "needs_you"), @@ -139,17 +137,17 @@ describe("attentionStore", () => { }, }); - attentionStore.getState().applySnapshot({ + activityStore.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); + expect(Object.keys(activityStore.getState().itemsById)).toEqual(["account-b"]); + expect(activityStore.getState().pendingAcknowledgements).toEqual({}); + expect(activityStore.getState().acknowledgementErrors).toEqual({}); + expect(activityStore.getState().revision).toBe(2); }); it.each([ @@ -157,7 +155,7 @@ describe("attentionStore", () => { ["equal", 40], ["higher", 80], ])("clears account A when account B has a %s revision", (_label, nextRevision) => { - attentionStore.getState().applySnapshot({ + activityStore.getState().applySnapshot({ contractVersion: ATTENTION_CONTRACT_VERSION, streamId: "account-a", revision: 40, @@ -165,7 +163,7 @@ describe("attentionStore", () => { items: [item("private-account-a", "needs_you")], }); - attentionStore.getState().applySnapshot({ + activityStore.getState().applySnapshot({ contractVersion: ATTENTION_CONTRACT_VERSION, streamId: "account-b", revision: nextRevision, @@ -173,13 +171,13 @@ describe("attentionStore", () => { items: [item("account-b", "running")], }); - expect(attentionStore.getState().streamId).toBe("account-b"); - expect(Object.keys(attentionStore.getState().itemsById)).toEqual(["account-b"]); + expect(activityStore.getState().streamId).toBe("account-b"); + expect(Object.keys(activityStore.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({ + activityStore.getState().applySnapshot({ contractVersion: ATTENTION_CONTRACT_VERSION, streamId: "account-a", revision: 3, @@ -187,7 +185,7 @@ describe("attentionStore", () => { items: [retained], }); - attentionStore.getState().applySnapshot({ + activityStore.getState().applySnapshot({ contractVersion: ATTENTION_CONTRACT_VERSION, streamId: "account-a", revision: 3, @@ -200,45 +198,14 @@ describe("attentionStore", () => { items: [], }); - expect(attentionStore.getState().itemsById[retained.id]?.machine).toMatchObject({ + expect(activityStore.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({ + it("tracks the global unseen badge across machines", () => { + activityStore.setState({ itemsById: { needs: item("needs", "needs_you"), seen: item("seen", "completed", { @@ -253,15 +220,13 @@ describe("attentionStore", () => { }, }), }, - scope: { kind: "machine", machineKey: "studio", label: "Studio Mac" }, }); - expect(selectAttentionCounts(attentionStore.getState()).inbox).toBe(1); - expect(selectAttentionUnseenCount(attentionStore.getState())).toBe(2); + expect(selectActivityUnseenCount(activityStore.getState())).toBe(2); }); - it("excludes expired work from views, counts, and the global badge", () => { - attentionStore.setState({ + it("excludes expired work from the global badge", () => { + activityStore.setState({ itemsById: { expired: item("expired", "needs_you", { expiresAt: "2020-01-01T00:00:00.000Z", @@ -270,18 +235,8 @@ describe("attentionStore", () => { 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); + expect(selectActivityUnseenCount(activityStore.getState())).toBe(0); }); it("rolls back only acknowledgement fields when a newer snapshot arrives", async () => { @@ -299,17 +254,17 @@ describe("attentionStore", () => { }, }, }); - attentionStore.setState({ + activityStore.setState({ revision: 1, itemsById: { needs: item("needs", "needs_you"), }, }); - const pending = acknowledgeAttentionItem("needs", "seen"); - expect(attentionStore.getState().itemsById.needs?.seenAt).not.toBeNull(); + const pending = acknowledgeActivityItem("needs", "seen"); + expect(activityStore.getState().itemsById.needs?.seenAt).not.toBeNull(); - attentionStore.getState().applySnapshot({ + activityStore.getState().applySnapshot({ contractVersion: ATTENTION_CONTRACT_VERSION, revision: 2, generatedAt: "2026-07-28T14:03:00.000Z", @@ -323,7 +278,7 @@ describe("attentionStore", () => { rejectAcknowledgement(new Error("Network unavailable")); await expect(pending).rejects.toThrow("Network unavailable"); - expect(attentionStore.getState().itemsById.needs).toMatchObject({ + expect(activityStore.getState().itemsById.needs).toMatchObject({ revision: 2, phase: "blocked", title: "New server title", diff --git a/apps/desktop/src/renderer/state/attentionStore.ts b/apps/desktop/src/renderer/state/activityStore.ts similarity index 64% rename from apps/desktop/src/renderer/state/attentionStore.ts rename to apps/desktop/src/renderer/state/activityStore.ts index b3bd80e3a..c7cd2eaba 100644 --- a/apps/desktop/src/renderer/state/attentionStore.ts +++ b/apps/desktop/src/renderer/state/activityStore.ts @@ -2,30 +2,22 @@ import { useStore } from "zustand"; import { createStore } from "zustand/vanilla"; import { - attentionItemIsLive, attentionItemNeedsInbox, - sortAttentionItems, type AttentionItem, + type AttentionPreferences, type AttentionSnapshot, type AttentionTombstone, } from "../../shared/types"; -export type AttentionView = "live" | "inbox" | "recent"; +export type ActivitySyncStatus = "idle" | "syncing" | "ready" | "error"; -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 = { +type PendingActivityAcknowledgement = { previous: AttentionItem; seenAt: string; dismissedAt?: string; }; -export type AttentionStoreState = { +export type ActivityStoreState = { snapshotScope: AttentionSnapshot["scope"] | null; accountOwnerId: string | null; availability: AttentionSnapshot["availability"] | null; @@ -34,89 +26,61 @@ export type AttentionStoreState = { generatedAt: string | null; itemsById: Record; tombstonesById: Record; - view: AttentionView; - scope: AttentionScope; - selectedItemId: string | null; headerSurfaceVisible: boolean; - syncStatus: AttentionSyncStatus; + /** + * Last account preferences this window loaded. Null means "not loaded yet", + * which every reader must treat as the conservative default rather than as + * "the user turned it off" — see `selectActivityHideDetails`. + */ + preferences: AttentionPreferences | null; + syncStatus: ActivitySyncStatus; syncError: string | null; - pendingAcknowledgements: Record; + pendingAcknowledgements: Record; acknowledgementErrors: Record; resetStream: () => void; + setPreferences: (preferences: AttentionPreferences | null) => 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; setHeaderSurfaceVisible: (visible: boolean) => void; - setSyncStatus: (status: AttentionSyncStatus, error?: string | null) => void; + setSyncStatus: (status: ActivitySyncStatus, 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); - }); +/** + * Whether Activity surfaces may show agent-authored text. Unloaded preferences + * resolve to `false` rather than `true`: hide-details is off by default, and + * defaulting a *display* choice to "hidden" would make every surface look + * broken for the seconds before the account load lands. The native notch takes + * the opposite default because it paints over the menu bar of a locked-away + * Mac — see `failClosedActivityNotchSettings` in `useActivitySync.ts`. + */ +export function selectActivityHideDetails( + state: Pick, +): boolean { + return state.preferences?.account?.hideDetails === true; } -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, - }; +/** + * Dock-badge scope. Local by default so a fresh install badges only the work + * on the Mac in front of the user; flipping it to `account` is an explicit, + * synced choice. + */ +export function selectDockBadgeScope( + state: Pick, +): "local" | "account" { + return state.preferences?.account?.dockBadgeScope === "account" ? "account" : "local"; } -export function selectAttentionUnseenCount( - state: Pick, +export function selectActivityUnseenCount( + state: Pick, ): number { const now = Date.now(); return Object.values(state.itemsById).filter( @@ -125,7 +89,7 @@ export function selectAttentionUnseenCount( } function createInitialState(): Pick< - AttentionStoreState, + ActivityStoreState, | "streamId" | "snapshotScope" | "accountOwnerId" @@ -134,10 +98,8 @@ function createInitialState(): Pick< | "generatedAt" | "itemsById" | "tombstonesById" - | "view" - | "scope" - | "selectedItemId" | "headerSurfaceVisible" + | "preferences" | "syncStatus" | "syncError" | "pendingAcknowledgements" @@ -152,10 +114,8 @@ function createInitialState(): Pick< generatedAt: null, itemsById: {}, tombstonesById: {}, - view: "live", - scope: { kind: "all" }, - selectedItemId: null, headerSurfaceVisible: false, + preferences: null, syncStatus: "idle", syncError: null, pendingAcknowledgements: {}, @@ -163,13 +123,16 @@ function createInitialState(): Pick< }; } -export const attentionStore = createStore((set) => ({ +export const activityStore = createStore((set) => ({ ...createInitialState(), resetStream: () => set((state) => ({ ...createInitialState(), headerSurfaceVisible: state.headerSurfaceVisible, })), + // Preferences are account-scoped, so a stream reset deliberately drops them + // rather than letting the previous account's privacy choice govern this one. + setPreferences: (preferences) => set({ preferences }), applySnapshot: (snapshot) => set((state) => { // Account revisions are monotonic only inside one verified account. @@ -235,9 +198,6 @@ export const attentionStore = createStore((set) => ({ syncError: null, pendingAcknowledgements: streamReset ? {} : state.pendingAcknowledgements, acknowledgementErrors: streamReset ? {} : state.acknowledgementErrors, - selectedItemId: state.selectedItemId && itemsById[state.selectedItemId] - ? state.selectedItemId - : null, }; }), upsertItem: (item) => @@ -261,12 +221,8 @@ export const attentionStore = createStore((set) => ({ 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 }), setHeaderSurfaceVisible: (headerSurfaceVisible) => set({ headerSurfaceVisible }), setSyncStatus: (syncStatus, syncError = null) => set({ syncStatus, syncError }), markSeen: (itemId, seenAt = new Date().toISOString()) => @@ -293,51 +249,50 @@ export const attentionStore = createStore((set) => ({ dismissedAt, }, }, - selectedItemId: state.selectedItemId === itemId ? null : state.selectedItemId, }; }), })); -export function useAttentionStore(selector: (state: AttentionStoreState) => T): T { - return useStore(attentionStore, selector); +export function useActivityStore(selector: (state: ActivityStoreState) => T): T { + return useStore(activityStore, selector); } -export function applyAttentionSnapshot(snapshot: AttentionSnapshot): void { - attentionStore.getState().applySnapshot(snapshot); +export function applyActivitySnapshot(snapshot: AttentionSnapshot): void { + activityStore.getState().applySnapshot(snapshot); } -export function upsertAttentionItem(item: AttentionItem): void { - attentionStore.getState().upsertItem(item); +export function upsertActivityItem(item: AttentionItem): void { + activityStore.getState().upsertItem(item); } -export function removeAttentionItem(tombstone: AttentionTombstone): void { - attentionStore.getState().removeItem(tombstone); +export function removeActivityItem(tombstone: AttentionTombstone): void { + activityStore.getState().removeItem(tombstone); } -export function resetAttentionStoreForTests(): void { - attentionStore.setState(createInitialState()); +export function resetActivityStoreForTests(): void { + activityStore.setState(createInitialState()); } -function attentionErrorMessage(error: unknown): string { +function activityErrorMessage(error: unknown): string { if (error instanceof Error && error.message.trim()) return error.message.trim(); - return "ADE couldn’t update this attention item."; + return "ADE couldn’t update this Activity item."; } -export async function acknowledgeAttentionItem( +export async function acknowledgeActivityItem( itemId: string, kind: "seen" | "dismiss", ): Promise { - const before = attentionStore.getState(); + const before = activityStore.getState(); const item = before.itemsById[itemId]; if (!item || before.pendingAcknowledgements[itemId]) return; const timestamp = new Date().toISOString(); - const pending: PendingAttentionAcknowledgement = { + const pending: PendingActivityAcknowledgement = { previous: item, seenAt: timestamp, ...(kind === "dismiss" ? { dismissedAt: timestamp } : {}), }; - attentionStore.setState((state) => { + activityStore.setState((state) => { const acknowledgementErrors = { ...state.acknowledgementErrors }; delete acknowledgementErrors[itemId]; return { @@ -348,8 +303,8 @@ export async function acknowledgeAttentionItem( acknowledgementErrors, }; }); - if (kind === "dismiss") attentionStore.getState().dismiss(itemId, timestamp); - else attentionStore.getState().markSeen(itemId, timestamp); + if (kind === "dismiss") activityStore.getState().dismiss(itemId, timestamp); + else activityStore.getState().markSeen(itemId, timestamp); try { const api = typeof window !== "undefined" ? window.ade?.attention : null; @@ -362,14 +317,14 @@ export async function acknowledgeAttentionItem( ...(kind === "dismiss" ? { dismissedAt: timestamp } : {}), }); } - attentionStore.setState((state) => { + activityStore.setState((state) => { const pendingAcknowledgements = { ...state.pendingAcknowledgements }; delete pendingAcknowledgements[itemId]; return { pendingAcknowledgements }; }); } catch (error) { - const message = attentionErrorMessage(error); - attentionStore.setState((state) => { + const message = activityErrorMessage(error); + activityStore.setState((state) => { const currentPending = state.pendingAcknowledgements[itemId]; if (!currentPending || currentPending.seenAt !== timestamp) return state; const current = state.itemsById[itemId]; @@ -394,7 +349,6 @@ export async function acknowledgeAttentionItem( ...state.acknowledgementErrors, [itemId]: message, }, - selectedItemId: canRollback ? itemId : state.selectedItemId, }; }); throw error; diff --git a/apps/desktop/src/renderer/state/crossMachineLanes.ts b/apps/desktop/src/renderer/state/crossMachineLanes.ts index 649de64a0..018c5258f 100644 --- a/apps/desktop/src/renderer/state/crossMachineLanes.ts +++ b/apps/desktop/src/renderer/state/crossMachineLanes.ts @@ -196,7 +196,7 @@ export type CrossMachineUnion = { * implicit in a second component. * * The glyph is amber, in an amber pill. Amber is machine identity everywhere in - * ADE (top bar, connections panel, attention center, session hover card), and + * ADE (top bar, connections panel, Activity pane, session hover card), and * `SessionCard` states the rule directly: amber appears exactly once per row, on * the machine tower, because that glyph is identity and never status. */ diff --git a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts index b2c7cd18c..0a4a2247e 100644 --- a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts +++ b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts @@ -66,6 +66,7 @@ describe("createAdeWebAdapter", () => { it("boots before and after a bound project", async () => { const adapter = createAdeWebAdapter(fake.asClient()); + expect("attentionNotch" in adapter.ade).toBe(false); await expect(adapter.ade.app.getProject()).resolves.toBeNull(); await expect(adapter.ade.app.getWindowSession()).resolves.toMatchObject({ windowId: null, @@ -227,7 +228,7 @@ describe("createAdeWebAdapter", () => { adapter.dispose(); }); - it("reads account Attention directly and refreshes one rejected browser token", async () => { + it("reads account Activity directly and refreshes one rejected browser token", async () => { const snapshot: BrowserAccountSnapshot = { state: "signed_in", userId: "account-a", @@ -287,7 +288,7 @@ describe("createAdeWebAdapter", () => { adapter.dispose(); }); - it("rejects malformed account Attention responses at the relay boundary", async () => { + it("rejects malformed account Activity responses at the relay boundary", async () => { const snapshot: BrowserAccountSnapshot = { state: "signed_in", userId: "account-a", @@ -317,12 +318,12 @@ describe("createAdeWebAdapter", () => { const adapter = createAdeWebAdapter(fake.asClient(), undefined, accountClient); await expect(adapter.ade.attention.getSnapshot()).rejects.toThrow( - "ADE Attention returned an incompatible response. Update ADE and retry.", + "ADE Activity returned an incompatible response. Update ADE and retry.", ); adapter.dispose(); }); - it("validates account Attention preferences before exposing them to the renderer", async () => { + it("validates account Activity preferences before exposing them to the renderer", async () => { const snapshot: BrowserAccountSnapshot = { state: "signed_in", userId: "account-a", @@ -341,10 +342,24 @@ describe("createAdeWebAdapter", () => { isSessionLeaseCurrent: () => true, getAccessToken: vi.fn(async () => "account-token"), } as unknown as BrowserAccountClient; + const { + dockBadgeScope: _legacyDockBadgeScope, + ...legacyAccount + } = DEFAULT_ATTENTION_PREFERENCES.account; + const { + machines: _legacyMachines, + ...legacyPreferences + } = DEFAULT_ATTENTION_PREFERENCES; const fetchMock = vi.fn() .mockResolvedValueOnce(new Response(JSON.stringify({ preferences: DEFAULT_ATTENTION_PREFERENCES, }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + preferences: { + ...legacyPreferences, + account: legacyAccount, + }, + }), { status: 200 })) .mockResolvedValueOnce(new Response(JSON.stringify({ preferences: { ...DEFAULT_ATTENTION_PREFERENCES, @@ -354,16 +369,105 @@ describe("createAdeWebAdapter", () => { vi.stubGlobal("fetch", fetchMock); const adapter = createAdeWebAdapter(fake.asClient(), undefined, accountClient); + await expect(adapter.ade.attention.getPreferences("account-a")) + .resolves.toEqual(DEFAULT_ATTENTION_PREFERENCES); await expect(adapter.ade.attention.getPreferences("account-a")) .resolves.toEqual(DEFAULT_ATTENTION_PREFERENCES); await expect(adapter.ade.attention.getPreferences("account-a")) .rejects.toThrow( - "Account Attention preferences were incompatible. Update ADE and retry.", + "Activity preferences were incompatible. Update ADE and retry.", ); adapter.dispose(); }); - it("loads real machine Attention from the paired host while signed out", async () => { + it("strips device and machine overrides from account preference saves", async () => { + const snapshot: BrowserAccountSnapshot = { + state: "signed_in", + userId: "account-a", + email: "owner@example.test", + name: "Owner", + imageUrl: null, + expiresAt: "2026-07-30T00:00:00.000Z", + machines: [], + relayBaseUrls: ["wss://relay.example"], + message: null, + }; + const accountClient = { + getSnapshot: () => snapshot, + captureSessionLease: () => ({ userId: "account-a", generation: 1 }), + isSessionLeaseCurrent: () => true, + getAccessToken: vi.fn(async () => "account-token"), + } as unknown as BrowserAccountClient; + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { + status: 200, + })); + vi.stubGlobal("fetch", fetchMock); + const adapter = createAdeWebAdapter(fake.asClient(), undefined, accountClient); + + await adapter.ade.attention.putPreferences("account-a", { + ...DEFAULT_ATTENTION_PREFERENCES, + devices: { browser: { notificationsEnabled: false } }, + machines: { studio: { notificationsEnabled: false } }, + }); + + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(JSON.parse(String(init.body))).toEqual({ + account: DEFAULT_ATTENTION_PREFERENCES.account, + projects: DEFAULT_ATTENTION_PREFERENCES.projects, + mutedSessionIds: DEFAULT_ATTENTION_PREFERENCES.mutedSessionIds, + }); + adapter.dispose(); + }); + + it("patches one machine's notification mute without rewriting the account document", async () => { + const snapshot: BrowserAccountSnapshot = { + state: "signed_in", + userId: "account-a", + email: "owner@example.test", + name: "Owner", + imageUrl: null, + expiresAt: "2026-07-30T00:00:00.000Z", + machines: [], + relayBaseUrls: ["wss://relay.example"], + message: null, + }; + const accountClient = { + getSnapshot: () => snapshot, + captureSessionLease: () => ({ userId: "account-a", generation: 1 }), + isSessionLeaseCurrent: () => true, + getAccessToken: vi.fn(async () => "account-token"), + } as unknown as BrowserAccountClient; + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { + status: 200, + })); + vi.stubGlobal("fetch", fetchMock); + const adapter = createAdeWebAdapter(fake.asClient(), undefined, accountClient); + + await expect(adapter.ade.attention.putMachinePreferences!( + "account-a", + "studio mac/1", + { notificationsEnabled: false }, + )).resolves.toBeUndefined(); + + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + // The machine key goes in the path, so it has to survive a space and a slash. + expect(url).toContain("/attention/account/preferences/machines/studio%20mac%2F1"); + expect(init.method).toBe("PATCH"); + expect(JSON.parse(String(init.body))).toEqual({ notificationsEnabled: false }); + expect(init.headers).toMatchObject({ authorization: "Bearer account-token" }); + + // A machine mute written after an account switch would land on the wrong + // account's preferences entirely. + await expect(adapter.ade.attention.putMachinePreferences!( + "account-b", + "studio", + { notificationsEnabled: false }, + )).rejects.toThrow(/account changed/i); + expect(fetchMock).toHaveBeenCalledTimes(1); + adapter.dispose(); + }); + + it("loads real machine Activity from the paired host while signed out", async () => { const snapshot: BrowserAccountSnapshot = { state: "signed_out", userId: null, @@ -423,7 +527,7 @@ describe("createAdeWebAdapter", () => { }, project: { projectId: "project-host", name: "Host Project" }, title: "Agent is working", - preview: "Implementing account Attention", + preview: "Implementing account Activity", privacyPreview: "Agent is working", destination: { kind: "session", sessionId: "session-host" }, actions: [], @@ -516,7 +620,7 @@ describe("createAdeWebAdapter", () => { adapter.dispose(); }); - it("rejects malformed signed-out machine Attention from the paired host", async () => { + it("rejects malformed signed-out machine Activity from the paired host", async () => { const snapshot: BrowserAccountSnapshot = { state: "signed_out", userId: null, @@ -546,12 +650,12 @@ describe("createAdeWebAdapter", () => { const adapter = createAdeWebAdapter(fake.asClient(), undefined, accountClient); await expect(adapter.ade.attention.getSnapshot()).rejects.toThrow( - "ADE Attention returned an incompatible response. Update ADE and retry.", + "ADE Activity returned an incompatible response. Update ADE and retry.", ); adapter.dispose(); }); - it("connects the owning account machine before opening an Attention destination", async () => { + it("connects the owning account machine before opening an Activity destination", async () => { const ownerMachine = { machineKey: "account-machine-studio", deviceId: "host-studio", diff --git a/apps/desktop/src/renderer/webclient/adapter/attention.ts b/apps/desktop/src/renderer/webclient/adapter/attention.ts index f5f21d3cb..aeb856626 100644 --- a/apps/desktop/src/renderer/webclient/adapter/attention.ts +++ b/apps/desktop/src/renderer/webclient/adapter/attention.ts @@ -1,5 +1,7 @@ import { ATTENTION_CONTRACT_VERSION, + ATTENTION_EVENT_KINDS, + ATTENTION_PHASES, DEFAULT_ATTENTION_PREFERENCES, attentionDestinationDeepLink, type AttentionAction, @@ -32,37 +34,6 @@ type RelayResult = { body: unknown; }; -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_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", -]); - function record(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record @@ -179,8 +150,14 @@ function parseAttentionItem(value: unknown): AttentionItem | null { || !Number.isInteger(candidate.revision) || typeof candidate.fingerprint !== "string" || !["agent", "pull_request"].includes(String(candidate.kind)) - || !ATTENTION_EVENT_KINDS.has(candidate.eventKind as AttentionEventKind) - || !ATTENTION_PHASES.has(candidate.phase as AttentionPhase) + || !ATTENTION_EVENT_KINDS.includes(candidate.eventKind as AttentionEventKind) + || !ATTENTION_PHASES.includes(candidate.phase as AttentionPhase) + || ( + candidate.activityTier !== undefined + && !["signal", "ambient", "idle"].includes(String(candidate.activityTier)) + ) + || (candidate.contentFingerprint !== undefined && typeof candidate.contentFingerprint !== "string") + || (candidate.alertFingerprint !== undefined && typeof candidate.alertFingerprint !== "string") || !machine || !project || !destination @@ -198,6 +175,7 @@ function parseAttentionItem(value: unknown): AttentionItem | null { || !progressValid || typeof candidate.occurredAt !== "string" || typeof candidate.updatedAt !== "string" + || !optionalString(candidate.statusSince) || !optionalString(candidate.seenAt) || !optionalString(candidate.dismissedAt) || !optionalString(candidate.expiresAt) @@ -249,9 +227,10 @@ function parseAttentionSnapshot(value: unknown): AttentionSnapshot { || tombstones.some((item) => !item) || machines === null || machines?.some((machine) => !machine) + || (candidate.itemsTruncated !== undefined && typeof candidate.itemsTruncated !== "boolean") ) { throw new Error( - "ADE Attention returned an incompatible response. Update ADE and retry.", + "ADE Activity returned an incompatible response. Update ADE and retry.", ); } return { @@ -262,6 +241,7 @@ function parseAttentionSnapshot(value: unknown): AttentionSnapshot { generatedAt: candidate.generatedAt, machines: machines as AttentionMachineRef[] | undefined, items: items as AttentionItem[], + itemsTruncated: candidate.itemsTruncated as boolean | undefined, tombstones: tombstones as AttentionTombstone[], }; } @@ -279,9 +259,9 @@ function isPreferenceScope(value: unknown, partial = false): value is AttentionP required("eventPolicies", (field) => { const policies = record(field); if (!policies) return false; - return (partial || [...ATTENTION_EVENT_KINDS].every((kind) => kind in policies)) + return (partial || ATTENTION_EVENT_KINDS.every((kind) => kind in policies)) && Object.entries(policies).every(([kind, policy]) => - ATTENTION_EVENT_KINDS.has(kind as AttentionEventKind) + ATTENTION_EVENT_KINDS.includes(kind as AttentionEventKind) && ["off", "ambient", "notify"].includes(String(policy))); }) && required("notificationsEnabled", (field) => typeof field === "boolean") @@ -292,6 +272,7 @@ function isPreferenceScope(value: unknown, partial = false): value is AttentionP && required("soundsEnabled", (field) => typeof field === "boolean") && required("celebrationsEnabled", (field) => typeof field === "boolean") && required("hideDetails", (field) => typeof field === "boolean") + && required("dockBadgeScope", (field) => field === "local" || field === "account") && required("quietHours", (field) => { const quietHours = record(field); return Boolean( @@ -307,23 +288,34 @@ function isPreferenceScope(value: unknown, partial = false): value is AttentionP function parseAttentionPreferences(value: unknown): AttentionPreferences { const candidate = record(value); + const account = record(candidate?.account); + const normalizedAccount = account && account.dockBadgeScope === undefined + ? { ...account, dockBadgeScope: "local" } + : account; const devices = record(candidate?.devices); + const machines = candidate?.machines === undefined ? {} : record(candidate.machines); const projects = record(candidate?.projects); if ( !candidate - || !isPreferenceScope(candidate.account) + || !isPreferenceScope(normalizedAccount) || !devices || !Object.values(devices).every((scope) => isPreferenceScope(scope, true)) + || !machines + || !Object.values(machines).every((scope) => isPreferenceScope(scope, true)) || !projects || !Object.values(projects).every((scope) => isPreferenceScope(scope, true)) || !Array.isArray(candidate.mutedSessionIds) || !candidate.mutedSessionIds.every((id) => typeof id === "string") ) { throw new Error( - "Account Attention preferences were incompatible. Update ADE and retry.", + "Activity preferences were incompatible. Update ADE and retry.", ); } - return candidate as AttentionPreferences; + return { + ...candidate, + account: normalizedAccount, + machines, + } as AttentionPreferences; } function relayBaseUrl(): string { @@ -340,7 +332,7 @@ function relayError(action: string, result: RelayResult): Error { : typeof body?.error === "string" ? body.error : `HTTP ${result.response.status}`; - return new Error(`Account Attention ${action} failed. ${reason}`); + return new Error(`Activity ${action} failed. ${reason}`); } export function createAttentionNamespace( @@ -368,7 +360,7 @@ export function createAttentionNamespace( availability: { state: "incompatible", title: `${hostName} needs an ADE update`, - message: `Update ADE on ${hostName}, then reconnect to load this machine's Attention.`, + message: `Update ADE on ${hostName}, then reconnect to load this machine's Activity.`, recovery: "update_host", hostName, }, @@ -382,16 +374,16 @@ export function createAttentionNamespace( const request = async ( action: string, - method: "GET" | "POST" | "PUT", + method: "GET" | "POST" | "PUT" | "PATCH", path: string, body?: unknown, ): Promise => { const lease = accountClient.captureSessionLease(); - if (!lease) throw new Error("Sign in to use account-wide Attention."); + if (!lease) throw new Error("Sign in to use account-wide Activity."); const requestOnce = async (forceRefresh: boolean): Promise => { const accessToken = await accountClient.getAccessToken({ forceRefresh }); if (!accountClient.isSessionLeaseCurrent(lease)) { - throw new Error("The ADE account changed before Attention could load."); + throw new Error("The ADE account changed before Activity could load."); } const response = await fetch(`${relayBaseUrl()}${path}`, { method, @@ -438,7 +430,7 @@ export function createAttentionNamespace( availability: { state: "signed_out", title: `Showing ${hostName} only`, - message: `Attention from ${hostName} is available. Sign in to combine work across every ADE machine.`, + message: `Activity from ${hostName} is available. Sign in to combine work across every ADE machine.`, recovery: "sign_in", hostName, }, @@ -459,7 +451,7 @@ export function createAttentionNamespace( accountOwnerId: accountClient.getSnapshot().userId?.trim() || null, availability: { state: "ready", - title: "Account Attention is live", + title: "Activity is live", message: "Work from every signed-in ADE machine is available.", recovery: null, }, @@ -473,7 +465,7 @@ export function createAttentionNamespace( : null; if (currentAccountOwnerId !== lastSnapshotAccountOwnerId) { throw new Error( - "The ADE account changed after Attention loaded. Refresh Attention, then try again.", + "The ADE account changed after Activity loaded. Refresh Activity, then try again.", ); } if (lastSnapshotScope === "machine") { @@ -482,7 +474,7 @@ export function createAttentionNamespace( || args.itemIds.some((itemId) => !lastMachineItemIds.has(itemId)) ) { throw new Error( - "Refresh this machine's Attention before acknowledging the item.", + "Refresh this machine's Activity before acknowledging the item.", ); } if ( @@ -491,13 +483,13 @@ export function createAttentionNamespace( !Number.isFinite(args.sourceRevisions?.[itemId])) ) { throw new Error( - "Refresh this machine's Attention before acknowledging a changed item.", + "Refresh this machine's Activity before acknowledging a changed item.", ); } if (!infra.commands.hasAction("attention.acknowledgeMachine")) { const hostName = infra.client.getStatus().hostName?.trim() || "the connected ADE host"; throw new Error( - `Update ADE on ${hostName}, reconnect, then try this Attention action again.`, + `Update ADE on ${hostName}, reconnect, then try this Activity action again.`, ); } await infra.commands.call( @@ -516,7 +508,7 @@ export function createAttentionNamespace( return; } if (lastSnapshotScope !== "account" || !currentAccountOwnerId) { - throw new Error("Refresh account Attention before acknowledging this item."); + throw new Error("Refresh account Activity before acknowledging this item."); } await request("acknowledgment", "POST", "/attention/account/ack", args); }, @@ -529,7 +521,7 @@ export function createAttentionNamespace( async getPreferences(accountOwnerId: string) { const owner = accountClient.getSnapshot().userId?.trim() ?? ""; if (!owner || owner !== accountOwnerId.trim()) { - throw new Error("The ADE account changed before Attention preferences could load."); + throw new Error("The ADE account changed before Activity settings could load."); } const result = record(await request( "preferences", @@ -547,9 +539,13 @@ export function createAttentionNamespace( ) { const owner = accountClient.getSnapshot().userId?.trim() ?? ""; if (!owner || owner !== accountOwnerId.trim()) { - throw new Error("The ADE account changed before Attention preferences could be saved."); + throw new Error("The ADE account changed before Activity settings could be saved."); } - const { devices: _deviceOverrides, ...accountPreferences } = preferences; + const { + devices: _deviceOverrides, + machines: _machineOverrides, + ...accountPreferences + } = preferences; await request( "preference update", "PUT", @@ -558,6 +554,31 @@ export function createAttentionNamespace( ); }, + /** + * Per-machine notification mute. It has its own relay route rather than + * riding the preferences PUT because that PUT strips `devices` and + * `machines` before replacing the account document — a partial machine + * scope written that way would race every other tab editing preferences. + */ + async putMachinePreferences( + accountOwnerId: string, + machineKey: string, + preferences: Partial, + ) { + const owner = accountClient.getSnapshot().userId?.trim() ?? ""; + if (!owner || owner !== accountOwnerId.trim()) { + throw new Error("The ADE account changed before Activity settings could be saved."); + } + const key = machineKey.trim(); + if (!key) throw new Error("A machine is required to change its notifications."); + await request( + "machine preference update", + "PATCH", + `/attention/account/preferences/machines/${encodeURIComponent(key)}`, + preferences, + ); + }, + async openItem(item: AttentionItem) { const accountSnapshot = accountClient.getSnapshot(); const ownerMachineKey = item.machine.accountMachineKey?.trim() ?? ""; @@ -569,7 +590,7 @@ export function createAttentionNamespace( const currentHostDeviceId = infra.client.getStatus().hostDeviceId?.trim() ?? ""; if (ownerMachine && ownerMachine.deviceId !== currentHostDeviceId) { const lease = accountClient.captureSessionLease(); - if (!lease) throw new Error("Sign in again to open this Attention item."); + if (!lease) throw new Error("Sign in again to open this Activity item."); const accessToken = await accountClient.getAccessToken(); await infra.client.pairWithAccountMachine({ machine: ownerMachine, @@ -606,7 +627,7 @@ export function createAttentionNamespace( } } const parsed = parseDeeplink(attentionDestinationDeepLink(item.destination, item)); - if (!parsed.ok) throw new Error("This Attention destination is invalid."); + if (!parsed.ok) throw new Error("This Activity destination is invalid."); infra.events.emit("navigate", { target: deeplinkToNavigationTarget(parsed.target), source: "attention", diff --git a/apps/desktop/src/renderer/webclient/adapter/index.ts b/apps/desktop/src/renderer/webclient/adapter/index.ts index 08092b712..7749c87f8 100644 --- a/apps/desktop/src/renderer/webclient/adapter/index.ts +++ b/apps/desktop/src/renderer/webclient/adapter/index.ts @@ -33,22 +33,6 @@ export type AdeWebAdapter = { dispose(): void; }; -export const WEB_HIDDEN_CAPABILITIES = { - revealInFinder: false, - externalEditor: false, - updater: false, - builtInBrowser: false, - appControl: false, - iosSimulator: false, - computerUse: false, - nativeWindowControls: false, - nativeDirectoryPicker: false, - localPathOpen: false, - cursorCloud: false, - transcription: false, - automations: false, -} as const; - const DOMAIN_EVENTS = { lanes: "lanesInvalidated", sessions: "sessionsInvalidated", diff --git a/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx b/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx index ecb690949..bff2ac345 100644 --- a/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx +++ b/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx @@ -60,6 +60,11 @@ const PENDING_TARGET_KEY = "ade-web:pending-target"; const ACCOUNT_LEASE_CHECK_INTERVAL_MS = 30_000; const APP_ROUTE_ROOTS = [ "/work", + // Activity is a modal, but its pathname is a real deep link the shell turns + // back into one. Without these two a hard reload on the hosted client drops + // the user at the sign-in shell instead of the app. + "/activity", + "/attention", "/lanes", "/files", "/prs", diff --git a/apps/desktop/src/shared/activityCatalog.test.ts b/apps/desktop/src/shared/activityCatalog.test.ts new file mode 100644 index 000000000..a2fcb6b55 --- /dev/null +++ b/apps/desktop/src/shared/activityCatalog.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { + ACTIVITY_EVENT_BY_KIND, + ACTIVITY_EVENT_CATALOG, +} from "./activityCatalog"; +import { + ATTENTION_EVENT_KINDS, + BALANCED_ATTENTION_EVENT_POLICIES, +} from "./types/attention"; + +describe("Activity event catalog", () => { + it("covers every Attention event kind exactly once", () => { + const kinds = ACTIVITY_EVENT_CATALOG.map((descriptor) => descriptor.kind); + expect(kinds).toHaveLength(11); + expect(new Set(kinds).size).toBe(11); + expect(kinds.slice().sort()).toEqual([...ATTENTION_EVENT_KINDS].sort()); + expect(Object.keys(ACTIVITY_EVENT_BY_KIND).sort()).toEqual([...ATTENTION_EVENT_KINDS].sort()); + }); + + it("derives the balanced defaults from catalog policy", () => { + expect(BALANCED_ATTENTION_EVENT_POLICIES).toEqual(Object.fromEntries( + ACTIVITY_EVENT_CATALOG.map(({ kind, defaultPolicy }) => [kind, defaultPolicy]), + )); + }); +}); diff --git a/apps/desktop/src/shared/activityCatalog.ts b/apps/desktop/src/shared/activityCatalog.ts new file mode 100644 index 000000000..998828191 --- /dev/null +++ b/apps/desktop/src/shared/activityCatalog.ts @@ -0,0 +1,156 @@ +import type { + AttentionDeliveryPolicy, + AttentionEventKind, +} from "./types/attention"; + +export type ActivityEventGroup = "agents" | "pull_requests"; + +export type ActivityIconKey = + | "working" + | "needs-you" + | "failed" + | "done" + | "checks" + | "review" + | "changes" + | "merge-ready" + | "pull-request" + | "closed"; + +export type ActivityEventDescriptor = { + kind: AttentionEventKind; + group: ActivityEventGroup; + label: string; + description: string; + iconKey: ActivityIconKey; + defaultPolicy: AttentionDeliveryPolicy; + supportsAmbient: boolean; + order: number; +}; + +export const ACTIVITY_EVENT_GROUPS = [ + { id: "agents", label: "Agents" }, + { id: "pull_requests", label: "Pull requests" }, +] as const satisfies readonly { id: ActivityEventGroup; label: string }[]; + +/** + * One ordered source of truth for every event ADE can put in Activity. + * Existing Notifications copy stays verbatim so adopting the catalog is a + * structural refactor rather than a settings-page copy change. + */ +export const ACTIVITY_EVENT_CATALOG = [ + { + kind: "agent_needs_you", + group: "agents", + label: "Agent asks a question", + description: "A run is blocked waiting on your answer.", + iconKey: "needs-you", + defaultPolicy: "notify", + supportsAmbient: true, + order: 0, + }, + { + kind: "agent_failed", + group: "agents", + label: "Agent fails", + description: "A run stopped on an error.", + iconKey: "failed", + defaultPolicy: "notify", + supportsAmbient: true, + order: 1, + }, + { + kind: "agent_completed", + group: "agents", + label: "Agent finishes", + description: "A run reached the end of its turn.", + iconKey: "done", + defaultPolicy: "ambient", + supportsAmbient: true, + order: 2, + }, + { + kind: "agent_running", + group: "agents", + label: "Agent starts working", + description: "A run picked up your request.", + iconKey: "working", + defaultPolicy: "ambient", + supportsAmbient: true, + order: 3, + }, + { + kind: "pr_checks_failing", + group: "pull_requests", + label: "CI fails", + description: "Checks went red on one of your PRs.", + iconKey: "checks", + defaultPolicy: "notify", + supportsAmbient: true, + order: 4, + }, + { + kind: "pr_review_requested", + group: "pull_requests", + label: "Review requested", + description: "Someone asked you to review.", + iconKey: "review", + defaultPolicy: "notify", + supportsAmbient: true, + order: 5, + }, + { + kind: "pr_changes_requested", + group: "pull_requests", + label: "Changes requested", + description: "A reviewer asked for changes.", + iconKey: "changes", + defaultPolicy: "notify", + supportsAmbient: true, + order: 6, + }, + { + kind: "pr_merge_ready", + group: "pull_requests", + label: "PR ready to merge", + description: "Checks passed and reviews are in.", + iconKey: "merge-ready", + defaultPolicy: "notify", + supportsAmbient: true, + order: 7, + }, + { + kind: "pr_merged", + group: "pull_requests", + label: "PR merged", + description: "One of your PRs landed.", + iconKey: "done", + defaultPolicy: "ambient", + supportsAmbient: true, + order: 8, + }, + { + kind: "pr_opened", + group: "pull_requests", + label: "PR opened", + description: "One of your pull requests was opened.", + iconKey: "pull-request", + defaultPolicy: "ambient", + supportsAmbient: true, + order: 9, + }, + { + kind: "pr_closed", + group: "pull_requests", + label: "PR closed", + description: "One of your pull requests closed without merging.", + iconKey: "closed", + defaultPolicy: "ambient", + supportsAmbient: true, + order: 10, + }, +] as const satisfies readonly ActivityEventDescriptor[]; + +export const ACTIVITY_EVENT_BY_KIND = Object.fromEntries( + ACTIVITY_EVENT_CATALOG.map((descriptor) => [descriptor.kind, descriptor]), +) as Readonly>; diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index aa3cb5153..56c64c9ff 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -39,6 +39,7 @@ export const IPC = { appOpenPathInEditor: "ade.app.openPathInEditor", appLogDebugEvent: "ade.app.logDebugEvent", attentionNotchPublishSnapshot: "ade.attentionNotch.publishSnapshot", + attentionNotchPublishToast: "ade.attentionNotch.publishToast", attentionNotchUpdateSettings: "ade.attentionNotch.updateSettings", attentionNotchGetHealth: "ade.attentionNotch.getHealth", attentionNotchRetry: "ade.attentionNotch.retry", @@ -50,6 +51,7 @@ export const IPC = { attentionReportPresence: "ade.attention.reportPresence", attentionGetPreferences: "ade.attention.getPreferences", attentionPutPreferences: "ade.attention.putPreferences", + attentionPutMachinePreferences: "ade.attention.putMachinePreferences", attentionOpenItem: "ade.attention.openItem", analyticsCapture: "ade.analytics.capture", analyticsGetStatus: "ade.analytics.getStatus", diff --git a/apps/desktop/src/shared/types/attention.test.ts b/apps/desktop/src/shared/types/attention.test.ts index 7dfb31d69..262fee373 100644 --- a/apps/desktop/src/shared/types/attention.test.ts +++ b/apps/desktop/src/shared/types/attention.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import { ATTENTION_CONTRACT_VERSION, + DEFAULT_ATTENTION_PREFERENCES, + activityItemIsAmbient, + activityItemTier, attentionDestinationDeepLink, attentionItemNeedsInbox, sanitizeAttentionPreview, @@ -55,6 +58,24 @@ describe("attention contract helpers", () => { expect(attentionItemNeedsInbox(item({ dismissedAt: "2026-07-28T10:05:00.000Z" }))).toBe(false); }); + it("keeps idle roster rows out of Inbox and derives legacy tiers by phase", () => { + const idleOutcome = item({ + phase: "completed", + eventKind: "agent_completed", + activityTier: "idle", + }); + expect(attentionItemNeedsInbox(idleOutcome)).toBe(false); + expect(activityItemTier(idleOutcome)).toBe("idle"); + expect(activityItemIsAmbient(idleOutcome)).toBe(true); + expect(activityItemTier(item({ phase: "needs_you" }))).toBe("signal"); + expect(activityItemTier(item({ phase: "running", eventKind: "agent_running" }))).toBe("ambient"); + }); + + it("defaults machine overrides empty and the dock badge to this Mac", () => { + expect(DEFAULT_ATTENTION_PREFERENCES.machines).toEqual({}); + expect(DEFAULT_ATTENTION_PREFERENCES.account.dockBadgeScope).toBe("local"); + }); + it("builds exact session and PR deep links", () => { expect(attentionDestinationDeepLink({ kind: "session", diff --git a/apps/desktop/src/shared/types/attention.ts b/apps/desktop/src/shared/types/attention.ts index 0d34048da..25a7e38c8 100644 --- a/apps/desktop/src/shared/types/attention.ts +++ b/apps/desktop/src/shared/types/attention.ts @@ -1,35 +1,43 @@ +import { ACTIVITY_EVENT_CATALOG } from "../activityCatalog"; + 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 const ATTENTION_PHASES = [ + "starting", + "running", + "needs_you", + "blocked", + "failed", + "completed", + "stale", + "checks_failing", + "review_requested", + "changes_requested", + "merge_ready", + "open", + "merged", + "closed", +] as const; + +export type AttentionPhase = (typeof ATTENTION_PHASES)[number]; + +export const ATTENTION_EVENT_KINDS = [ + "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", +] as const; + +export type AttentionEventKind = (typeof ATTENTION_EVENT_KINDS)[number]; export type AttentionDeliveryPolicy = "off" | "ambient" | "notify"; @@ -95,6 +103,12 @@ export type AttentionItem = { id: string; revision: number; fingerprint: string; + /** Alert eligibility and Activity filing. Absent on legacy items. */ + activityTier?: "signal" | "ambient" | "idle"; + /** Stable identity for row-content changes. */ + contentFingerprint?: string; + /** Stable identity for alert deduplication. */ + alertFingerprint?: string; kind: AttentionItemKind; eventKind: AttentionEventKind; phase: AttentionPhase; @@ -118,17 +132,77 @@ export type AttentionItem = { actions: AttentionAction[]; occurredAt: string; updatedAt: string; + /** Immutable timestamp for the current phase, when the publisher has one. */ + statusSince?: string | null; seenAt: string | null; dismissedAt: string | null; expiresAt: string | null; }; +/** + * Attention's tone vocabulary is `sessionStatusPresentation`'s five hues plus + * two that only pull requests ever use. The session five keep their meanings + * exactly — see the one-hue-one-meaning rule in + * `apps/desktop/src/shared/sessionStatusPresentation.ts`: + * + * blue work is happening, nothing is asked of you + * amber YOUR MOVE — and nothing else, ever + * emerald finished cleanly, you have not looked yet + * red it broke + * neutral true, but not actionable + * + * `violet` carries "a human review is outstanding" — neither "your move" (it is + * usually someone else's) nor an outcome, and without its own hue it would have + * to borrow amber, which is precisely the erosion the rule forbids. `cyan` is + * currently unused by any phase; it stays in the union and the stylesheets as + * the spare for the next PR-side distinction, and must never be handed to a + * session state — those five hues are settled. + * + * It lives here rather than beside the phase table because the native notch + * protocol carries it on the wire (`NotchStatusTone` in `AttentionModels.swift` + * mirrors this union), so main-process code has to name it too. + */ +export type AttentionTone = + | "amber" + | "red" + | "violet" + | "blue" + | "cyan" + | "emerald" + | "neutral"; + +export const ATTENTION_TONES: readonly AttentionTone[] = [ + "amber", + "red", + "violet", + "blue", + "cyan", + "emerald", + "neutral", +]; + export type AttentionTombstone = { id: string; revision: number; deletedAt: string; }; +/** + * The whole account's shape, sent alongside a bounded projection of its items. + * + * Load-bearing: the renderer publishes only the top-priority slice to stay + * inside the native pipe's byte budget, so "5 working · 2 need you · 61 total" + * can only be honest if the totals travel separately from the rows. + */ +export type AttentionCounts = { + needsYou: number; + working: number; + done: number; + total: number; + machinesOnline: number; + machinesTotal: number; +}; + export type AttentionSnapshot = { contractVersion: typeof ATTENTION_CONTRACT_VERSION; /** Where this snapshot was sourced. Account is canonical; machine is fallback. */ @@ -160,6 +234,13 @@ export type AttentionSnapshot = { /** Current account-machine presence, returned even when no items changed. */ machines?: AttentionMachineRef[]; items: AttentionItem[]; + itemsTruncated?: boolean; + /** + * Totals over the full item set, so a surface receiving a truncated + * projection can still state how much work the account actually has. + * Optional: publishers older than this build omit it. + */ + counts?: AttentionCounts; tombstones?: AttentionTombstone[]; }; @@ -182,6 +263,19 @@ export type AttentionPreferenceScope = { soundsEnabled: boolean; celebrationsEnabled: boolean; hideDetails: boolean; + dockBadgeScope: "local" | "account"; + /** + * Notch presentation, synced so a second Mac inherits the choice instead of + * starting from the shipped default. Optional because every relay and + * publisher older than this build omits them, and because localStorage + * remains the offline cache of record — readers take the synced value when + * it is present and the local one otherwise. The localStorage key strings + * are unchanged; only the source of truth moved. + */ + notchRevealMode?: AttentionNotchRevealMode; + notchExpandedPanel?: boolean; + notchAutomaticReveal?: boolean; + notchTicker?: boolean; quietHours: { enabled: boolean; startMinute: number; @@ -193,6 +287,7 @@ export type AttentionPreferenceScope = { export type AttentionPreferences = { account: AttentionPreferenceScope; devices: Record>; + machines: Record>; projects: Record>; mutedSessionIds: string[]; }; @@ -227,6 +322,45 @@ export function isAttentionNotchRevealMode( ); } +/** + * Per-kind delight for an event that just happened, rendered by the native + * surface as a transient rather than a row. `celebration` earns the confetti; + * everything else rides the alert layout with a calmer tone. + */ +export type AttentionNotchToastTreatment = + | "celebration" + | "success" + | "alert" + | "info"; + +export const ATTENTION_NOTCH_TOAST_TREATMENTS: readonly AttentionNotchToastTreatment[] = [ + "celebration", + "success", + "alert", + "info", +]; + +/** + * A one-shot event pushed to the native notch. Unlike every other helper + * command this is not state-setting: it is never replayed on restart, because + * a toast for something that happened before the crash is a lie. + */ +export type AttentionNotchToast = { + itemId?: string | null; + eventKind: AttentionEventKind; + treatment: AttentionNotchToastTreatment; + title: string; + subtitle?: string | null; + /** Host-chosen hue; the native side falls back to the treatment's own. */ + tone?: AttentionTone | null; + /** Natively clamped to 800..15000; out-of-range values are rejected here. */ + durationMs?: number | null; +}; + +/** Matches the native clamp, so the router can reject rather than silently bend. */ +export const ATTENTION_NOTCH_TOAST_MIN_DURATION_MS = 800; +export const ATTENTION_NOTCH_TOAST_MAX_DURATION_MS = 15_000; + export type AttentionNotchSettings = { enabled: boolean; revealMode: AttentionNotchRevealMode; @@ -235,6 +369,10 @@ export type AttentionNotchSettings = { * never grow far enough to sit over menu-bar content. */ expandedPanelEnabled: boolean; + /** Whether an event may pop the surface out on its own. Defaults true. */ + automaticRevealEnabled: boolean; + /** Whether the pinned strip cycles what each agent is doing. Defaults true. */ + tickerEnabled: boolean; preferredDisplayId?: number | null; hideDetails: boolean; celebrationsEnabled: boolean; @@ -264,19 +402,9 @@ export type AttentionNotchAcknowledgeRequest = { 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", -}; +> = Object.fromEntries( + ACTIVITY_EVENT_CATALOG.map(({ kind, defaultPolicy }) => [kind, defaultPolicy]), +) as Record; export const DEFAULT_ATTENTION_PREFERENCES: AttentionPreferences = { account: { @@ -288,6 +416,7 @@ export const DEFAULT_ATTENTION_PREFERENCES: AttentionPreferences = { soundsEnabled: false, celebrationsEnabled: true, hideDetails: false, + dockBadgeScope: "local", quietHours: { enabled: false, startMinute: 22 * 60, @@ -296,11 +425,12 @@ export const DEFAULT_ATTENTION_PREFERENCES: AttentionPreferences = { }, }, devices: {}, + machines: {}, projects: {}, mutedSessionIds: [], }; -const ATTENTION_PHASE_PRIORITY: Record = { +export const ATTENTION_PHASE_PRIORITY: Readonly> = { needs_you: 0, failed: 1, checks_failing: 1, @@ -332,6 +462,7 @@ export function sortAttentionItems(items: readonly AttentionItem[]): AttentionIt } export function attentionItemNeedsInbox(item: AttentionItem): boolean { + if (activityItemTier(item) === "idle") return false; if (item.dismissedAt) return false; if ( item.phase === "needs_you" @@ -346,6 +477,31 @@ export function attentionItemNeedsInbox(item: AttentionItem): boolean { return (item.phase === "completed" || item.phase === "merged") && item.seenAt === null; } +/** + * Legacy snapshots predate the tier field. Derive the old signal/ambient split + * from the phase so a mixed-version fleet still files rows consistently. + */ +export function activityItemTier(item: AttentionItem): "signal" | "ambient" | "idle" { + if (item.activityTier) return item.activityTier; + switch (item.phase) { + case "needs_you": + case "blocked": + case "failed": + case "checks_failing": + case "review_requested": + case "changes_requested": + case "merge_ready": + return "signal"; + default: + return "ambient"; + } +} + +/** Idle rows are also ambient: neither tier is eligible to interrupt. */ +export function activityItemIsAmbient(item: AttentionItem): boolean { + return activityItemTier(item) !== "signal"; +} + export function attentionItemIsLive(item: AttentionItem): boolean { return ( item.phase === "starting" diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 76fb21f92..e547d3b90 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -20,6 +20,16 @@ E2000000000000000000009B /* LinearConnectionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2000000000000000000009B /* LinearConnectionScreen.swift */; }; AA1100000000000000000001 /* ADESharedContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000001 /* ADESharedContainer.swift */; }; AA1100000000000000000002 /* ADESharedModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000002 /* ADESharedModels.swift */; }; + AA1100000000000000000004 /* ActivityRowPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000004 /* ActivityRowPresentation.swift */; }; + AA1100000000000000000005 /* ActivityWidgetPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000005 /* ActivityWidgetPresentation.swift */; }; + AA1100000000000000000014 /* ActivityRowPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000004 /* ActivityRowPresentation.swift */; }; + AA1100000000000000000015 /* ActivityWidgetPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000005 /* ActivityWidgetPresentation.swift */; }; + D3000000000000000000002A /* ActivityRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3000000000000000000001A /* ActivityRow.swift */; }; + E200000000000000000000A2 /* HubLiveStrip.swift in Sources */ = {isa = PBXBuildFile; fileRef = D200000000000000000000A2 /* HubLiveStrip.swift */; }; + AC7600000000000000000001 /* ActivityRowPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7500000000000000000001 /* ActivityRowPresentationTests.swift */; }; + AC7600000000000000000003 /* WorkSessionGroupingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7500000000000000000003 /* WorkSessionGroupingTests.swift */; }; + AC7600000000000000000004 /* ActivityWidgetPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7500000000000000000004 /* ActivityWidgetPresentationTests.swift */; }; + AC7600000000000000000002 /* HubProjectPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7500000000000000000002 /* HubProjectPresentationTests.swift */; }; AA1100000000000000000003 /* ADESharedTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000003 /* ADESharedTheme.swift */; }; AA1100000000000000000011 /* ADESharedContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000001 /* ADESharedContainer.swift */; }; AA1100000000000000000012 /* ADESharedModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000002 /* ADESharedModels.swift */; }; @@ -106,6 +116,7 @@ E1000000000000000000003A /* WorkReasoningCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000003A /* WorkReasoningCard.swift */; }; E1000000000000000000003B /* WorkActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000003B /* WorkActivityIndicator.swift */; }; E1000000000000000000003C /* WorkSessionGrouping.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000003C /* WorkSessionGrouping.swift */; }; + E10000000000000000000F01 /* WorkLaneOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000F01 /* WorkLaneOrder.swift */; }; H10000000000000000000001 /* CtoRootScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = H10000000000000000000010 /* CtoRootScreen.swift */; }; H10000000000000000000002 /* CtoSessionDestinationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = H10000000000000000000011 /* CtoSessionDestinationView.swift */; }; H10000000000000000000005 /* CtoIdentityEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = H10000000000000000000014 /* CtoIdentityEditor.swift */; }; @@ -145,10 +156,13 @@ C85070CCC923CAB6FD61AF85 /* RecordingPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C7F48CB9D3EBD80F1FDBD9F /* RecordingPill.swift */; }; B1D40000000000000000A001 /* GlobalDictationPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D40000000000000000A002 /* GlobalDictationPill.swift */; }; 7B70BE6839672E5D2D006B28 /* ADETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C0DF7FEB4C2EB854BAC888 /* ADETests.swift */; }; - D30000000000000000000011 /* AttentionDrawerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000001 /* AttentionDrawerModel.swift */; }; - D30000000000000000000012 /* AttentionDrawerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000002 /* AttentionDrawerButton.swift */; }; - D30000000000000000000013 /* AttentionDrawerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000003 /* AttentionDrawerSheet.swift */; }; - D30000000000000000000015 /* AttentionDrawerModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000005 /* AttentionDrawerModelTests.swift */; }; + D30000000000000000000011 /* ActivityDrawerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000001 /* ActivityDrawerModel.swift */; }; + D30000000000000000000012 /* ActivityBellButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000002 /* ActivityBellButton.swift */; }; + D30000000000000000000013 /* ActivityDrawerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000003 /* ActivityDrawerSheet.swift */; }; + D30000000000000000000015 /* ActivityDrawerModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000005 /* ActivityDrawerModelTests.swift */; }; + AC7200000000000000000001 /* ActivityContractDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7100000000000000000001 /* ActivityContractDecodingTests.swift */; }; + AC7400000000000000000001 /* ActivityAckQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7300000000000000000001 /* ActivityAckQueueTests.swift */; }; + AC7400000000000000000002 /* ActivityPollingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7300000000000000000002 /* ActivityPollingTests.swift */; }; D30000000000000000000016 /* SyncEnvelopeChunkAssemblerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000006 /* SyncEnvelopeChunkAssemblerTests.swift */; }; D30000000000000000000017 /* WorkMarkdownStreamingParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000007 /* WorkMarkdownStreamingParsingTests.swift */; }; D30000000000000000000018 /* PrMergeMergeStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000008 /* PrMergeMergeStateTests.swift */; }; @@ -309,6 +323,14 @@ D2000000000000000000009B /* LinearConnectionScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LinearConnectionScreen.swift; path = ADE/Views/Linear/LinearConnectionScreen.swift; sourceTree = ""; }; AA1000000000000000000001 /* ADESharedContainer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADESharedContainer.swift; path = ADE/Shared/ADESharedContainer.swift; sourceTree = ""; }; AA1000000000000000000002 /* ADESharedModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADESharedModels.swift; path = ADE/Shared/ADESharedModels.swift; sourceTree = ""; }; + AA1000000000000000000004 /* ActivityRowPresentation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityRowPresentation.swift; path = ADE/Shared/ActivityRowPresentation.swift; sourceTree = ""; }; + AA1000000000000000000005 /* ActivityWidgetPresentation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityWidgetPresentation.swift; path = ADE/Shared/ActivityWidgetPresentation.swift; sourceTree = ""; }; + D3000000000000000000001A /* ActivityRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityRow.swift; path = ADE/Views/Activity/ActivityRow.swift; sourceTree = ""; }; + D200000000000000000000A2 /* HubLiveStrip.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HubLiveStrip.swift; path = ADE/Views/Hub/HubLiveStrip.swift; sourceTree = ""; }; + AC7500000000000000000001 /* ActivityRowPresentationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityRowPresentationTests.swift; path = ADETests/ActivityRowPresentationTests.swift; sourceTree = ""; }; + AC7500000000000000000003 /* WorkSessionGroupingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkSessionGroupingTests.swift; path = ADETests/WorkSessionGroupingTests.swift; sourceTree = ""; }; + AC7500000000000000000004 /* ActivityWidgetPresentationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityWidgetPresentationTests.swift; path = ADETests/ActivityWidgetPresentationTests.swift; sourceTree = ""; }; + AC7500000000000000000002 /* HubProjectPresentationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HubProjectPresentationTests.swift; path = ADETests/HubProjectPresentationTests.swift; sourceTree = ""; }; AA1000000000000000000003 /* ADESharedTheme.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADESharedTheme.swift; path = ADE/Shared/ADESharedTheme.swift; sourceTree = ""; }; AA0000000000000000000002 /* ADEWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ADEWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; AA5000000000000000000001 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = ADEWidgets/Info.plist; sourceTree = ""; }; @@ -393,6 +415,7 @@ D10000000000000000000049 /* TerminalSessionScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TerminalSessionScreen.swift; path = ADE/Views/Work/TerminalSessionScreen.swift; sourceTree = ""; }; D1000000000000000000004A /* SwiftTermSessionView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SwiftTermSessionView.swift; path = ADE/Views/Work/SwiftTermSessionView.swift; sourceTree = ""; }; D1000000000000000000003C /* WorkSessionGrouping.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkSessionGrouping.swift; path = ADE/Views/Work/WorkSessionGrouping.swift; sourceTree = ""; }; + D10000000000000000000F01 /* WorkLaneOrder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkLaneOrder.swift; path = ADE/Views/Work/WorkLaneOrder.swift; sourceTree = ""; }; H10000000000000000000010 /* CtoRootScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CtoRootScreen.swift; path = ADE/Views/Cto/CtoRootScreen.swift; sourceTree = ""; }; H10000000000000000000011 /* CtoSessionDestinationView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CtoSessionDestinationView.swift; path = ADE/Views/Cto/CtoSessionDestinationView.swift; sourceTree = ""; }; H10000000000000000000014 /* CtoIdentityEditor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CtoIdentityEditor.swift; path = ADE/Views/Cto/CtoIdentityEditor.swift; sourceTree = ""; }; @@ -420,10 +443,13 @@ D200000000000000000000A1 /* HubQuickConnect.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HubQuickConnect.swift; path = ADE/Views/Hub/HubQuickConnect.swift; sourceTree = ""; }; F40000000000000000000002 /* PersonalChatsScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PersonalChatsScreen.swift; path = ADE/Views/PersonalChats/PersonalChatsScreen.swift; sourceTree = ""; }; 14C0DF7FEB4C2EB854BAC888 /* ADETests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADETests.swift; path = ADETests/ADETests.swift; sourceTree = ""; }; - D30000000000000000000001 /* AttentionDrawerModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttentionDrawerModel.swift; path = ADE/Views/AttentionDrawer/AttentionDrawerModel.swift; sourceTree = ""; }; - D30000000000000000000002 /* AttentionDrawerButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttentionDrawerButton.swift; path = ADE/Views/AttentionDrawer/AttentionDrawerButton.swift; sourceTree = ""; }; - D30000000000000000000003 /* AttentionDrawerSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttentionDrawerSheet.swift; path = ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift; sourceTree = ""; }; - D30000000000000000000005 /* AttentionDrawerModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttentionDrawerModelTests.swift; path = ADETests/AttentionDrawerModelTests.swift; sourceTree = ""; }; + D30000000000000000000001 /* ActivityDrawerModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityDrawerModel.swift; path = ADE/Views/Activity/ActivityDrawerModel.swift; sourceTree = ""; }; + D30000000000000000000002 /* ActivityBellButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityBellButton.swift; path = ADE/Views/Activity/ActivityBellButton.swift; sourceTree = ""; }; + D30000000000000000000003 /* ActivityDrawerSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityDrawerSheet.swift; path = ADE/Views/Activity/ActivityDrawerSheet.swift; sourceTree = ""; }; + D30000000000000000000005 /* ActivityDrawerModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityDrawerModelTests.swift; path = ADETests/ActivityDrawerModelTests.swift; sourceTree = ""; }; + AC7100000000000000000001 /* ActivityContractDecodingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityContractDecodingTests.swift; path = ADETests/ActivityContractDecodingTests.swift; sourceTree = ""; }; + AC7300000000000000000001 /* ActivityAckQueueTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityAckQueueTests.swift; path = ADETests/ActivityAckQueueTests.swift; sourceTree = ""; }; + AC7300000000000000000002 /* ActivityPollingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityPollingTests.swift; path = ADETests/ActivityPollingTests.swift; sourceTree = ""; }; D30000000000000000000006 /* SyncEnvelopeChunkAssemblerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncEnvelopeChunkAssemblerTests.swift; path = ADETests/SyncEnvelopeChunkAssemblerTests.swift; sourceTree = ""; }; D30000000000000000000007 /* WorkMarkdownStreamingParsingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkMarkdownStreamingParsingTests.swift; path = ADETests/WorkMarkdownStreamingParsingTests.swift; sourceTree = ""; }; D30000000000000000000008 /* PrMergeMergeStateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrMergeMergeStateTests.swift; path = ADETests/PrMergeMergeStateTests.swift; sourceTree = ""; }; @@ -679,7 +705,7 @@ G20000000000000000000000 /* PRs */, A10000000000000000000015 /* Settings */, AD0000000000000000000C01 /* Account */, - D30000000000000000000004 /* AttentionDrawer */, + D30000000000000000000004 /* Activity */, K30000000000000000000001 /* Deeplinks */, 9270CF8A67F3FA79089F39C1 /* LanesTabView.swift */, 5EE4D463D21266B62B422D11 /* PRsTabView.swift */, @@ -695,6 +721,7 @@ D20000000000000000000052 /* HubScreen+ChatNavigation.swift */, D20000000000000000000053 /* HubComposerDrawer.swift */, D200000000000000000000A1 /* HubQuickConnect.swift */, + D200000000000000000000A2 /* HubLiveStrip.swift */, ); name = Hub; sourceTree = ""; @@ -707,14 +734,15 @@ name = PersonalChats; sourceTree = ""; }; - D30000000000000000000004 /* AttentionDrawer */ = { + D30000000000000000000004 /* Activity */ = { isa = PBXGroup; children = ( - D30000000000000000000001 /* AttentionDrawerModel.swift */, - D30000000000000000000002 /* AttentionDrawerButton.swift */, - D30000000000000000000003 /* AttentionDrawerSheet.swift */, + D30000000000000000000001 /* ActivityDrawerModel.swift */, + D30000000000000000000002 /* ActivityBellButton.swift */, + D30000000000000000000003 /* ActivityDrawerSheet.swift */, + D3000000000000000000001A /* ActivityRow.swift */, ); - name = AttentionDrawer; + name = Activity; sourceTree = ""; }; K30000000000000000000001 /* Deeplinks */ = { @@ -826,6 +854,7 @@ D10000000000000000000049 /* TerminalSessionScreen.swift */, D1000000000000000000004A /* SwiftTermSessionView.swift */, D1000000000000000000003C /* WorkSessionGrouping.swift */, + D10000000000000000000F01 /* WorkLaneOrder.swift */, D1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift */, D1000000000000000000002D /* WorkChatRichCardViews.swift */, D10000000000000000000055 /* WorkChatPrViews.swift */, @@ -961,6 +990,8 @@ AA1000000000000000000001 /* ADESharedContainer.swift */, AA1000000000000000000002 /* ADESharedModels.swift */, AA1000000000000000000003 /* ADESharedTheme.swift */, + AA1000000000000000000004 /* ActivityRowPresentation.swift */, + AA1000000000000000000005 /* ActivityWidgetPresentation.swift */, AA5100000000000000000004 /* AttentionActionIntents.swift */, AE00000000000000000000A5 /* ADEAgentActivityAttributes.swift */, ); @@ -1058,7 +1089,14 @@ B90000000000000000000001 /* SyncTransportSelectionTests.swift */, AF00000000000000000000A4 /* PairingAndDpopTests.swift */, AC1000000000000000000008 /* ClipPairingHandoffTests.swift */, - D30000000000000000000005 /* AttentionDrawerModelTests.swift */, + D30000000000000000000005 /* ActivityDrawerModelTests.swift */, + AC7500000000000000000001 /* ActivityRowPresentationTests.swift */, + AC7500000000000000000003 /* WorkSessionGroupingTests.swift */, + AC7500000000000000000004 /* ActivityWidgetPresentationTests.swift */, + AC7500000000000000000002 /* HubProjectPresentationTests.swift */, + AC7100000000000000000001 /* ActivityContractDecodingTests.swift */, + AC7300000000000000000001 /* ActivityAckQueueTests.swift */, + AC7300000000000000000002 /* ActivityPollingTests.swift */, D30000000000000000000006 /* SyncEnvelopeChunkAssemblerTests.swift */, D30000000000000000000007 /* WorkMarkdownStreamingParsingTests.swift */, D30000000000000000000008 /* PrMergeMergeStateTests.swift */, @@ -1343,6 +1381,8 @@ AA1100000000000000000001 /* ADESharedContainer.swift in Sources */, AA1100000000000000000002 /* ADESharedModels.swift in Sources */, AA1100000000000000000003 /* ADESharedTheme.swift in Sources */, + AA1100000000000000000004 /* ActivityRowPresentation.swift in Sources */, + AA1100000000000000000005 /* ActivityWidgetPresentation.swift in Sources */, C10000000000000000000002 /* ADECodeRenderingCache.swift in Sources */, 0A1E077A24A5367ED58900F9 /* ADEDesignSystem.swift in Sources */, E7C4AFA1DEBFC844E11CC907 /* SpeechDictationService.swift in Sources */, @@ -1353,9 +1393,10 @@ 91D46087242081A9F29BBFF5 /* DictationMicButton.swift in Sources */, C85070CCC923CAB6FD61AF85 /* RecordingPill.swift in Sources */, B1D40000000000000000A001 /* GlobalDictationPill.swift in Sources */, - D30000000000000000000011 /* AttentionDrawerModel.swift in Sources */, - D30000000000000000000012 /* AttentionDrawerButton.swift in Sources */, - D30000000000000000000013 /* AttentionDrawerSheet.swift in Sources */, + D30000000000000000000011 /* ActivityDrawerModel.swift in Sources */, + D30000000000000000000012 /* ActivityBellButton.swift in Sources */, + D30000000000000000000013 /* ActivityDrawerSheet.swift in Sources */, + D3000000000000000000002A /* ActivityRow.swift in Sources */, C10000000000000000000001 /* ADEMobilePrimitives.swift in Sources */, C1000000000000000000B001 /* MachineRowView.swift in Sources */, F2A1C9D8456E7B3C1D2E4F90 /* FilesCodeSupport.swift in Sources */, @@ -1403,6 +1444,7 @@ E20000000000000000000052 /* HubScreen+ChatNavigation.swift in Sources */, E20000000000000000000053 /* HubComposerDrawer.swift in Sources */, E200000000000000000000A1 /* HubQuickConnect.swift in Sources */, + E200000000000000000000A2 /* HubLiveStrip.swift in Sources */, F40000000000000000000001 /* PersonalChatsScreen.swift in Sources */, B10000000000000000000002 /* LaneAttachSheet.swift in Sources */, B10000000000000000000003 /* LaneBatchManageSheet.swift in Sources */, @@ -1508,6 +1550,7 @@ E10000000000000000000049 /* TerminalSessionScreen.swift in Sources */, E1000000000000000000004A /* SwiftTermSessionView.swift in Sources */, E1000000000000000000003C /* WorkSessionGrouping.swift in Sources */, + E10000000000000000000F01 /* WorkLaneOrder.swift in Sources */, E1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift in Sources */, E1000000000000000000002D /* WorkChatRichCardViews.swift in Sources */, E10000000000000000000055 /* WorkChatPrViews.swift in Sources */, @@ -1559,7 +1602,14 @@ B90000000000000000000002 /* SyncTransportSelectionTests.swift in Sources */, AF00000000000000000000C4 /* PairingAndDpopTests.swift in Sources */, AC1100000000000000000008 /* ClipPairingHandoffTests.swift in Sources */, - D30000000000000000000015 /* AttentionDrawerModelTests.swift in Sources */, + D30000000000000000000015 /* ActivityDrawerModelTests.swift in Sources */, + AC7600000000000000000001 /* ActivityRowPresentationTests.swift in Sources */, + AC7600000000000000000003 /* WorkSessionGroupingTests.swift in Sources */, + AC7600000000000000000004 /* ActivityWidgetPresentationTests.swift in Sources */, + AC7600000000000000000002 /* HubProjectPresentationTests.swift in Sources */, + AC7200000000000000000001 /* ActivityContractDecodingTests.swift in Sources */, + AC7400000000000000000001 /* ActivityAckQueueTests.swift in Sources */, + AC7400000000000000000002 /* ActivityPollingTests.swift in Sources */, D30000000000000000000016 /* SyncEnvelopeChunkAssemblerTests.swift in Sources */, D30000000000000000000017 /* WorkMarkdownStreamingParsingTests.swift in Sources */, D30000000000000000000018 /* PrMergeMergeStateTests.swift in Sources */, @@ -1580,6 +1630,8 @@ AA1100000000000000000011 /* ADESharedContainer.swift in Sources */, AA1100000000000000000012 /* ADESharedModels.swift in Sources */, AA1100000000000000000013 /* ADESharedTheme.swift in Sources */, + AA1100000000000000000014 /* ActivityRowPresentation.swift in Sources */, + AA1100000000000000000015 /* ActivityWidgetPresentation.swift in Sources */, AA5200000000000000000011 /* ADEWidgetBundle.swift in Sources */, AA5200000000000000000014 /* ADELockScreenWidget.swift in Sources */, AA5100000000000000000024 /* AttentionActionIntents.swift in Sources */, diff --git a/apps/ios/ADE/App/ADEApp.swift b/apps/ios/ADE/App/ADEApp.swift index 04b61dfd8..051cbafa4 100644 --- a/apps/ios/ADE/App/ADEApp.swift +++ b/apps/ios/ADE/App/ADEApp.swift @@ -50,11 +50,13 @@ struct ADEApp: App { .onChange(of: scenePhase) { _, newPhase in if newPhase == .background { didEnterBackground = true + accountService.stopAttentionPolling() ProductAnalytics.shared.flush() Task { await accountService.updateAttentionAppForeground(false) } return } guard newPhase == .active else { return } + accountService.startAttentionPolling() if didEnterBackground { didEnterBackground = false ProductAnalytics.shared.captureAppOpened(.foreground) diff --git a/apps/ios/ADE/App/ADEAppDelegate.swift b/apps/ios/ADE/App/ADEAppDelegate.swift index ca17bb86b..98f29d6c1 100644 --- a/apps/ios/ADE/App/ADEAppDelegate.swift +++ b/apps/ios/ADE/App/ADEAppDelegate.swift @@ -77,9 +77,14 @@ final class ADEAppDelegate: NSObject, UIApplicationDelegate { didReceiveRemoteNotification userInfo: [AnyHashable: Any] ) async -> UIBackgroundFetchResult { await MainActor.run { PushNotificationService.shared.notePushReceived() } - // This callback only records push diagnostics; it fetches/syncs nothing, - // so claiming `.newData` would skew iOS's background-fetch budget. - return .noData + let previousRevision = await MainActor.run { + AccountService.shared.attentionSnapshotRevision + } + await AccountService.shared.refreshAttentionSnapshot() + let refreshedRevision = await MainActor.run { + AccountService.shared.attentionSnapshotRevision + } + return refreshedRevision != previousRevision ? .newData : .noData } } @@ -94,6 +99,9 @@ extension ADEAppDelegate: UNUserNotificationCenterDelegate { willPresent notification: UNNotification ) async -> UNNotificationPresentationOptions { let userInfo = notification.request.content.userInfo + Task { @MainActor in + await AccountService.shared.refreshAttentionSnapshot() + } return await MainActor.run { PushNotificationService.shared.notePushReceived() if let sessionId = ADEAppDelegate.sessionId(from: userInfo), @@ -114,6 +122,9 @@ extension ADEAppDelegate: UNUserNotificationCenterDelegate { let userInfo = response.notification.request.content.userInfo let sessionId = (userInfo["sessionId"] as? String) ?? "" let itemId = (userInfo["itemId"] as? String) ?? "" + Task { @MainActor in + await AccountService.shared.refreshAttentionSnapshot() + } // Both ids are required to target the pending approval — a payload // missing either (older host, malformed push) falls through to the diff --git a/apps/ios/ADE/App/ContentView.swift b/apps/ios/ADE/App/ContentView.swift index 93188d59b..044ddddd8 100644 --- a/apps/ios/ADE/App/ContentView.swift +++ b/apps/ios/ADE/App/ContentView.swift @@ -111,7 +111,7 @@ struct ContentView: View { ConnectionSettingsView(syncService: syncService) } .sheet(isPresented: $syncService.attentionDrawerPresented) { - AttentionDrawerSheet() + ActivityDrawerSheet() .environmentObject(syncService) .environmentObject(syncService.attentionDrawer) } diff --git a/apps/ios/ADE/App/DeepLinkRouter.swift b/apps/ios/ADE/App/DeepLinkRouter.swift index 77d1c5652..ee8138856 100644 --- a/apps/ios/ADE/App/DeepLinkRouter.swift +++ b/apps/ios/ADE/App/DeepLinkRouter.swift @@ -132,6 +132,17 @@ final class DeepLinkRouter { isValidLinearIssueBranch(url: url) else { return } routeLinearIssue(identifier: identifier, url: url) + case "activity": + // `ade://activity` — the lock-screen widget's fallback when nothing in + // particular is asking for you. It opens the drawer rather than picking a + // row on the user's behalf, which is the honest answer to "show me + // everything". Takes no path or query, so there is nothing to validate. + SyncService.shared?.attentionDrawerPresented = true + NotificationCenter.default.post( + name: .adeDeepLinkRequested, + object: nil, + userInfo: ["kind": "activity", "identifier": ""] + ) default: return } diff --git a/apps/ios/ADE/Services/AccountDirectory.swift b/apps/ios/ADE/Services/AccountDirectory.swift index de202f103..4a4628403 100644 --- a/apps/ios/ADE/Services/AccountDirectory.swift +++ b/apps/ios/ADE/Services/AccountDirectory.swift @@ -296,6 +296,11 @@ struct AccountDirectoryClient { /// 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 AccountAttentionAcknowledgmentResult: Equatable, Sendable { + let applied: [String] + let stale: [String] +} + struct AccountAttentionRelayClient { enum RelayError: LocalizedError, Equatable { case unauthorized @@ -308,9 +313,9 @@ struct AccountAttentionRelayClient { 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." + case .server(let status): return "Activity service error (\(status))." + case .transport: return "Couldn't reach the Activity service." + case .invalidSnapshot: return "The Activity service returned unreadable data." } } } @@ -355,22 +360,73 @@ struct AccountAttentionRelayClient { itemIds: [String], dismiss: Bool, refreshToken: (() async -> String?)? = nil - ) async throws { - guard !itemIds.isEmpty else { return } - let timestamp = ISO8601DateFormatter().string(from: Date()) + ) async throws -> AccountAttentionAcknowledgmentResult { + let now = Date() + return try await acknowledge( + baseURL: baseURL, + token: token, + itemIds: itemIds, + seenAt: now, + dismissedAt: dismiss ? now : nil, + sourceRevisions: nil, + expectedAccountOwnerId: nil, + refreshToken: refreshToken + ) + } + + func acknowledge( + baseURL: URL, + token: String, + itemIds: [String], + seenAt: Date, + dismissedAt: Date?, + sourceRevisions: [String: Int]?, + expectedAccountOwnerId: String?, + refreshToken: (() async -> String?)? = nil + ) async throws -> AccountAttentionAcknowledgmentResult { + let ids = Array(itemIds.prefix(64)) + guard !ids.isEmpty else { + return AccountAttentionAcknowledgmentResult(applied: [], stale: []) + } + let formatter = ISO8601DateFormatter() var payload: [String: Any] = [ - "itemIds": Array(itemIds.prefix(64)), - "seenAt": timestamp, + "itemIds": ids, + "seenAt": formatter.string(from: seenAt), ] - if dismiss { payload["dismissedAt"] = timestamp } + if let dismissedAt { + payload["dismissedAt"] = formatter.string(from: dismissedAt) + } + if let sourceRevisions { + payload["sourceRevisions"] = sourceRevisions + } + if let expectedAccountOwnerId { + payload["expectedAccountOwnerId"] = expectedAccountOwnerId + } let body = try JSONSerialization.data(withJSONObject: payload) - _ = try await perform( + let data = try await perform( url: endpoint(baseURL, "ack"), method: "POST", token: token, body: body, refreshToken: refreshToken ) + // Older relays returned no applied/stale arrays. Treat a successful legacy + // response as applying every requested id so this additive client remains + // compatible during a staggered rollout. + guard !data.isEmpty else { + return AccountAttentionAcknowledgmentResult(applied: ids, stale: []) + } + struct Response: Decodable { + let applied: [String]? + let stale: [String]? + } + guard let response = try? JSONDecoder().decode(Response.self, from: data) else { + throw RelayError.invalidSnapshot + } + return AccountAttentionAcknowledgmentResult( + applied: response.applied ?? ids, + stale: response.stale ?? [] + ) } func updatePresence( diff --git a/apps/ios/ADE/Services/AccountService.swift b/apps/ios/ADE/Services/AccountService.swift index 4bf6a2d3e..9d3636491 100644 --- a/apps/ios/ADE/Services/AccountService.swift +++ b/apps/ios/ADE/Services/AccountService.swift @@ -321,6 +321,238 @@ struct AccountDeviceRevocationStore { } } +/// One optimistic acknowledgment that still needs to reach the account relay. +/// Entries are owner-scoped by `AccountAttentionPendingAckStore`, so the wire +/// shape stays limited to item state and can never cross an account boundary. +struct AccountAttentionPendingAck: Codable, Equatable, Sendable { + let itemId: String + let seenAt: Date? + let dismissedAt: Date? + let sourceRevision: Int? + let queuedAt: Date + let attemptCount: Int + + init( + itemId: String, + seenAt: Date?, + dismissedAt: Date?, + sourceRevision: Int?, + queuedAt: Date = Date(), + attemptCount: Int = 0 + ) { + self.itemId = itemId + self.seenAt = seenAt + self.dismissedAt = dismissedAt + self.sourceRevision = sourceRevision + self.queuedAt = queuedAt + self.attemptCount = max(0, attemptCount) + } + + private enum CodingKeys: String, CodingKey { + case itemId, seenAt, dismissedAt, sourceRevision, queuedAt, attemptCount + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + itemId = try container.decode(String.self, forKey: .itemId) + seenAt = try container.decodeIfPresent(Date.self, forKey: .seenAt) + dismissedAt = try container.decodeIfPresent(Date.self, forKey: .dismissedAt) + sourceRevision = try container.decodeIfPresent(Int.self, forKey: .sourceRevision) + queuedAt = try container.decodeIfPresent(Date.self, forKey: .queuedAt) + ?? [seenAt, dismissedAt].compactMap { $0 }.max() + ?? Date() + attemptCount = max( + 0, + try container.decodeIfPresent(Int.self, forKey: .attemptCount) ?? 0 + ) + } + + func merging(_ other: AccountAttentionPendingAck) -> AccountAttentionPendingAck { + precondition(itemId == other.itemId) + let mergedQueuedAt = max(queuedAt, other.queuedAt) + let mergedAttemptCount: Int + if queuedAt == other.queuedAt { + mergedAttemptCount = max(attemptCount, other.attemptCount) + } else { + mergedAttemptCount = mergedQueuedAt == queuedAt ? attemptCount : other.attemptCount + } + return AccountAttentionPendingAck( + itemId: itemId, + seenAt: Self.latest(seenAt, other.seenAt), + dismissedAt: Self.latest(dismissedAt, other.dismissedAt), + sourceRevision: Self.latest(sourceRevision, other.sourceRevision), + queuedAt: mergedQueuedAt, + attemptCount: mergedAttemptCount + ) + } + + private static func latest(_ lhs: Value?, _ rhs: Value?) -> Value? { + switch (lhs, rhs) { + case (.some(let lhs), .some(let rhs)): return max(lhs, rhs) + case (.some(let lhs), .none): return lhs + case (.none, .some(let rhs)): return rhs + case (.none, .none): return nil + } + } +} + +private struct AccountAttentionPendingAckArchive: Codable { + var entriesByOwner: [String: [AccountAttentionPendingAck]] = [:] +} + +/// App Group-backed queue for account acknowledgments. Reads always normalize +/// duplicate item ids so a crash between enqueue and cleanup cannot multiply +/// relay writes on the next foreground refresh. +struct AccountAttentionPendingAckStore { + static let maximumEntriesPerOwner = 200 + static let maximumAge: TimeInterval = 24 * 60 * 60 + static let maximumFailedAttempts = 5 + + private let defaults: UserDefaults + private let key: String + + init( + defaults: UserDefaults = ADESharedContainer.defaults, + key: String = ADESharedContainer.attentionPendingAcksKey + ) { + self.defaults = defaults + self.key = key + } + + func entries(for ownerId: String) -> [AccountAttentionPendingAck] { + let ownerId = normalizedOwnerId(ownerId) + guard !ownerId.isEmpty else { return [] } + return Self.bounded(load().entriesByOwner[ownerId] ?? []) + } + + func enqueue(_ entries: [AccountAttentionPendingAck], for ownerId: String) { + let ownerId = normalizedOwnerId(ownerId) + guard !ownerId.isEmpty, !entries.isEmpty else { return } + var archive = load() + archive.entriesByOwner[ownerId] = Self.bounded( + (archive.entriesByOwner[ownerId] ?? []) + entries + ) + save(archive) + } + + func replace(_ entries: [AccountAttentionPendingAck], for ownerId: String) { + let ownerId = normalizedOwnerId(ownerId) + guard !ownerId.isEmpty else { return } + var archive = load() + let normalized = Self.bounded(entries) + if normalized.isEmpty { + archive.entriesByOwner.removeValue(forKey: ownerId) + } else { + archive.entriesByOwner[ownerId] = normalized + } + save(archive) + } + + func remove(itemIds: Set, for ownerId: String) { + guard !itemIds.isEmpty else { return } + replace( + entries(for: ownerId).filter { !itemIds.contains($0.itemId) }, + for: ownerId + ) + } + + func clear(for ownerId: String) { + replace([], for: ownerId) + } + + @discardableResult + func pruneExpired( + for ownerId: String, + now: Date = Date() + ) -> [AccountAttentionPendingAck] { + let cutoff = now.addingTimeInterval(-Self.maximumAge) + let retained = entries(for: ownerId).filter { $0.queuedAt >= cutoff } + replace(retained, for: ownerId) + return retained + } + + @discardableResult + func recordFailedAttempt( + itemIds: Set, + for ownerId: String + ) -> Set { + guard !itemIds.isEmpty else { return [] } + var evicted: Set = [] + let updated = entries(for: ownerId).compactMap { entry in + guard itemIds.contains(entry.itemId) else { return entry } + guard entry.attemptCount < Self.maximumFailedAttempts - 1 else { + evicted.insert(entry.itemId) + return nil + } + return AccountAttentionPendingAck( + itemId: entry.itemId, + seenAt: entry.seenAt, + dismissedAt: entry.dismissedAt, + sourceRevision: entry.sourceRevision, + queuedAt: entry.queuedAt, + attemptCount: entry.attemptCount + 1 + ) + } + replace(updated, for: ownerId) + return evicted + } + + static func deduplicated( + _ entries: [AccountAttentionPendingAck] + ) -> [AccountAttentionPendingAck] { + var byId: [String: AccountAttentionPendingAck] = [:] + for entry in entries { + let itemId = entry.itemId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !itemId.isEmpty else { continue } + let normalized = AccountAttentionPendingAck( + itemId: itemId, + seenAt: entry.seenAt, + dismissedAt: entry.dismissedAt, + sourceRevision: entry.sourceRevision, + queuedAt: entry.queuedAt, + attemptCount: entry.attemptCount + ) + byId[itemId] = byId[itemId]?.merging(normalized) ?? normalized + } + return byId.values.sorted { + if $0.queuedAt != $1.queuedAt { + return $0.queuedAt < $1.queuedAt + } + return $0.itemId < $1.itemId + } + } + + private static func bounded( + _ entries: [AccountAttentionPendingAck] + ) -> [AccountAttentionPendingAck] { + Array(deduplicated(entries).suffix(maximumEntriesPerOwner)) + } + + private func normalizedOwnerId(_ ownerId: String) -> String { + ownerId.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func load() -> AccountAttentionPendingAckArchive { + guard let data = defaults.data(forKey: key), + let archive = try? JSONDecoder().decode( + AccountAttentionPendingAckArchive.self, + from: data + ) else { + return AccountAttentionPendingAckArchive() + } + return archive + } + + private func save(_ archive: AccountAttentionPendingAckArchive) { + if archive.entriesByOwner.isEmpty { + defaults.removeObject(forKey: key) + } else if let data = try? JSONEncoder().encode(archive) { + defaults.set(data, 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. @@ -470,6 +702,98 @@ func accountPairingCommitIsAuthorized( ) } +let ActivityPollInterval: UInt64 = 20_000_000_000 +typealias AccountAttentionPollSleep = @MainActor (UInt64) async throws -> Void +typealias AccountAttentionPollSignedIn = @MainActor () -> Bool +typealias AccountAttentionPollRefresh = @MainActor () async -> Void + +struct AccountAttentionAckFlushOutcome { + var attemptedItemIds: Set = [] + var staleItemIds: Set = [] + var unbackedItemIds: Set = [] + var failureMessage: String? +} + +struct AccountAttentionAckTiming: Hashable { + let seenAt: Date + let dismissedAt: Date? +} + +/// Drains every ready acknowledgment batch unless Relay reports an ownership +/// fence. Retryable failures are recorded per entry and do not prevent later +/// batches from making progress. +@MainActor +func flushAccountAttentionAckEntries( + _ entries: [AccountAttentionPendingAck], + ownerId: String, + revisionById: [String: Int], + store: AccountAttentionPendingAckStore, + acknowledge: @MainActor ( + _ itemIds: [String], + _ timing: AccountAttentionAckTiming, + _ sourceRevisions: [String: Int] + ) async throws -> AccountAttentionAcknowledgmentResult +) async -> AccountAttentionAckFlushOutcome { + var outcome = AccountAttentionAckFlushOutcome() + let grouped = Dictionary(grouping: entries) { entry in + AccountAttentionAckTiming( + seenAt: entry.seenAt ?? entry.dismissedAt ?? entry.queuedAt, + dismissedAt: entry.dismissedAt + ) + } + for (timing, groupEntries) in grouped { + var startIndex = 0 + while startIndex < groupEntries.count { + let endIndex = min(startIndex + 64, groupEntries.count) + let batch = Array(groupEntries[startIndex..( + itemIds: Set, + refresh: () async -> Void, + retry: (Set) async -> Output +) async -> Output? { + guard !itemIds.isEmpty else { return nil } + await refresh() + return await retry(itemIds) +} + /// Wraps ClerkKit behind the app's `ObservableObject` convention so SwiftUI /// surfaces observe published state instead of the `@Observable` `Clerk` type /// directly. Owns configuration, session restore, the sign-in/out operations, @@ -505,6 +829,12 @@ final class AccountService: ObservableObject { /// 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 + /// Last relay acknowledgment failure. Optimistic local drawer state remains + /// active while the durable queue waits for the next successful refresh. + @Published private(set) var attentionAckFailure: String? + /// Last snapshot-refresh failure. Without it an unreachable relay and a + /// genuinely quiet account render the same empty drawer. + @Published private(set) var attentionRefreshFailure: String? /// Transient, user-facing error from the last sign-in attempt. @Published var lastError: String? @@ -520,7 +850,14 @@ final class AccountService: ObservableObject { private var lastRelayCredential: (ownerId: String, token: String)? private var attentionRefreshTask: Task? private var attentionRefreshId: UUID? + private var attentionPollTask: Task? + private var attentionPollGeneration = 0 + private let attentionPollSleep: AccountAttentionPollSleep + private let attentionPollSignedInOverride: AccountAttentionPollSignedIn? + private let attentionPollRefreshOverride: AccountAttentionPollRefresh? private var attentionPresenceState = AccountAttentionPresenceState() + private let attentionPendingAckStore: AccountAttentionPendingAckStore + private var attentionAckFlushExclusions: Set = [] private var isEndingAccountOwnership = false private let accountRegistrationQueue = LatestAccountRegistrationQueue() @@ -546,7 +883,19 @@ final class AccountService: ObservableObject { ?? "ios-device" } - private init() {} + init( + attentionPollSleep: @escaping AccountAttentionPollSleep = { + try await Task.sleep(nanoseconds: $0) + }, + attentionPollSignedIn: AccountAttentionPollSignedIn? = nil, + attentionPollRefresh: AccountAttentionPollRefresh? = nil, + attentionPendingAckStore: AccountAttentionPendingAckStore = AccountAttentionPendingAckStore() + ) { + self.attentionPollSleep = attentionPollSleep + self.attentionPollSignedInOverride = attentionPollSignedIn + self.attentionPollRefreshOverride = attentionPollRefresh + self.attentionPendingAckStore = attentionPendingAckStore + } // MARK: - Lifecycle @@ -639,6 +988,7 @@ final class AccountService: ObservableObject { if phase != .signedIn { phase = .signedIn } + startAttentionPolling() if shouldRefreshMachines { Task { if accountSwitched { @@ -686,7 +1036,11 @@ final class AccountService: ObservableObject { lastRelayCredential = nil accountRegistrationQueue.discardPending() accountPreferencesQueue.discardPending() + if let previousOwnerId { + attentionPendingAckStore.clear(for: previousOwnerId) + } cancelAttentionRefresh() + stopAttentionPolling() invalidatePairingAuthorization() SyncService.shared?.removeAccountOwnedPairings(exceptOwnerId: nil) identity = nil @@ -720,6 +1074,60 @@ final class AccountService: ObservableObject { attentionRefreshId = nil } + /// Starts the foreground account Activity poll. Repeated starts while the + /// same loop is live are a no-op; stop/start advances the generation so a + /// cancellation-insensitive sleeper cannot resurrect an older loop. + func startAttentionPolling() { + guard attentionPollTask == nil, attentionPollingIsSignedIn else { return } + attentionPollGeneration &+= 1 + let generation = attentionPollGeneration + attentionPollTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + guard self?.attentionPollingIsSignedIn == true, + let pollSleep = self?.attentionPollSleep else { + break + } + do { + try await pollSleep(ActivityPollInterval) + } catch { + break + } + guard let self, + !Task.isCancelled, + self.attentionPollGeneration == generation, + self.attentionPollingIsSignedIn else { + break + } + if let refresh = self.attentionPollRefreshOverride { + await refresh() + } else { + await self.refreshAttentionSnapshot() + } + } + if let self, self.attentionPollGeneration == generation { + self.attentionPollTask = nil + } + } + } + + func stopAttentionPolling() { + attentionPollGeneration &+= 1 + attentionPollTask?.cancel() + attentionPollTask = nil + } + + var isAttentionPolling: Bool { + attentionPollTask != nil + } + + var currentAttentionPollGeneration: Int { + attentionPollGeneration + } + + private var attentionPollingIsSignedIn: Bool { + attentionPollSignedInOverride?() ?? isSignedIn + } + /// 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. @@ -1012,6 +1420,18 @@ final class AccountService: ObservableObject { } let existing = ADESharedContainer.readAttentionSnapshot() + let pendingAckOutcome = await flushPendingAttentionAcks( + ownerId: requestedOwnerId, + session: initialSession, + baseURL: baseURL, + snapshot: existing + ) + if let failureMessage = pendingAckOutcome.failureMessage { + attentionAckFailure = failureMessage + } else if !pendingAckOutcome.attemptedItemIds.isEmpty, + pendingAckOutcome.staleItemIds.isEmpty { + attentionAckFailure = nil + } do { let delta = try await attentionRelay.fetchSnapshot( baseURL: baseURL, @@ -1040,45 +1460,216 @@ final class AccountService: ObservableObject { incoming: delta ) guard ADESharedContainer.writeAttentionSnapshot(complete) else { return } + attentionRefreshFailure = nil attentionSnapshotRevision &+= 1 WidgetReloadBridge.reloadAllTimelines() + + // A stale fence needs one fresh snapshot before retrying. Items queued + // before their account row existed get the same single post-refresh + // opportunity; if they are still absent they remain durable for a later + // publisher reconcile. + let retryItemIds = pendingAckOutcome.staleItemIds + .union(pendingAckOutcome.unbackedItemIds) + if pendingAckOutcome.failureMessage == nil, !retryItemIds.isEmpty { + let retryOutcome = await flushPendingAttentionAcks( + ownerId: requestedOwnerId, + session: initialSession, + baseURL: baseURL, + snapshot: complete, + limitingTo: retryItemIds + ) + if let failureMessage = retryOutcome.failureMessage { + attentionAckFailure = failureMessage + } else if !retryOutcome.staleItemIds.isEmpty { + attentionAckFailure = "Activity changed again before the update was applied. Try again." + } else { + attentionAckFailure = nil + } + } else if pendingAckOutcome.failureMessage == nil { + attentionAckFailure = nil + } } catch { - // Keep the last-known account snapshot and machine-local fallback. + // Keep the last-known account snapshot and machine-local fallback, but + // say so: an unreachable relay must not read as "nothing is happening". + attentionRefreshFailure = "Couldn't reach your machines. Showing the last known activity." } } 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 ids = Array(Set(itemIds.compactMap { itemId -> String? in + let normalized = itemId.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized + })).prefix(64) + guard !ids.isEmpty else { return } + guard let requestedOwnerId = identity?.userId + ?? deviceOwnershipStore.state.ownerId else { + attentionAckFailure = "Sign in to sync this Activity update." + return + } + + let now = Date() + let revisionById = Dictionary( + uniqueKeysWithValues: (ADESharedContainer.readAttentionSnapshot()?.items ?? []) + .map { ($0.id, $0.revision) } + ) + let pending = ids.map { + AccountAttentionPendingAck( + itemId: $0, + seenAt: now, + dismissedAt: dismiss ? now : nil, + sourceRevision: revisionById[$0] + ) + } + attentionPendingAckStore.enqueue(pending, for: requestedOwnerId) + + guard isSignedIn, let baseURL = AccountConfig.attentionRelayBaseURL, let initialSession = await pairingSession(), initialSession.authorization.ownerId == requestedOwnerId, isPairingCommitAuthorized(initialSession.authorization) else { + attentionAckFailure = "Couldn't sync this Activity update yet. It will retry automatically." 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 + let attemptedIds = Set(ids) + let outcome = await flushPendingAttentionAcks( + ownerId: requestedOwnerId, + session: initialSession, + baseURL: baseURL, + snapshot: ADESharedContainer.readAttentionSnapshot(), + limitingTo: attemptedIds + ) + if let failureMessage = outcome.failureMessage { + attentionAckFailure = failureMessage + return + } + + let retryItemIds = outcome.staleItemIds.union(outcome.unbackedItemIds) + if retryItemIds.isEmpty { + attentionAckFailure = nil + await refreshAttentionSnapshot() + return + } + + // Keep the refresh's top-of-cycle queue drain from sending the stale ids a + // second time before the snapshot fence has advanced. The retry below is + // their one allowed post-refresh attempt. + attentionAckFlushExclusions.formUnion(retryItemIds) + let retryOutcome = await retryAccountAttentionAcknowledgmentsOnce( + itemIds: retryItemIds, + refresh: { [weak self] in + await self?.refreshAttentionSnapshot() + }, + retry: { [weak self] retryItemIds in + guard let self else { + return AccountAttentionAckFlushOutcome( + failureMessage: "Couldn't finish the Activity update." + ) + } + self.attentionAckFlushExclusions.subtract(retryItemIds) + guard self.isPairingCommitAuthorized(initialSession.authorization), + self.identity?.userId == requestedOwnerId else { + return AccountAttentionAckFlushOutcome( + failureMessage: "The signed-in account changed before the Activity update completed." + ) } + return await self.flushPendingAttentionAcks( + ownerId: requestedOwnerId, + session: initialSession, + baseURL: baseURL, + snapshot: ADESharedContainer.readAttentionSnapshot(), + limitingTo: retryItemIds + ) + } + ) ?? AccountAttentionAckFlushOutcome() + if let failureMessage = retryOutcome.failureMessage { + attentionAckFailure = failureMessage + } else if !retryOutcome.staleItemIds.isEmpty { + attentionAckFailure = "Activity changed again before the update was applied. Try again." + } else { + attentionAckFailure = nil + } + } + + private func flushPendingAttentionAcks( + ownerId: String, + session initialSession: AccountPairingSession, + baseURL: URL, + snapshot: AccountAttentionSnapshot?, + limitingTo itemIds: Set? = nil + ) async -> AccountAttentionAckFlushOutcome { + var outcome = AccountAttentionAckFlushOutcome() + guard isPairingCommitAuthorized(initialSession.authorization), + initialSession.authorization.ownerId == ownerId else { + outcome.failureMessage = "The signed-in account changed before the Activity update completed." + return outcome + } + + let revisionById = Dictionary( + uniqueKeysWithValues: (snapshot?.items ?? []).map { ($0.id, $0.revision) } + ) + // Orphaned item ids are intentionally unsendable without a current + // snapshot row. Bound their lifetime here so they cannot persist forever. + let stored = attentionPendingAckStore.pruneExpired(for: ownerId) + let hydrated = stored.map { entry in + AccountAttentionPendingAck( + itemId: entry.itemId, + seenAt: entry.seenAt, + dismissedAt: entry.dismissedAt, + sourceRevision: revisionById[entry.itemId] ?? entry.sourceRevision, + queuedAt: entry.queuedAt, + attemptCount: entry.attemptCount ) - await refreshAttentionSnapshot() - } catch { - // The local seen state remains useful offline. A later snapshot refresh - // will reconcile shared acknowledgment. } + if hydrated != stored { + attentionPendingAckStore.replace(hydrated, for: ownerId) + } + + let selected = hydrated.filter { entry in + (itemIds == nil || itemIds?.contains(entry.itemId) == true) + && !attentionAckFlushExclusions.contains(entry.itemId) + } + // Only the complete snapshot proves an item is account-backed *now*. + // Persisted source revisions are historical context, never authority for a + // send after the row disappeared or the account stream reset. + let ready = selected.filter { revisionById[$0.itemId] != nil } + outcome.unbackedItemIds = Set( + selected.filter { revisionById[$0.itemId] == nil }.map(\.itemId) + ) + guard !ready.isEmpty else { return outcome } + + let drained = await flushAccountAttentionAckEntries( + ready, + ownerId: ownerId, + revisionById: revisionById, + store: attentionPendingAckStore, + acknowledge: { [weak self] batchIds, timing, sourceRevisions in + guard let self else { + throw AccountAttentionRelayClient.RelayError.transport + } + return try await self.attentionRelay.acknowledge( + baseURL: baseURL, + token: initialSession.token, + itemIds: batchIds, + seenAt: timing.seenAt, + dismissedAt: timing.dismissedAt, + sourceRevisions: sourceRevisions, + expectedAccountOwnerId: ownerId, + refreshToken: { [weak self] in + guard let self, + self.isPairingCommitAuthorized(initialSession.authorization) else { + return nil + } + return try? await self.freshRelaySession( + expectedAuthorization: initialSession.authorization + ).token + } + ) + } + ) + outcome.attemptedItemIds.formUnion(drained.attemptedItemIds) + outcome.staleItemIds.formUnion(drained.staleItemIds) + outcome.failureMessage = drained.failureMessage + return outcome } func acknowledgeAttentionNavigation(_ itemId: String?) async { diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index cb4eedd5c..8d1e197b2 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -3704,7 +3704,7 @@ final class SyncService: ObservableObject { /// Sessions currently eligible for glance surfaces. Rebuilt from /// `localStateRevision` changes and written into the App Group snapshot for - /// the lock-screen widget and in-app Attention Drawer. + /// the lock-screen widget and in-app Activity drawer. @Published private(set) var activeSessions: [AgentSnapshot] = [] /// Chat sessions currently waiting on user input. Surfaced as a count chip @@ -3743,16 +3743,15 @@ final class SyncService: ObservableObject { /// uses to observe `activeSessions` / workspace snapshot writes. Lazily /// initialised on first access so tests + previews that never touch the /// drawer don't allocate an extra `ObservableObject`. - private var attentionDrawerStorage: AttentionDrawerModel? + private var attentionDrawerStorage: ActivityDrawerModel? private var attentionDrawerCancellables: Set = [] /// Drawer surface injected into the root view via `.environmentObject`. /// Rebuilt from the App Group `WorkspaceSnapshot` each time the host /// state changes — no independent transport. - @available(iOS 17.0, *) - var attentionDrawer: AttentionDrawerModel { + var attentionDrawer: ActivityDrawerModel { if let existing = attentionDrawerStorage { return existing } - let fresh = AttentionDrawerModel() + let fresh = ActivityDrawerModel() attentionDrawerCancellables = fresh.bind(to: self) attentionDrawerStorage = fresh return fresh @@ -18544,7 +18543,7 @@ extension SyncService { } /// Dispatch a remote command over the existing sync WebSocket. Used by: - /// • in-app Attention Drawer App Intents + /// • in-app Activity drawer App Intents /// • "Send to Mac" deep-link handoff /// /// The caller supplies a loosely-typed payload so action-specific fields @@ -18782,7 +18781,7 @@ extension SyncService { // `activeSessions` holds every relevant chat session — running, // awaiting-input, idle, and failed. The widget reads the running subset - // while the in-app Attention Drawer still gets the full set. Non-chat + // while the in-app Activity drawer still gets the full set. Non-chat // (shell / CLI) sessions are excluded entirely. // Completed / ended sessions are dropped since they're terminal. var allAgents: [AgentSnapshot] = [] @@ -18793,7 +18792,7 @@ extension SyncService { // The lock-screen widget should only surface chats actively streaming // output right now. Anything older than this gate is dropped from the - // running roster, but can still appear in the in-app Attention Drawer via + // running roster, but can still appear in the in-app Activity drawer via // `allAgents`. let runningRecencyCutoff = now.addingTimeInterval(-120) diff --git a/apps/ios/ADE/Shared/ADESharedContainer.swift b/apps/ios/ADE/Shared/ADESharedContainer.swift index 6ff9d6bfc..965fc8b42 100644 --- a/apps/ios/ADE/Shared/ADESharedContainer.swift +++ b/apps/ios/ADE/Shared/ADESharedContainer.swift @@ -51,6 +51,7 @@ public enum ADESharedContainer { /// calls `WidgetCenter.shared.reloadAllTimelines()` on change. public static let workspaceSnapshotKey = "ade.workspaceSnapshot" public static let attentionSnapshotKey = "ade.attentionSnapshot.v1" + public static let attentionPendingAcksKey = "ade.attentionPendingAcks.v1" public static let pushPreferencesKey = "ade.push.prefs" public static let pendingAccountDeviceRevocationKey = "ade.attention.pending-account-device-revocation.v1" @@ -148,6 +149,12 @@ public enum ADESharedContainer { decoder.dateDecodingStrategy = .custom { decoder in let container = try decoder.singleValueContainer() if let seconds = try? container.decode(Double.self) { + guard seconds.isFinite, abs(seconds) < 3_200_000_000 else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Attention timestamp is outside the supported range" + ) + } return Date(timeIntervalSince1970: seconds) } let raw = try container.decode(String.self) diff --git a/apps/ios/ADE/Shared/ADESharedModels.swift b/apps/ios/ADE/Shared/ADESharedModels.swift index d06ee95c8..d9563158f 100644 --- a/apps/ios/ADE/Shared/ADESharedModels.swift +++ b/apps/ios/ADE/Shared/ADESharedModels.swift @@ -141,7 +141,7 @@ public struct WorkspaceSnapshot: Codable, Hashable, Sendable { public let generatedAt: Date /// All live chat sessions — running, awaiting-input, and idle. The /// lock-screen widget narrows this to currently-producing sessions so old / - /// pending sessions don't pollute the glance; the in-app Attention Drawer + /// pending sessions don't pollute the glance; the in-app Activity drawer /// reads the full set. public let agents: [AgentSnapshot] public let prs: [PrSnapshot] @@ -253,28 +253,113 @@ public struct WorkspaceSnapshot: Codable, Hashable, Sendable { /// model again. public let ADEAttentionContractVersion = 1 -public enum AccountAttentionItemKind: String, Codable, Hashable, Sendable { +public enum AccountAttentionItemKind: RawRepresentable, Codable, Hashable, Sendable { case agent - case pullRequest = "pull_request" + case pullRequest + case unrecognized(String) + + public init?(rawValue: String) { + switch rawValue { + case "agent": self = .agent + case "pull_request": self = .pullRequest + default: self = .unrecognized(rawValue) + } + } + + public var rawValue: String { + switch self { + case .agent: return "agent" + case .pullRequest: return "pull_request" + case .unrecognized(let rawValue): return rawValue + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = Self(rawValue: rawValue) ?? .unrecognized(rawValue) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } } -public enum AccountAttentionPhase: String, Codable, Hashable, Sendable { +public enum AccountAttentionPhase: RawRepresentable, Codable, Hashable, Sendable { case starting case running - case needsYou = "needs_you" + case needsYou 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 checksFailing + case reviewRequested + case changesRequested + case mergeReady case open case merged case closed + case unrecognized(String) + + public init?(rawValue: String) { + switch rawValue { + case "starting": self = .starting + case "running": self = .running + case "needs_you": self = .needsYou + case "blocked": self = .blocked + case "failed": self = .failed + case "completed": self = .completed + case "stale": self = .stale + case "checks_failing": self = .checksFailing + case "review_requested": self = .reviewRequested + case "changes_requested": self = .changesRequested + case "merge_ready": self = .mergeReady + case "open": self = .open + case "merged": self = .merged + case "closed": self = .closed + default: self = .unrecognized(rawValue) + } + } - /// Row copy for the Attention Drawer. Same words as `AgentRunPhase.label` + public var rawValue: 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 "merge_ready" + case .open: return "open" + case .merged: return "merged" + case .closed: return "closed" + case .unrecognized(let rawValue): return rawValue + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = Self(rawValue: rawValue) ?? .unrecognized(rawValue) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + fileprivate var isRecognized: Bool { + if case .unrecognized = self { return false } + return true + } + + /// Row copy for the Activity drawer. Same words as `AgentRunPhase.label` /// and the desktop sidebar — "Working", not "Running"; "Done", not /// "Completed" — so one device never describes one session two ways. public var displayLabel: String { @@ -293,22 +378,69 @@ public enum AccountAttentionPhase: String, Codable, Hashable, Sendable { case .open: return "Open" case .merged: return "Merged" case .closed: return "Closed" + case .unrecognized: return "Unknown" } } } -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 enum AccountAttentionEventKind: RawRepresentable, Codable, Hashable, Sendable { + case agentRunning + case agentNeedsYou + case agentFailed + case agentCompleted + case prChecksFailing + case prReviewRequested + case prChangesRequested + case prMergeReady + case prMerged + case prOpened + case prClosed + case unrecognized(String) + + public init?(rawValue: String) { + switch rawValue { + case "agent_running": self = .agentRunning + case "agent_needs_you": self = .agentNeedsYou + case "agent_failed": self = .agentFailed + case "agent_completed": self = .agentCompleted + case "pr_checks_failing": self = .prChecksFailing + case "pr_review_requested": self = .prReviewRequested + case "pr_changes_requested": self = .prChangesRequested + case "pr_merge_ready": self = .prMergeReady + case "pr_merged": self = .prMerged + case "pr_opened": self = .prOpened + case "pr_closed": self = .prClosed + default: self = .unrecognized(rawValue) + } + } + + public var rawValue: String { + switch self { + case .agentRunning: return "agent_running" + case .agentNeedsYou: return "agent_needs_you" + case .agentFailed: return "agent_failed" + case .agentCompleted: return "agent_completed" + case .prChecksFailing: return "pr_checks_failing" + case .prReviewRequested: return "pr_review_requested" + case .prChangesRequested: return "pr_changes_requested" + case .prMergeReady: return "pr_merge_ready" + case .prMerged: return "pr_merged" + case .prOpened: return "pr_opened" + case .prClosed: return "pr_closed" + case .unrecognized(let rawValue): return rawValue + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = Self(rawValue: rawValue) ?? .unrecognized(rawValue) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } } public struct AccountAttentionMachine: Codable, Hashable, Sendable { @@ -358,6 +490,7 @@ public enum AccountAttentionDestination: Hashable, Sendable { tab: String, eventId: String? ) + case unrecognized(String) public var deepLinkURL: URL? { deepLinkURL(accountMachineKey: nil) @@ -398,6 +531,9 @@ public enum AccountAttentionDestination: Hashable, Sendable { ].compactMap { $0 } components?.queryItems = queryItems.isEmpty ? nil : queryItems return components?.url + + case .unrecognized: + return nil } } @@ -418,21 +554,22 @@ extension AccountAttentionDestination: Codable { case prId, repoOwner, repoName, number, tab } - private enum Kind: String, Codable { + private enum Kind: String { 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: + let rawKind = try container.decode(String.self, forKey: .kind) + switch Kind(rawValue: rawKind) { + case .some(.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: + case .some(.pullRequest): self = .pullRequest( prId: try container.decodeIfPresent(String.self, forKey: .prId), repoOwner: try container.decodeIfPresent(String.self, forKey: .repoOwner), @@ -441,6 +578,8 @@ extension AccountAttentionDestination: Codable { tab: try container.decodeIfPresent(String.self, forKey: .tab) ?? "overview", eventId: try container.decodeIfPresent(String.self, forKey: .eventId) ) + case .none: + self = .unrecognized(rawKind) } } @@ -448,31 +587,73 @@ extension AccountAttentionDestination: Codable { 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(Kind.session.rawValue, 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.encode(Kind.pullRequest.rawValue, 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) + case .unrecognized(let rawKind): + try container.encode(rawKind, forKey: .kind) } } } -public enum AccountAttentionActionKind: String, Codable, Hashable, Sendable { +public enum AccountAttentionActionKind: RawRepresentable, Codable, Hashable, Sendable { case approve case deny case answer case restart - case rerunChecks = "rerun_checks" - case markSeen = "mark_seen" + case rerunChecks + case markSeen case dismiss case open + case unrecognized(String) + + public init?(rawValue: String) { + switch rawValue { + case "approve": self = .approve + case "deny": self = .deny + case "answer": self = .answer + case "restart": self = .restart + case "rerun_checks": self = .rerunChecks + case "mark_seen": self = .markSeen + case "dismiss": self = .dismiss + case "open": self = .open + default: self = .unrecognized(rawValue) + } + } + + public var rawValue: String { + switch self { + case .approve: return "approve" + case .deny: return "deny" + case .answer: return "answer" + case .restart: return "restart" + case .rerunChecks: return "rerun_checks" + case .markSeen: return "mark_seen" + case .dismiss: return "dismiss" + case .open: return "open" + case .unrecognized(let rawValue): return rawValue + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = Self(rawValue: rawValue) ?? .unrecognized(rawValue) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } } public enum AccountAttentionPayloadValue: Codable, Hashable, Sendable { @@ -543,6 +724,12 @@ public struct AccountAttentionPlanProgress: Codable, Hashable, Sendable { } } +public enum AccountActivityTier: String, Codable, Hashable, Sendable { + case signal + case ambient + case idle +} + public struct AccountAttentionItem: Codable, Hashable, Identifiable, Sendable { public let contractVersion: Int public let id: String @@ -551,6 +738,9 @@ public struct AccountAttentionItem: Codable, Hashable, Identifiable, Sendable { public let kind: AccountAttentionItemKind public let eventKind: AccountAttentionEventKind public let phase: AccountAttentionPhase + /// Kept as an optional wire string so future tier values remain additive. + public let activityTier: String? + public let statusSince: Date? public private(set) var machine: AccountAttentionMachine public let project: AccountAttentionProject public let laneId: String? @@ -579,6 +769,8 @@ public struct AccountAttentionItem: Codable, Hashable, Identifiable, Sendable { kind: AccountAttentionItemKind, eventKind: AccountAttentionEventKind, phase: AccountAttentionPhase, + activityTier: String? = nil, + statusSince: Date? = nil, machine: AccountAttentionMachine, project: AccountAttentionProject, laneId: String? = nil, @@ -606,6 +798,8 @@ public struct AccountAttentionItem: Codable, Hashable, Identifiable, Sendable { self.kind = kind self.eventKind = eventKind self.phase = phase + self.activityTier = activityTier + self.statusSince = statusSince self.machine = machine self.project = project self.laneId = laneId @@ -651,11 +845,27 @@ public struct AccountAttentionItem: Codable, Hashable, Identifiable, Sendable { return true case .open, .completed, .merged, .closed: return false + case .unrecognized: + return false + } + } + + public var tier: AccountActivityTier { + if let activityTier, let publishedTier = AccountActivityTier(rawValue: activityTier) { + return publishedTier + } + switch phase { + case .needsYou, .blocked, .failed, .checksFailing, .reviewRequested, + .changesRequested, .mergeReady: + return .signal + case .starting, .running, .completed, .stale, .open, .merged, .closed, + .unrecognized: + return .ambient } } public var needsInbox: Bool { - guard dismissedAt == nil else { return false } + guard tier != .idle, dismissedAt == nil else { return false } switch phase { case .needsYou, .failed, .checksFailing, .changesRequested, .reviewRequested, .mergeReady: @@ -664,6 +874,8 @@ public struct AccountAttentionItem: Codable, Hashable, Identifiable, Sendable { return seenAt == nil case .starting, .running, .blocked, .open, .stale, .closed: return false + case .unrecognized: + return false } } @@ -678,6 +890,14 @@ public struct AccountAttentionTombstone: Codable, Hashable, Identifiable, Sendab public let deletedAt: Date } +private struct FailableDecodable: Decodable { + let value: Value? + + init(from decoder: Decoder) throws { + value = try? Value(from: decoder) + } +} + public struct AccountAttentionSnapshot: Codable, Hashable, Sendable { public let contractVersion: Int /// Opaque account stream identity assigned by Relay. Older relays and @@ -691,6 +911,18 @@ public struct AccountAttentionSnapshot: Codable, Hashable, Sendable { public let machines: [AccountAttentionMachine]? public let items: [AccountAttentionItem] public let tombstones: [AccountAttentionTombstone]? + public let itemsTruncated: Bool? + + private enum CodingKeys: String, CodingKey { + case contractVersion + case streamId + case revision + case generatedAt + case machines + case items + case tombstones + case itemsTruncated + } public init( contractVersion: Int = ADEAttentionContractVersion, @@ -699,7 +931,8 @@ public struct AccountAttentionSnapshot: Codable, Hashable, Sendable { generatedAt: Date, machines: [AccountAttentionMachine]? = nil, items: [AccountAttentionItem], - tombstones: [AccountAttentionTombstone]? = nil + tombstones: [AccountAttentionTombstone]? = nil, + itemsTruncated: Bool? = nil ) { self.contractVersion = contractVersion self.streamId = streamId @@ -708,6 +941,42 @@ public struct AccountAttentionSnapshot: Codable, Hashable, Sendable { self.machines = machines self.items = items self.tombstones = tombstones + self.itemsTruncated = itemsTruncated + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + contractVersion = try container.decode(Int.self, forKey: .contractVersion) + streamId = try container.decodeIfPresent(String.self, forKey: .streamId) + revision = try container.decode(Int.self, forKey: .revision) + generatedAt = try container.decode(Date.self, forKey: .generatedAt) + machines = try container.decodeIfPresent([AccountAttentionMachine].self, forKey: .machines) + items = try container.decode( + [FailableDecodable].self, + forKey: .items + ) + .compactMap(\.value) + // Unknown phases cannot be categorized safely by an installed UI. + // The raw enum value still decodes losslessly, while this one row is + // omitted instead of invalidating the entire account snapshot. + .filter { $0.phase.isRecognized } + tombstones = try container.decodeIfPresent( + [AccountAttentionTombstone].self, + forKey: .tombstones + ) + itemsTruncated = try container.decodeIfPresent(Bool.self, forKey: .itemsTruncated) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(contractVersion, forKey: .contractVersion) + try container.encodeIfPresent(streamId, forKey: .streamId) + try container.encode(revision, forKey: .revision) + try container.encode(generatedAt, forKey: .generatedAt) + try container.encodeIfPresent(machines, forKey: .machines) + try container.encode(items, forKey: .items) + try container.encodeIfPresent(tombstones, forKey: .tombstones) + try container.encodeIfPresent(itemsTruncated, forKey: .itemsTruncated) } /// Apply an incremental relay response to the last full snapshot. Relay @@ -748,7 +1017,8 @@ public struct AccountAttentionSnapshot: Codable, Hashable, Sendable { generatedAt: delta.generatedAt, machines: delta.machines ?? machines, items: Array(byId.values), - tombstones: delta.tombstones + tombstones: delta.tombstones, + itemsTruncated: delta.itemsTruncated ?? itemsTruncated )) } } @@ -775,7 +1045,8 @@ private func normalizedAccountAttentionSnapshot( generatedAt: snapshot.generatedAt, machines: snapshot.machines, items: Array(itemsById.values), - tombstones: snapshot.tombstones + tombstones: snapshot.tombstones, + itemsTruncated: snapshot.itemsTruncated ) } diff --git a/apps/ios/ADE/Shared/ADESharedTheme.swift b/apps/ios/ADE/Shared/ADESharedTheme.swift index 2eadd1063..eca60587b 100644 --- a/apps/ios/ADE/Shared/ADESharedTheme.swift +++ b/apps/ios/ADE/Shared/ADESharedTheme.swift @@ -101,7 +101,7 @@ public enum ADESharedTheme { // MARK: - Semantic status colors - // Attention phase colors mirror the desktop attention center so the same + // Attention phase colors mirror the desktop Activity pane so the same // state reads identically on every ADE surface. One hue, one meaning — // see `AgentRunPhase` and `sessionStatusPresentation.ts` for why: // diff --git a/apps/ios/ADE/Shared/ActivityRowPresentation.swift b/apps/ios/ADE/Shared/ActivityRowPresentation.swift new file mode 100644 index 000000000..c0751ef89 --- /dev/null +++ b/apps/ios/ADE/Shared/ActivityRowPresentation.swift @@ -0,0 +1,374 @@ +import Foundation + +/// The iOS mirror of `apps/desktop/src/shared/sessionStatusPresentation.ts` and +/// the PR half of `renderer/components/activity/activityPresentation.ts`. +/// +/// One item in, one row's worth of vocabulary out: what it is called, which hue +/// it wears, which glyph it carries, and whether the label is followed by a +/// ticking elapsed duration. Nothing here imports SwiftUI — tones are tokens, +/// not colours — so the app, the widget extension, and any future surface all +/// read the same table without inheriting the app's design system. +/// +/// **iOS 17 constraint.** This file compiles into the widget extension, whose +/// deployment target is 17.0. Keep it free of any newer API. +/// +/// ── The one-hue-one-meaning rule ──────────────────────────────────────────── +/// +/// blue work is happening, nothing is asked of you +/// amber YOUR MOVE — and nothing else, ever +/// emerald finished cleanly, you have not looked yet +/// red it broke +/// violet a human review is outstanding +/// neutral true, but not actionable +/// +/// Exactly one phase is amber: `needsYou`. A hue added here that the desktop +/// table does not have is a drift bug, not a feature. +public enum ActivityTone: String, Codable, Hashable, Sendable { + case blue + case violet + case amber + case emerald + case red + case neutral +} + +/// Glyph identity, not an icon import — the same split the desktop makes so the +/// table stays renderer-free. `systemImage` is the SF Symbols binding both +/// Apple-platform consumers happen to share. +public enum ActivityGlyph: String, Codable, Hashable, Sendable { + case working + case planning + case waiting + case needsYou + case done + case stale + case failed + case review + case merged + + public var systemImage: String { + switch self { + case .working: return "circle.dotted" + case .planning: return "list.bullet.rectangle" + case .waiting: return "hourglass" + case .needsYou: return "bell.badge.fill" + case .done: return "checkmark.circle.fill" + case .stale: return "clock.badge.exclamationmark" + case .failed: return "exclamationmark.triangle.fill" + case .review: return "eye.fill" + case .merged: return "arrow.triangle.merge" + } + } +} + +/// Which of the three priority bands a row belongs to. Mirrors desktop's +/// `activityPriority.ts`: needs-you first, then work in flight, then outcomes. +public enum ActivityBand: String, Codable, Hashable, Sendable, CaseIterable { + case needsYou + case working + case done + + public var title: String { + switch self { + case .needsYou: return "Needs you" + case .working: return "Working" + case .done: return "Done" + } + } +} + +/// The label/tone/glyph triple for one phase, before an item's own data is +/// folded in. +public struct ActivityPhasePresentation: Hashable, Sendable { + public let label: String + public let tone: ActivityTone + public let glyph: ActivityGlyph? + /// Whether the label should be followed by a live elapsed duration + /// ("Working 14s"). Only where elapsed time is the useful fact — a failed + /// run's age is noise. + public let showsElapsed: Bool + /// Whether this state should pull the eye. Working is deliberately not + /// prominent: an agent mid-turn is not yet your problem. + public let prominent: Bool + /// Liveness, not prominence — drives the pulsing dot on an online row. + public let active: Bool + + public init( + label: String, + tone: ActivityTone, + glyph: ActivityGlyph?, + showsElapsed: Bool, + prominent: Bool, + active: Bool + ) { + self.label = label + self.tone = tone + self.glyph = glyph + self.showsElapsed = showsElapsed + self.prominent = prominent + self.active = active + } +} + +public enum ActivityPhaseVocabulary { + /// Session-derived phases delegate to the desktop's `PHASE_PRESENTATION`; + /// PR phases come from `NON_SESSION_PRESENTATION` + `NON_SESSION_STATUS_DETAILS`. + /// Both tables are transcribed verbatim — if one changes, this changes. + public static func presentation(for phase: AccountAttentionPhase) -> ActivityPhasePresentation { + switch phase { + case .starting: + return .init(label: "Starting", tone: .blue, glyph: .working, showsElapsed: false, prominent: false, active: true) + case .running: + return .init(label: "Working", tone: .blue, glyph: .working, showsElapsed: true, prominent: false, active: true) + case .needsYou: + return .init(label: "Needs you", tone: .amber, glyph: .needsYou, showsElapsed: false, prominent: true, active: true) + case .completed: + return .init(label: "Done", tone: .emerald, glyph: .done, showsElapsed: false, prominent: true, active: false) + case .failed: + return .init(label: "Failed", tone: .red, glyph: .failed, showsElapsed: false, prominent: true, active: false) + // Running but silent past the threshold. Neutral, not blue: the process + // is technically alive, but "how long has it been quiet" is the actual + // question, so the elapsed ticker stays on. + case .stale: + return .init(label: "Stale", tone: .neutral, glyph: .stale, showsElapsed: true, prominent: false, active: false) + // Merge-blocked, not "your move" — frequently something the reader + // cannot clear at all, so it makes no claim on them and never paints amber. + case .blocked: + return .init(label: "Blocked", tone: .neutral, glyph: nil, showsElapsed: false, prominent: false, active: false) + case .checksFailing: + return .init(label: "Checks failing", tone: .red, glyph: .failed, showsElapsed: false, prominent: true, active: false) + case .reviewRequested: + return .init(label: "Review requested", tone: .violet, glyph: .review, showsElapsed: false, prominent: true, active: false) + case .changesRequested: + return .init(label: "Changes requested", tone: .red, glyph: .failed, showsElapsed: false, prominent: true, active: false) + case .mergeReady: + return .init(label: "Ready to merge", tone: .emerald, glyph: .done, showsElapsed: false, prominent: true, active: false) + case .open: + return .init(label: "Open", tone: .blue, glyph: nil, showsElapsed: false, prominent: false, active: false) + case .merged: + return .init(label: "Merged", tone: .emerald, glyph: .merged, showsElapsed: false, prominent: true, active: false) + case .closed: + return .init(label: "Closed", tone: .neutral, glyph: nil, showsElapsed: false, prominent: false, active: false) + case .unrecognized(let raw): + return unrecognizedPresentation(raw) + } + } + + /// A phase this build has never heard of gets the quietest presentation + /// there is. The one exception is `planning`, which the desktop already + /// renders as violet "Planning" from a session's chat activity mode and + /// which a newer publisher may start sending as a phase. + private static func unrecognizedPresentation(_ raw: String) -> ActivityPhasePresentation { + switch raw.lowercased() { + case "planning", "plan": + return .init(label: "Planning", tone: .violet, glyph: .planning, showsElapsed: true, prominent: false, active: true) + case "waiting": + return .init(label: "Waiting", tone: .neutral, glyph: .waiting, showsElapsed: false, prominent: false, active: false) + // The two resting states a session sits in between turns. Neither is a + // claim on anyone, so both are neutral and neither ticks. + case "ready": + return .init(label: "Ready", tone: .neutral, glyph: nil, showsElapsed: false, prominent: false, active: false) + case "idle": + return .init(label: "Idle", tone: .neutral, glyph: nil, showsElapsed: false, prominent: false, active: false) + case "stopped": + return .init(label: "Stopped", tone: .neutral, glyph: nil, showsElapsed: false, prominent: false, active: false) + case "ended": + return .init(label: "Ended", tone: .neutral, glyph: nil, showsElapsed: false, prominent: false, active: false) + default: + // Never manufacture a hue for a state we cannot describe: a + // fallback that could paint amber would defeat the rule it exists + // to protect. + return .init(label: "Unknown", tone: .neutral, glyph: nil, showsElapsed: false, prominent: false, active: false) + } + } + + /// Which priority band a phase files under. `idle`-tier rows are forced out + /// of the needs-you band by `ActivityRowPresentation` — a row nobody is + /// waiting on must never sit at the top of the drawer. + public static func band(for phase: AccountAttentionPhase) -> ActivityBand { + switch phase { + case .needsYou, .failed, .checksFailing, .changesRequested: + return .needsYou + case .starting, .running, .blocked, .stale, .open, .reviewRequested: + return .working + case .completed, .merged, .closed, .mergeReady: + return .done + case .unrecognized: + return .done + } + } +} + +/// Everything one Activity row renders, derived from one `AccountAttentionItem`. +/// +/// Pure value type with no transport, no service reference, and no colour — the +/// drawer, the hub strip, and (from P7) the lock-screen widget all build their +/// rows from this so the three surfaces cannot describe one session three ways. +public struct ActivityRowPresentation: Identifiable, Hashable, Sendable { + public let id: String + public let title: String + public let laneName: String? + public let projectName: String + public let phaseLabel: String + public let tone: ActivityTone + public let glyph: ActivityGlyph? + public let showsElapsed: Bool + public let prominent: Bool + public let isActive: Bool + public let band: ActivityBand + /// Anchor for the elapsed ticker. `statusSince` when the publisher supplies + /// it (immutable for the life of a phase); `occurredAt` otherwise, which is + /// approximate but never wrong enough to mislead. + public let elapsedSince: Date? + /// The italic one-liner under the title. `nil` when the item carries no + /// prose worth the row height — the phase label already says the state. + public let statusNote: String? + public let modelLabel: String? + public let providerSlug: String? + public let machineKey: String + public let machineName: String + public let machineOnline: Bool + public let machineLastSeenAt: Date? + public let tier: AccountActivityTier + public let isPullRequest: Bool + public let prNumber: Int? + public let sessionId: String? + /// Pending approval/input item, when the row is holding for one. + public let pendingItemId: String? + public let planProgress: AccountAttentionPlanProgress? + public let recentActivity: [String] + public let actions: [AccountAttentionAction] + public let deepLink: URL? + public let updatedAt: Date + public let seenAt: Date? + /// Whether this row belongs in the Inbox bucket (PR/CI traffic and + /// unlooked-at outcomes), per `AccountAttentionItem.needsInbox`. + public let needsInbox: Bool + /// Inline App Intents execute against the currently paired host, so an item + /// owned by another machine must navigate instead of acting locally. + public let inlineActionsAllowed: Bool + + public init(item: AccountAttentionItem, inlineActionsAllowed: Bool = false) { + let presentation = ActivityPhaseVocabulary.presentation(for: item.phase) + let rawBand = ActivityPhaseVocabulary.band(for: item.phase) + + id = item.id + title = Self.nonEmpty(item.title) ?? "Untitled session" + laneName = Self.nonEmpty(item.laneName) + projectName = Self.nonEmpty(item.project.name) ?? "Project" + phaseLabel = presentation.label + tone = presentation.tone + glyph = presentation.glyph + showsElapsed = presentation.showsElapsed + prominent = presentation.prominent + isActive = presentation.active && item.machine.online + tier = item.tier + // An idle row is by definition not waiting on the reader. Letting one + // reach the needs-you band is how a drawer stops meaning anything. + band = (item.tier == .idle && rawBand == .needsYou) ? .working : rawBand + elapsedSince = item.statusSince ?? item.occurredAt + statusNote = Self.nonEmpty(item.preview) + ?? Self.nonEmpty(item.detail) + ?? Self.nonEmpty(item.privacyPreview) + modelLabel = Self.nonEmpty(item.model) + providerSlug = Self.nonEmpty(item.provider) + machineKey = item.machine.machineKey + machineName = Self.nonEmpty(item.machine.name) ?? "Mac" + machineOnline = item.machine.online + machineLastSeenAt = item.machine.lastSeenAt + isPullRequest = item.kind == .pullRequest + planProgress = item.planProgress + recentActivity = item.recentActivity ?? [] + actions = item.actions + deepLink = item.deepLinkURL + updatedAt = item.updatedAt + seenAt = item.seenAt + needsInbox = item.needsInbox + self.inlineActionsAllowed = inlineActionsAllowed + + switch item.destination { + case .session(let sessionId, let itemId, _): + self.sessionId = Self.nonEmpty(sessionId) + pendingItemId = Self.nonEmpty(itemId) + prNumber = nil + case .pullRequest(_, _, _, let number, _, _): + self.sessionId = nil + pendingItemId = nil + prNumber = number > 0 ? number : nil + case .unrecognized: + self.sessionId = nil + pendingItemId = nil + prNumber = nil + } + } + + /// "Studio Mac · ADE" — the row's scope in one line. + 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)" + } + + /// Compact elapsed copy for the "Working 14s" ticker. Mirrors + /// `formatWorkingDuration`: seconds, then minutes, then hours, then days — + /// deliberately lossy above the hour, where the exact figure stops changing + /// any decision. + public func elapsedLabel(now: Date = Date()) -> String? { + guard showsElapsed, let elapsedSince else { return nil } + return Self.formatDuration(now.timeIntervalSince(elapsedSince)) + } + + /// "last seen 2h ago" copy for an offline machine's banner. + public func lastSeenLabel(now: Date = Date()) -> String? { + guard !machineOnline, let machineLastSeenAt else { return nil } + guard let duration = Self.formatDuration(now.timeIntervalSince(machineLastSeenAt)) else { + return nil + } + return "last seen \(duration) ago" + } + + public static func formatDuration(_ seconds: TimeInterval) -> String? { + guard seconds.isFinite, seconds >= 0, abs(seconds) < 3_200_000_000 else { + return nil + } + let totalSeconds = Int(seconds) + if totalSeconds < 60 { return "\(totalSeconds)s" } + let totalMinutes = totalSeconds / 60 + if totalMinutes < 60 { return "\(totalMinutes)m" } + let totalHours = totalMinutes / 60 + if totalHours < 24 { return "\(totalHours)h" } + return "\(totalHours / 24)d" + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { return nil } + return value + } +} + +public extension Array where Element == ActivityRowPresentation { + /// Priority order inside a band: rows that want a human first, then the + /// freshest, then a stable id tiebreak so equal rows never swap places + /// between snapshots. + func sortedByActivityPriority() -> [ActivityRowPresentation] { + sorted { lhs, rhs in + if lhs.band != rhs.band { + return activityBandRank(lhs.band) < activityBandRank(rhs.band) + } + if lhs.prominent != rhs.prominent { return lhs.prominent } + if lhs.updatedAt != rhs.updatedAt { return lhs.updatedAt > rhs.updatedAt } + return lhs.id < rhs.id + } + } +} + +public func activityBandRank(_ band: ActivityBand) -> Int { + switch band { + case .needsYou: return 0 + case .working: return 1 + case .done: return 2 + } +} diff --git a/apps/ios/ADE/Shared/ActivityWidgetPresentation.swift b/apps/ios/ADE/Shared/ActivityWidgetPresentation.swift new file mode 100644 index 000000000..b9f61a6b2 --- /dev/null +++ b/apps/ios/ADE/Shared/ActivityWidgetPresentation.swift @@ -0,0 +1,149 @@ +import SwiftUI + +/// The two things every Activity surface needs beyond the pure mapper in +/// `ActivityRowPresentation`: the tone → colour binding, and the lock-screen +/// widget's ranking. +/// +/// Both live here rather than in the widget because the widget target cannot +/// see the app's views and the app cannot see the widget's — a copy on each +/// side is exactly how the lock screen ends up describing a session in words +/// and colours the app does not use. +/// +/// **iOS 17 constraint.** Compiled into the widget extension. Keep it free of +/// any newer API, and of anything from the app's design system beyond +/// `ADESharedTheme`, which is in both targets. + +/// Tone token → the shared palette. The five session hues keep the meanings +/// documented on `ActivityTone`; violet is the PR-review hue. +public func activityToneColor(_ tone: ActivityTone) -> Color { + switch tone { + case .blue: return ADESharedTheme.statusRunning + case .violet: return ADESharedTheme.statusReview + case .amber: return ADESharedTheme.warningAmber + case .emerald: return ADESharedTheme.statusSuccess + case .red: return ADESharedTheme.statusFailed + case .neutral: return ADESharedTheme.statusIdle + } +} + +public enum ActivityWidgetPresentation { + /// Where a widget tap lands when nothing in particular is asking. Handled by + /// `DeepLinkRouter`'s workspace route, which is the Activity surface's home. + public static let activityURL = URL(string: "ade://activity") ?? URL(fileURLWithPath: "/") + + /// One line of the rectangular family: a glyph, a title, and the phase. + public struct CompactLine: Identifiable, Hashable, Sendable { + public let id: String + public let title: String + public let phaseLabel: String + public let tone: ActivityTone + public let glyph: ActivityGlyph? + + public init( + id: String, + title: String, + phaseLabel: String, + tone: ActivityTone, + glyph: ActivityGlyph? + ) { + self.id = id + self.title = title + self.phaseLabel = phaseLabel + self.tone = tone + self.glyph = glyph + } + } + + /// Items worth showing at all: not dismissed, not expired. + public static func visibleItems( + _ items: [AccountAttentionItem], + now: Date = Date() + ) -> [AccountAttentionItem] { + items.filter { item in + item.dismissedAt == nil && (item.expiresAt.map { $0 > now } ?? true) + } + } + + /// Where a tap goes. + /// + /// The widget used to follow whatever sorted first, which on a busy account + /// is usually a PR notification — so the one glance-and-tap surface people + /// have could not reliably reach the session actually blocked on them. + /// Ranking is explicit now: the top needs-you row, else the top live agent, + /// else the Activity surface itself. The item URLs already carry + /// `?item=&event=&accountMachineKey=`, so the ack path is unchanged. + public static func deepLink( + for items: [AccountAttentionItem], + now: Date = Date() + ) -> URL { + let visible = visibleItems(items, now: now) + let ordered = ranked(visible) + if let needsYou = ordered.first(where: { $0.phase == .needsYou }), + let url = needsYou.deepLinkURL { + return url + } + // `active`, not `AccountAttentionItem.isLive`: the latter counts a PR + // with failing checks as live, and a tap that lands on PR traffic when + // an agent is mid-turn is exactly the miss this ranking exists to stop. + let live = ordered.first { ActivityPhaseVocabulary.presentation(for: $0.phase).active } + if let live, let url = live.deepLinkURL { + return url + } + return activityURL + } + + /// Priority order for the rectangular lines: the same band ranking the + /// drawer uses, then the freshest, then a stable id tiebreak so two equal + /// rows never trade places between 60-second timeline entries. + public static func ranked(_ items: [AccountAttentionItem]) -> [AccountAttentionItem] { + items.sorted { lhs, rhs in + let lhsBand = activityBandRank(ActivityPhaseVocabulary.band(for: lhs.phase)) + let rhsBand = activityBandRank(ActivityPhaseVocabulary.band(for: rhs.phase)) + if lhsBand != rhsBand { return lhsBand < rhsBand } + let lhsProminent = ActivityPhaseVocabulary.presentation(for: lhs.phase).prominent + let rhsProminent = ActivityPhaseVocabulary.presentation(for: rhs.phase).prominent + if lhsProminent != rhsProminent { return lhsProminent } + if lhs.updatedAt != rhs.updatedAt { return lhs.updatedAt > rhs.updatedAt } + return lhs.id < rhs.id + } + } + + /// The top `limit` rows as compact lines. `hideDetails` swaps in the + /// publisher's privacy preview, which is what that setting is for — a lock + /// screen is readable by anyone holding the phone. + public static func compactLines( + for items: [AccountAttentionItem], + limit: Int = 2, + hideDetails: Bool = false, + now: Date = Date() + ) -> [CompactLine] { + ranked(visibleItems(items, now: now)).prefix(limit).map { item in + let presentation = ActivityPhaseVocabulary.presentation(for: item.phase) + return CompactLine( + id: item.id, + title: title(for: item, hideDetails: hideDetails), + phaseLabel: presentation.label, + tone: presentation.tone, + glyph: presentation.glyph + ) + } + } + + /// How many visible rows the compact lines left off, for the "+N more" tail. + public static func overflowCount( + for items: [AccountAttentionItem], + limit: Int = 2, + now: Date = Date() + ) -> Int { + max(0, visibleItems(items, now: now).count - limit) + } + + private static func title(for item: AccountAttentionItem, hideDetails: Bool) -> String { + guard hideDetails else { + let title = item.title.trimmingCharacters(in: .whitespacesAndNewlines) + return title.isEmpty ? "Untitled session" : title + } + let privateTitle = item.privacyPreview.trimmingCharacters(in: .whitespacesAndNewlines) + return privateTitle.isEmpty ? "Activity update" : privateTitle + } +} diff --git a/apps/ios/ADE/Shared/AttentionActionIntents.swift b/apps/ios/ADE/Shared/AttentionActionIntents.swift index 3f2a44c40..b65313df6 100644 --- a/apps/ios/ADE/Shared/AttentionActionIntents.swift +++ b/apps/ios/ADE/Shared/AttentionActionIntents.swift @@ -1,7 +1,7 @@ import AppIntents import Foundation -/// App-intent actions used by the in-app Attention Drawer. +/// App-intent actions used by the in-app Activity drawer. /// /// The drawer can approve or deny pending input, restart a failed session, and /// rerun failing PR checks without pushing users back to the Mac. The intents diff --git a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerButton.swift b/apps/ios/ADE/Views/Activity/ActivityBellButton.swift similarity index 73% rename from apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerButton.swift rename to apps/ios/ADE/Views/Activity/ActivityBellButton.swift index 4781918fe..42ecd9626 100644 --- a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerButton.swift +++ b/apps/ios/ADE/Views/Activity/ActivityBellButton.swift @@ -1,18 +1,18 @@ import SwiftUI import UIKit -/// Bell affordance rendered next to the root toolbar connection and project controls. +/// Bell affordance for the Activity drawer, mounted on every root's toolbar and +/// (since this pass) on the hub's top bar too. /// /// Tapping flips `SyncService.attentionDrawerPresented` to `true`, which -/// surfaces `AttentionDrawerSheet` (mounted once on the root `ContentView`). +/// surfaces `ActivityDrawerSheet` (mounted once on the root `ContentView`). /// -/// Visual spec: liquid-glass disc with an amber tint + glow when there are -/// unread attention items; a red 16pt badge overlays the top-right corner -/// when `unreadCount > 0` (count-capped at `9+`). -@available(iOS 17.0, *) -struct AttentionDrawerButton: View { +/// Visual spec is unchanged: liquid-glass disc with an amber tint + glow when +/// something needs the user, and a 16pt badge capped at `9+`. What changed is +/// what the number counts — needs-you rows only, never ambient work in flight. +struct ActivityBellButton: View { @EnvironmentObject private var syncService: SyncService - @EnvironmentObject private var drawer: AttentionDrawerModel + @EnvironmentObject private var drawer: ActivityDrawerModel private var hasUnread: Bool { drawer.unreadCount > 0 } @@ -23,7 +23,7 @@ struct AttentionDrawerButton: View { var body: some View { Button(action: openDrawer) { Label { - Text("Attention") + Text("Activity") } icon: { ZStack { PrsGlassDisc(tint: tint, isAlive: hasUnread) { @@ -45,8 +45,12 @@ struct AttentionDrawerButton: View { } .buttonStyle(.plain) .animation(.snappy(duration: 0.2), value: drawer.unreadCount) - .accessibilityLabel("Attention items: \(drawer.unreadCount)") - .accessibilityHint("Opens the attention drawer.") + .accessibilityLabel( + hasUnread + ? "Activity, \(drawer.unreadCount) \(drawer.unreadCount == 1 ? "item needs" : "items need") you" + : "Activity" + ) + .accessibilityHint("Opens the Activity drawer.") .accessibilityShowsLargeContentViewer() } diff --git a/apps/ios/ADE/Views/Activity/ActivityDrawerModel.swift b/apps/ios/ADE/Views/Activity/ActivityDrawerModel.swift new file mode 100644 index 000000000..627cdc3f9 --- /dev/null +++ b/apps/ios/ADE/Views/Activity/ActivityDrawerModel.swift @@ -0,0 +1,633 @@ +import Combine +import Foundation +import SwiftUI + +/// The two buckets the whole product now agrees on. +/// +/// Sessions is "what are my agents doing"; Inbox is "what arrived and wants an +/// acknowledgement" — PR and CI traffic plus outcomes nobody has looked at. +/// The old third bucket, Recent, is gone: a time-ordered pile of things that +/// already resolved is not a bucket, it is a scroll. +public enum ActivityBucket: String, CaseIterable, Hashable, Sendable { + case sessions + case inbox + + public var title: String { + switch self { + case .sessions: return "Sessions" + case .inbox: return "Inbox" + } + } +} + +/// Where the currently rendered rows came from. The drawer needs this to tell +/// "genuinely all clear" apart from "we could not reach anything" — two states +/// that used to render the same empty screen. +public enum ActivitySource: Hashable, Sendable { + /// A signed-in account snapshot, fresh enough to trust. + case account + /// This machine's local workspace snapshot only. + case machineFallback + /// Nothing at all — no account snapshot, no local snapshot. + case none +} + +/// One rendered line in a bucket: a row, or the divider that explains why the +/// rows beneath it are dimmed. +public enum ActivityListEntry: Identifiable, Hashable, Sendable { + case offlineMachine(machineKey: String, name: String, lastSeenLabel: String?) + case row(ActivityRowPresentation) + + public var id: String { + switch self { + case .offlineMachine(let machineKey, _, _): return "offline:\(machineKey)" + case .row(let row): return row.id + } + } +} + +public struct ActivitySection: Identifiable, Hashable, Sendable { + public let band: ActivityBand + public let rows: [ActivityRowPresentation] + public let entries: [ActivityListEntry] + + public var id: String { band.rawValue } + public var title: String { band.title } + public var count: Int { rows.count } +} + +/// Source of truth for the in-app Activity drawer. +/// +/// Reducer-only: it 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, projecting both through the same +/// `AccountAttentionItem` → `ActivityRowPresentation` path so a locally-derived +/// row and an account row can never look different. +/// +/// Persistence keys are deliberately unchanged from the Attention era: they are +/// user state, not naming. +@MainActor +public final class ActivityDrawerModel: ObservableObject { + /// Agent-kind rows, priority-flat: needs you → working → done. + @Published public private(set) var sessions: [ActivityRowPresentation] = [] + /// PR/CI traffic plus outcomes nobody has looked at yet. + @Published public private(set) var inbox: [ActivityRowPresentation] = [] + /// Machine presence from the snapshot, for the offline banners. + @Published public private(set) var machines: [AccountAttentionMachine] = [] + /// Where each offline machine's work lives, so a surface scoped to one + /// project — the Work list — can tell whether the outage touches it. The + /// drawer itself banners per row and does not need this. + @Published public private(set) var offlineScopes: [ActivityOfflineScope] = [] + @Published public private(set) var unreadCount: Int = 0 + @Published public private(set) var source: ActivitySource = .none + /// The relay capped the account feed. Surfaced so the drawer can say so + /// rather than quietly showing a partial list. + @Published public private(set) var itemsTruncated: Bool = false + + 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 { + defaults.set( + lastSeenAt.timeIntervalSince1970, + forKey: Self.lastSeenAtKey + ) + recomputeUnreadCount() + } + } + + private let defaults: UserDefaults + private var dismissedItemIDs: Set + private var seenItemIDs: Set + + public init(defaults: UserDefaults = ADESharedContainer.defaults) { + self.defaults = defaults + let stored = defaults.double(forKey: Self.lastSeenAtKey) + self.lastSeenAt = stored > 0 + ? Date(timeIntervalSince1970: stored) + : .distantPast + self.dismissedItemIDs = Set(defaults.stringArray(forKey: Self.dismissedItemIDsKey) ?? []) + self.seenItemIDs = Set(defaults.stringArray(forKey: Self.seenItemIDsKey) ?? []) + } + + // MARK: - Reducer + + /// Rebuild from the account-level contract — the real path once signed in. + 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) + } + apply( + items: active, + machines: snapshot.machines ?? [], + source: .account, + truncated: snapshot.itemsTruncated ?? false, + inlineActionsAllowed: false + ) + } + + /// Rebuild from this machine's workspace snapshot. Projected into the same + /// account item shape first, so the fallback path shares every rule above + /// it instead of maintaining a parallel one. + public func rebuild(from snapshot: WorkspaceSnapshot) { + let machine = AccountAttentionMachine( + machineKey: Self.nonEmpty(snapshot.machineId) ?? "current-machine", + name: Self.nonEmpty(snapshot.machineName) ?? "Connected Mac", + online: snapshot.connection.lowercased() != "disconnected", + lastSeenAt: snapshot.generatedAt + ) + apply( + items: Self.accountItems(from: snapshot, machine: machine), + machines: [machine], + source: .machineFallback, + truncated: false, + // These rows belong to the paired host, so inline App Intents are + // pointed at the machine that actually owns them. + inlineActionsAllowed: true + ) + } + + /// Clear everything — used when no snapshot of any kind is available, so an + /// empty drawer reports "no source" rather than "all clear". + public func clearAll() { + sessions = [] + inbox = [] + machines = [] + offlineScopes = [] + source = .none + itemsTruncated = false + recomputeUnreadCount() + } + + private func apply( + items: [AccountAttentionItem], + machines: [AccountAttentionMachine], + source: ActivitySource, + truncated: Bool, + inlineActionsAllowed: Bool + ) { + let rows = items.map { + ActivityRowPresentation(item: $0, inlineActionsAllowed: inlineActionsAllowed) + } + pruneDismissedItems(activeIDs: Set(rows.map(\.id))) + let visible = rows.filter { !dismissedItemIDs.contains($0.id) } + + sessions = visible + .filter { !$0.isPullRequest } + .sortedByActivityPriority() + // PR/CI traffic always files here; agent rows join it only once they + // have finished and nobody has looked — which is exactly the set that + // would otherwise be a push nobody can act on. + inbox = visible + .filter { $0.isPullRequest || ($0.needsInbox && $0.band == .done) } + .sortedByActivityPriority() + self.machines = machines + offlineScopes = Self.offlineScopes(from: items) + self.source = source + itemsTruncated = truncated + pruneSeenItems(activeIDs: Set(rows.map(\.id))) + recomputeUnreadCount() + } + + // MARK: - Derived views + + /// Sessions grouped into the three priority bands, each carrying its + /// offline-machine dividers. + public var sessionSections: [ActivitySection] { + ActivityBand.allCases.compactMap { band in + let rows = sessions.filter { $0.band == band } + guard !rows.isEmpty else { return nil } + return ActivitySection(band: band, rows: rows, entries: Self.entries(for: rows)) + } + } + + public var inboxEntries: [ActivityListEntry] { + Self.entries(for: inbox) + } + + /// Rows for the hub's "Live now" strip: work actually in flight across every + /// machine on the account, quietest tier excluded. + public var liveNow: [ActivityRowPresentation] { + sessions.filter { row in + guard row.tier != .idle else { return false } + return row.band == .needsYou || row.band == .working + } + } + + public var isEmpty: Bool { sessions.isEmpty && inbox.isEmpty } + + public func rows(in bucket: ActivityBucket) -> [ActivityRowPresentation] { + switch bucket { + case .sessions: return sessions + case .inbox: return inbox + } + } + + /// Ids currently on screen, for the presence ping. Capped the same way the + /// relay caps its side of the call. + public var visibleItemIds: [String] { + Array((sessions + inbox).map(\.id).prefix(64)) + } + + /// Count label for the bell. `nil` at zero, `"9+"` past nine so the 16pt + /// circle never grows past two glyphs. + public var badgeLabel: String? { + guard unreadCount > 0 else { return nil } + return unreadCount > 9 ? "9+" : "\(unreadCount)" + } + + // MARK: - Acknowledgements + + /// Per-item dismiss — the affordance iOS never had. Optimistic locally, and + /// durable in `AccountService`'s pending-ack queue if the relay is out of + /// reach, so the intent survives a refresh. + public func dismiss(_ itemId: String) { + dismissedItemIDs.insert(itemId) + persistDismissedItems() + sessions.removeAll { $0.id == itemId } + inbox.removeAll { $0.id == itemId } + recomputeUnreadCount() + Task { await AccountService.shared.acknowledgeAttentionItems([itemId], dismiss: true) } + } + + public func markSeen(_ itemId: String) { + seenItemIDs.insert(itemId) + persistSeenItems() + recomputeUnreadCount() + Task { await AccountService.shared.acknowledgeAttentionItems([itemId], dismiss: false) } + } + + /// Mark every visible row seen. Rows stay listed — the underlying work has + /// not changed — but the bell stops asking. + public func markAllSeen() { + lastSeenAt = Date() + let ids = (sessions + inbox).filter { $0.seenAt == nil }.map(\.id) + seenItemIDs.formUnion(ids) + persistSeenItems() + recomputeUnreadCount() + guard !ids.isEmpty else { return } + Task { await AccountService.shared.acknowledgeAttentionItems(ids, dismiss: false) } + } + + /// Bulk dismiss for one bucket. Scoped to the ids on screen and pruned once + /// the backing state clears, so a future regression reappears. + public func dismissVisible(in bucket: ActivityBucket) { + let ids = rows(in: bucket).map(\.id) + guard !ids.isEmpty else { return } + dismissedItemIDs.formUnion(ids) + persistDismissedItems() + let dismissed = Set(ids) + sessions.removeAll { dismissed.contains($0.id) } + inbox.removeAll { dismissed.contains($0.id) } + recomputeUnreadCount() + Task { await AccountService.shared.acknowledgeAttentionItems(ids, dismiss: true) } + } + + // MARK: - Private + + /// The bell counts one thing: rows in the needs-you band, at signal tier, + /// that have not been dismissed or already looked at. Ambient work in + /// flight is visible in the drawer and never on the badge. + private func recomputeUnreadCount() { + unreadCount = sessions.filter { row in + row.band == .needsYou + && row.tier == .signal + && row.seenAt == nil + && !seenItemIDs.contains(row.id) + && row.updatedAt > lastSeenAt + }.count + } + + /// Online rows first; then one banner per offline machine followed by its + /// rows, so the explanation always precedes the dimmed run it explains. + private static func entries(for rows: [ActivityRowPresentation]) -> [ActivityListEntry] { + let online = rows.filter(\.machineOnline) + let offline = rows.filter { !$0.machineOnline } + var entries = online.map { ActivityListEntry.row($0) } + var seenMachines: Set = [] + for row in offline { + if seenMachines.insert(row.machineKey).inserted { + entries.append( + .offlineMachine( + machineKey: row.machineKey, + name: row.machineName, + lastSeenLabel: row.lastSeenLabel() + ) + ) + } + entries.append(.row(row)) + } + return entries + } + + private func pruneDismissedItems(activeIDs: Set) { + let pruned = dismissedItemIDs.intersection(activeIDs) + guard pruned != dismissedItemIDs else { return } + dismissedItemIDs = pruned + persistDismissedItems() + } + + private func persistDismissedItems() { + 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) + } + + /// One scope entry per offline (machine, project, lane) an item mentions. + /// Deduplicated so a machine with forty stalled rows contributes one entry + /// per lane, not forty. + static func offlineScopes(from items: [AccountAttentionItem]) -> [ActivityOfflineScope] { + var seen: Set = [] + var scopes: [ActivityOfflineScope] = [] + for item in items where !item.machine.online { + let scope = ActivityOfflineScope( + machineKey: item.machine.machineKey, + machineName: nonEmpty(item.machine.name) ?? "Mac", + lastSeenAt: item.machine.lastSeenAt, + projectId: item.project.projectId, + laneId: nonEmpty(item.laneId) + ) + guard seen.insert(scope.id).inserted else { continue } + scopes.append(scope) + } + return scopes + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { return nil } + return value + } +} + +/// Where one offline machine's work lives. Presence plus scope, nothing else — +/// the row vocabulary stays in `ActivityRowPresentation`. +public struct ActivityOfflineScope: Identifiable, Hashable, Sendable { + public let machineKey: String + public let machineName: String + public let lastSeenAt: Date? + public let projectId: String + public let laneId: String? + + public var id: String { "\(machineKey)|\(projectId)|\(laneId ?? "")" } + + public init( + machineKey: String, + machineName: String, + lastSeenAt: Date?, + projectId: String, + laneId: String? + ) { + self.machineKey = machineKey + self.machineName = machineName + self.lastSeenAt = lastSeenAt + self.projectId = projectId + self.laneId = laneId + } + + /// "last seen 2h ago" — the same wording and the same clock arithmetic the + /// row banner uses, so two banners for one machine cannot disagree. + public func lastSeenLabel(now: Date = Date()) -> String? { + guard let lastSeenAt, + let duration = ActivityRowPresentation.formatDuration(now.timeIntervalSince(lastSeenAt)) + else { return nil } + return "last seen \(duration) ago" + } +} + +// MARK: - Workspace snapshot projection + +extension ActivityDrawerModel { + /// Project the machine-local snapshot into account items. Ids keep their + /// historical prefixes (`awaiting:`, `live:`, `ci:` …) so dismissals + /// persisted before this rewrite still match their rows. + static func accountItems( + from snapshot: WorkspaceSnapshot, + machine: AccountAttentionMachine + ) -> [AccountAttentionItem] { + let project = AccountAttentionProject( + projectId: nonEmpty(snapshot.projectId) ?? "current-project", + name: nonEmpty(snapshot.projectName) ?? "Current project" + ) + var items: [AccountAttentionItem] = [] + + for agent in snapshot.agents { + let phase = agentPhase(agent, machineOnline: machine.online) + let idPrefix: String + switch phase { + case .needsYou: idPrefix = "awaiting" + case .failed: idPrefix = "failed" + case .completed: idPrefix = "completed" + default: idPrefix = "live" + } + guard phase != .completed + || Date().timeIntervalSince(agent.lastActivityAt) <= 86_400 else { continue } + items.append( + AccountAttentionItem( + id: "\(idPrefix):\(agent.sessionId)", + revision: 0, + fingerprint: "local:\(agent.sessionId)", + kind: .agent, + eventKind: agentEventKind(phase), + phase: phase, + machine: machine, + project: project, + laneName: agent.laneName, + provider: agent.provider, + model: agent.modelId, + title: agentTitle(agent), + preview: nonEmpty(agent.preview) ?? "", + privacyPreview: "", + destination: .session( + sessionId: agent.sessionId, + itemId: agent.pendingInputItemId, + eventId: nil + ), + occurredAt: agent.lastActivityAt, + updatedAt: agent.lastActivityAt + ) + ) + } + + for pr in snapshot.prs { + guard let phase = prPhase(pr) else { continue } + let timestamp = pr.updatedAt ?? snapshot.generatedAt + if pr.state != "open", + Date().timeIntervalSince(timestamp) > 86_400 { continue } + items.append( + AccountAttentionItem( + id: "\(prIdPrefix(phase, state: pr.state)):\(pr.id)", + revision: 0, + fingerprint: "local:\(pr.id)", + kind: .pullRequest, + eventKind: prEventKind(phase), + phase: phase, + machine: machine, + project: project, + title: "PR #\(pr.number) · \(pr.title)", + preview: "", + privacyPreview: "", + destination: .pullRequest( + prId: pr.id, + repoOwner: nil, + repoName: nil, + number: pr.number, + tab: "overview", + eventId: nil + ), + occurredAt: timestamp, + updatedAt: timestamp + ) + ) + } + + return items + } + + private static func agentPhase( + _ agent: AgentSnapshot, + machineOnline: Bool + ) -> AccountAttentionPhase { + let status = agent.status.lowercased() + if agent.awaitingInput { return .needsYou } + if status == "failed" || status == "error" { return .failed } + if status == "completed" || status == "ended" { return .completed } + if status == "idle" { return .completed } + if !machineOnline { return .stale } + if nonEmpty(agent.phase)?.lowercased() == "blocked" { return .blocked } + return .running + } + + private static func agentEventKind( + _ phase: AccountAttentionPhase + ) -> AccountAttentionEventKind { + switch phase { + case .needsYou: return .agentNeedsYou + case .failed: return .agentFailed + case .completed: return .agentCompleted + default: return .agentRunning + } + } + + private static func prPhase(_ pr: PrSnapshot) -> AccountAttentionPhase? { + switch pr.state { + case "merged": return .merged + case "closed": return .closed + case "open": + if pr.checks == "failing" { return .checksFailing } + if pr.mergeReady { return .mergeReady } + if pr.review == "changes_requested" { return .changesRequested } + if pr.review == "pending" { return .reviewRequested } + return .open + default: return nil + } + } + + private static func prIdPrefix( + _ phase: AccountAttentionPhase, + state: String + ) -> String { + switch phase { + case .checksFailing: return "ci" + case .mergeReady: return "merge" + case .reviewRequested, .changesRequested: return "review" + default: return state + } + } + + private static func prEventKind( + _ phase: AccountAttentionPhase + ) -> AccountAttentionEventKind { + switch phase { + case .checksFailing: return .prChecksFailing + case .mergeReady: return .prMergeReady + case .reviewRequested: return .prReviewRequested + case .changesRequested: return .prChangesRequested + case .merged: return .prMerged + case .closed: return .prClosed + default: return .prOpened + } + } + + private static func agentTitle(_ agent: AgentSnapshot) -> String { + if let title = nonEmpty(agent.title) { return title } + let provider = ADESharedTheme.providerDisplayName(for: agent.provider) ?? "Agent" + return "\(provider) · \(agent.sessionId)" + } +} + +// MARK: - SyncService wiring + +extension ActivityDrawerModel { + /// Wire the model up to a live `SyncService`: rebuild whenever its sessions + /// or the App Group snapshots change. The workspace snapshot is read from + /// the App Group because `SyncService` already writes the authoritative blob + /// there — no separate transport. + /// + /// Returns the cancellables so callers (typically `SyncService` itself) can + /// retain them for the drawer's lifetime. + func bind(to syncService: SyncService) -> Set { + var bag: Set = [] + + let refresh: () -> Void = { [weak self, weak syncService] in + guard let self, let syncService else { return } + if let account = ADESharedContainer.readAttentionSnapshot(), + Date().timeIntervalSince(account.generatedAt) <= 86_400 { + self.rebuild(from: account) + return + } + if let snapshot = ADESharedContainer.readWorkspaceSnapshot() { + self.rebuild(from: snapshot) + return + } + guard !syncService.activeSessions.isEmpty else { + self.clearAll() + return + } + self.rebuild( + from: WorkspaceSnapshot( + generatedAt: Date(), + agents: syncService.activeSessions, + prs: [], + connection: "disconnected" + ) + ) + } + + syncService.$activeSessions + .receive(on: DispatchQueue.main) + .sink { _ in refresh() } + .store(in: &bag) + + syncService.$localStateRevision + .receive(on: DispatchQueue.main) + .sink { _ in refresh() } + .store(in: &bag) + + syncService.$workspaceSnapshotRevision + .receive(on: DispatchQueue.main) + .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/Activity/ActivityDrawerSheet.swift b/apps/ios/ADE/Views/Activity/ActivityDrawerSheet.swift new file mode 100644 index 000000000..e29ea84b2 --- /dev/null +++ b/apps/ios/ADE/Views/Activity/ActivityDrawerSheet.swift @@ -0,0 +1,517 @@ +import AppIntents +import SwiftUI + +/// Account-wide Activity, in two buckets: Sessions and Inbox. +/// +/// Sessions is every agent across every signed-in machine, priority-flat +/// (needs you → working → done). Inbox is the traffic that wants an +/// acknowledgement — pull requests, CI, and outcomes nobody has looked at. +/// Rows carry a swipe to dismiss or mark seen, which is the first per-item +/// affordance this surface has ever had. +struct ActivityDrawerSheet: View { + @EnvironmentObject private var drawer: ActivityDrawerModel + @EnvironmentObject private var accountService: AccountService + @Environment(\.dismiss) private var dismiss + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var bucket: ActivityBucket = .sessions + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + bucketPicker + if let message = failureMessage { + ActivityErrorBanner(message: message) + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + content + } + .navigationTitle("Activity") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Done") { dismiss() } + } + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button { + drawer.markAllSeen() + } label: { + Label("Mark all seen", systemImage: "checkmark.circle") + } + Button(role: .destructive) { + drawer.dismissVisible(in: bucket) + } label: { + Label("Dismiss \(bucket.title.lowercased())", systemImage: "rectangle.stack.badge.minus") + } + .disabled(drawer.rows(in: bucket).isEmpty) + } label: { + Image(systemName: "ellipsis.circle") + } + .accessibilityLabel("Activity actions") + } + } + .adeScreenBackground() + .adeNavigationGlass() + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .presentationContentInteraction(.scrolls) + .task { + await accountService.refreshAttentionSnapshot() + await accountService.updateAttentionPresence( + centerVisible: true, + visibleItemIds: drawer.visibleItemIds + ) + } + .onDisappear { + Task { + await accountService.updateAttentionPresence( + centerVisible: false, + visibleItemIds: [] + ) + } + } + } + + private var bucketPicker: some View { + Picker("Activity bucket", selection: $bucket) { + ForEach(ActivityBucket.allCases, id: \.self) { value in + Text(bucketLabel(value)).tag(value) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .padding(.top, 10) + .padding(.bottom, 10) + } + + private func bucketLabel(_ value: ActivityBucket) -> String { + let count = drawer.rows(in: value).count + return count > 0 ? "\(value.title) \(count)" : value.title + } + + /// The relay is the only thing that can tell us an acknowledgement or a + /// refresh failed; both used to vanish into an empty `catch`. + private var failureMessage: String? { + accountService.attentionAckFailure ?? accountService.attentionRefreshFailure + } + + @ViewBuilder + private var content: some View { + switch bucket { + case .sessions: + if drawer.sessions.isEmpty { + emptyState + } else { + sessionsList + } + case .inbox: + if drawer.inbox.isEmpty { + emptyState + } else { + inboxList + } + } + } + + private var sessionsList: some View { + List { + ForEach(drawer.sessionSections) { section in + Section { + ForEach(section.entries) { entry in + entryView(entry) + } + } header: { + ActivitySectionHeader(band: section.band, count: section.count) + } + } + if drawer.itemsTruncated { + truncationNote + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + + private var inboxList: some View { + List { + ForEach(drawer.inboxEntries) { entry in + entryView(entry) + } + if drawer.itemsTruncated { + truncationNote + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + + @ViewBuilder + private func entryView(_ entry: ActivityListEntry) -> some View { + switch entry { + case .offlineMachine(_, let name, let lastSeenLabel): + ActivityOfflineMachineBanner(machineName: name, lastSeenLabel: lastSeenLabel) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 4, trailing: 16)) + case .row(let row): + VStack(alignment: .leading, spacing: 8) { + ActivityRow(row: row, dimmed: !row.machineOnline) { follow(row) } + ActivityActionButtons( + row: row, + open: { follow(row) }, + markSeen: { drawer.markSeen(row.id) } + ) + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 2, trailing: 16)) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + drawer.dismiss(row.id) + } label: { + Label("Dismiss", systemImage: "xmark") + } + Button { + drawer.markSeen(row.id) + } label: { + Label("Mark seen", systemImage: "checkmark") + } + .tint(ADEColor.accent) + } + // Swipe is not an affordance every input method has. Voice Control, + // Switch Control, and direct-touch users with limited mobility get + // the same two actions here. + .contextMenu { + Button { + drawer.markSeen(row.id) + } label: { + Label("Mark seen", systemImage: "checkmark") + } + Button(role: .destructive) { + drawer.dismiss(row.id) + } label: { + Label("Dismiss", systemImage: "xmark") + } + } + } + } + + private var truncationNote: some View { + Text("Showing the most recent activity. Older rows stay on their machine.") + .font(.system(.caption2, design: .rounded)) + .foregroundStyle(ADEColor.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 20, trailing: 16)) + } + + /// Three genuinely different empty states: nothing to reach, nothing to do, + /// and nothing new. They used to be one grey placeholder. + private var emptyState: some View { + let copy = emptyCopy + return VStack(spacing: 14) { + Spacer() + Image(systemName: copy.symbol) + .font(.system(.largeTitle, design: .rounded).weight(.regular)) + .foregroundStyle(copy.tint) + .accessibilityHidden(true) + VStack(spacing: 5) { + Text(copy.title) + .font(.system(.title3, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text(copy.body) + .font(.system(.subheadline, design: .rounded)) + .foregroundStyle(ADEColor.textSecondary) + .multilineTextAlignment(.center) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(copy.title). \(copy.body)") + if drawer.source == .none { + Button { + Task { await accountService.refreshAttentionSnapshot() } + } label: { + Text("Try again") + .font(.system(.footnote, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.accent) + .padding(.horizontal, 16) + .padding(.vertical, 9) + .background(ADEColor.accent.opacity(0.14), in: Capsule()) + .frame(minWidth: 44, minHeight: 44) + } + .buttonStyle(.plain) + } + Spacer() + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 32) + // `.contain`, not `.combine`: combining here swallowed the "Try again" + // button, which is the only recovery path when the source is unreachable. + .accessibilityElement(children: .contain) + } + + private var emptyCopy: (symbol: String, tint: Color, title: String, body: String) { + if drawer.source == .none { + return ( + "antenna.radiowaves.left.and.right.slash", + ADEColor.textMuted, + "Can't reach your machines", + "Sign in or reconnect to see what your agents are doing." + ) + } + switch bucket { + case .sessions: + return ( + "moon.zzz", + ADEColor.textMuted, + "All agents idle.", + "Sessions appear here the moment one starts working." + ) + case .inbox: + return ( + "checkmark.seal", + ADESharedTheme.statusSuccess, + "Nothing needs you.", + "Pull requests, checks, and finished runs land here." + ) + } + } + + private func follow(_ row: ActivityRowPresentation) { + guard let url = row.deepLink else { return } + drawer.markSeen(row.id) + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + (reduceMotion ? 0 : 0.18)) { + DeepLinkRouter.shared.handle(url) + } + } +} + +// MARK: - Section header + +private struct ActivitySectionHeader: View { + let band: ActivityBand + let count: Int + + private var tint: Color { + switch band { + case .needsYou: return ADESharedTheme.warningAmber + case .working: return ADESharedTheme.statusRunning + case .done: return ADESharedTheme.statusSuccess + } + } + + var body: some View { + HStack(spacing: 7) { + Text(band.title) + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .textCase(nil) + Text("\(count)") + .font(.system(.caption, design: .rounded).weight(.semibold).monospacedDigit()) + .foregroundStyle(tint) + .contentTransition(.numericText()) + Spacer(minLength: 0) + } + .padding(.vertical, 2) + .listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 4, trailing: 16)) + .accessibilityElement(children: .combine) + } +} + +// MARK: - Error banner + +private struct ActivityErrorBanner: View { + let message: String + + var body: some View { + HStack(spacing: 9) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(.caption, design: .rounded).weight(.semibold)) + .foregroundStyle(ADESharedTheme.warningAmber) + .accessibilityHidden(true) + Text(message) + .font(.system(.caption, design: .rounded)) + .foregroundStyle(ADEColor.textPrimary) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + .padding(.horizontal, 11) + .padding(.vertical, 9) + .background(ADESharedTheme.warningAmber.opacity(0.10), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(ADESharedTheme.warningAmber.opacity(0.28), lineWidth: 0.7) + ) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Error. \(message)") + } +} + +// MARK: - Server-supplied actions + +/// The item's own `actions[]`, rendered instead of the per-kind buttons this +/// sheet used to hardcode. Inline App Intents run against the paired host, so +/// they only appear when the row's machine is both this one and reachable — +/// otherwise every action degrades to navigation. +struct ActivityActionButtons: View { + let row: ActivityRowPresentation + let open: () -> Void + let markSeen: () -> Void + + private var canActInline: Bool { row.inlineActionsAllowed && row.machineOnline } + + private var visibleActions: [AccountAttentionAction] { + row.actions.filter { action in + switch action.kind { + case .approve, .deny, .answer, .restart, .rerunChecks: + return canActInline + case .open: + return row.deepLink != nil + case .markSeen, .dismiss, .unrecognized: + return false + } + } + } + + var body: some View { + if visibleActions.isEmpty { + EmptyView() + } else { + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { buttons } + VStack(spacing: 8) { buttons } + } + .padding(.bottom, 4) + } + } + + @ViewBuilder + private var buttons: some View { + ForEach(visibleActions, id: \.id) { action in + actionButton(action) + } + } + + @ViewBuilder + private func actionButton(_ action: AccountAttentionAction) -> some View { + switch action.kind { + case .approve: + Button(intent: ApproveSessionIntent( + sessionId: row.sessionId ?? "", + itemId: row.pendingItemId ?? "" + )) { + ActivityActionLabel(action.label, systemImage: "checkmark", variant: .primary(ADEColor.success)) + } + .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded(markSeen)) + + case .deny: + Button(intent: DenySessionIntent( + sessionId: row.sessionId ?? "", + itemId: row.pendingItemId ?? "" + )) { + ActivityActionLabel(action.label, systemImage: "xmark", variant: .danger) + } + .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded(markSeen)) + + case .restart: + Button(intent: RestartSessionIntent(sessionId: row.sessionId ?? "")) { + ActivityActionLabel(action.label, systemImage: "arrow.uturn.backward", variant: .secondary) + } + .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded(markSeen)) + + case .rerunChecks: + Button(intent: RetryCheckIntent( + prNumber: row.prNumber ?? 0, + prId: row.actionPayloadString(action, key: "prId") ?? "" + )) { + ActivityActionLabel(action.label, systemImage: "arrow.clockwise", variant: .secondary) + } + .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded(markSeen)) + + // Answering happens in the session, not in a drawer row. + case .answer, .open, .markSeen, .dismiss, .unrecognized: + Button(action: open) { + ActivityActionLabel(action.label, systemImage: "arrow.right", variant: .secondary) + } + .buttonStyle(.plain) + } + } +} + +private extension ActivityRowPresentation { + func actionPayloadString(_ action: AccountAttentionAction, key: String) -> String? { + guard case .string(let value)? = action.payload?[key] else { return nil } + return value + } +} + +private enum ActivityActionVariant { + case primary(Color) + case secondary + case danger + + var foreground: Color { + switch self { + case .primary(let tint): return tint + case .secondary: return ADEColor.textPrimary + case .danger: return ADEColor.danger + } + } + + var background: Color { + switch self { + case .primary(let tint): return tint.opacity(0.18) + case .secondary: return ADEColor.surfaceBackground.opacity(0.72) + case .danger: return ADEColor.danger.opacity(0.14) + } + } + + var stroke: Color { + switch self { + case .primary(let tint): return tint.opacity(0.32) + case .secondary: return ADEColor.glassBorder + case .danger: return ADEColor.danger.opacity(0.30) + } + } +} + +private struct ActivityActionLabel: View { + let title: String + let systemImage: String + let variant: ActivityActionVariant + + init(_ title: String, systemImage: String, variant: ActivityActionVariant) { + self.title = title + self.systemImage = systemImage + self.variant = variant + } + + var body: some View { + HStack(spacing: 5) { + Image(systemName: systemImage) + .font(.system(.caption2, design: .rounded).weight(.bold)) + .accessibilityHidden(true) + Text(title) + .font(.system(.caption, design: .rounded).weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.76) + } + .foregroundStyle(variant.foreground) + .frame(maxWidth: .infinity, minHeight: 44) + .padding(.horizontal, 10) + .background(variant.background, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(variant.stroke, lineWidth: 0.6) + ) + .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } +} diff --git a/apps/ios/ADE/Views/Activity/ActivityRow.swift b/apps/ios/ADE/Views/Activity/ActivityRow.swift new file mode 100644 index 000000000..3c4be7085 --- /dev/null +++ b/apps/ios/ADE/Views/Activity/ActivityRow.swift @@ -0,0 +1,367 @@ +import SwiftUI + +/// The one Activity row, in two densities. +/// +/// `regular` is the drawer/list row; `compact` is the fixed-width card in the +/// hub's "Live now" strip. Both read every field from `ActivityRowPresentation` +/// and nothing else — no service, no snapshot, no transport — so the drawer, +/// the hub, and the widget cannot describe one session three different ways. +/// +/// Colours are resolved here rather than in the presentation so the mapper can +/// stay iOS-17-safe and design-system-free. +enum ActivityRowDensity { + case regular + case compact +} + +// `activityToneColor` lives in `ADE/Shared/ActivityWidgetPresentation.swift` so +// the widget extension can read the same table; it is not app-only. + +struct ActivityRow: View { + let row: ActivityRowPresentation + var density: ActivityRowDensity = .regular + /// Rows belonging to an offline machine recede — the banner above them + /// carries the explanation, so the rows only need to stop competing. + var dimmed: Bool = false + let onOpen: () -> Void + /// The compact card is a fixed width so the strip scrolls predictably; it + /// still has to grow with the text inside it or the title clips at AX sizes. + @ScaledMetric(relativeTo: .footnote) private var compactCardWidth: CGFloat = 208 + + var body: some View { + Button(action: onOpen) { + content + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .opacity(dimmed ? 0.55 : 1) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + .accessibilityHint(row.isPullRequest ? "Opens the pull request." : "Opens the session.") + } + + /// Everything the row shows visually, in words. The offline state is carried + /// only by `dimmed`'s opacity and the plan bar/status note are dropped by + /// `.combine`'s label override, so without them VoiceOver hears strictly + /// less than a sighted reader sees. + private var accessibilityLabel: String { + var parts = [row.title, row.phaseLabel, row.scopeLabel] + if let note = row.statusNote { parts.append(note) } + if let progress = row.planProgress, progress.total > 0 { + parts.append("step \(progress.completed) of \(progress.total)") + } + if let model = row.modelLabel { parts.append(model) } + parts.append(row.machineOnline ? "machine online" : "machine offline") + return parts.joined(separator: ", ") + } + + @ViewBuilder + private var content: some View { + switch density { + case .regular: regularContent + case .compact: compactContent + } + } + + // MARK: - Regular + + private var regularContent: some View { + HStack(alignment: .top, spacing: 11) { + ActivityProviderMark(slug: row.providerSlug, size: 26, pulse: row.isActive) + + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(row.title) + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.8) + Spacer(minLength: 6) + ActivityStatusLabel(row: row) + } + + if let note = row.statusNote { + Text(note) + .font(.system(.caption, design: .rounded)) + .italic() + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + + if let progress = row.planProgress, progress.total > 0 { + ActivityPlanProgressBar(progress: progress, tone: row.tone) + } + + metaRow + } + } + .padding(.vertical, 9) + } + + private var metaRow: some View { + HStack(spacing: 6) { + if let lane = row.laneName { + ActivityLaneChip(name: lane) + } + ActivityMachineChip( + name: row.machineName, + online: row.machineOnline, + lastSeenLabel: row.lastSeenLabel() + ) + if let model = row.modelLabel { + Text(model) + .font(.system(.caption2, design: .rounded)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer(minLength: 0) + } + } + + // MARK: - Compact + + private var compactContent: some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 7) { + ActivityProviderMark(slug: row.providerSlug, size: 18, pulse: row.isActive) + ActivityStatusLabel(row: row) + Spacer(minLength: 0) + } + + Text(row.title) + .font(.system(.footnote, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(row.laneName.map { "\($0) · \(row.machineName)" } ?? row.machineName) + .font(.system(.caption2, design: .rounded)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } + .padding(11) + .frame(minHeight: 44) + .frame(width: compactCardWidth, alignment: .leading) + .background(ADEColor.cardBackground.opacity(0.62), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke( + row.prominent + ? activityToneColor(row.tone).opacity(0.45) + : ADEColor.border.opacity(0.8), + lineWidth: 1 + ) + ) + } +} + +/// Status dot + phase label + the elapsed ticker, in the tone the phase owns. +struct ActivityStatusLabel: View { + let row: ActivityRowPresentation + /// Re-renders once a second only while a row is actually ticking. + @State private var now = Date() + + var body: some View { + let tint = activityToneColor(row.tone) + HStack(spacing: 5) { + ActivityStatusDot(tone: row.tone, active: row.isActive) + Text(label) + .font(.system(.caption2, design: .rounded).weight(.semibold).monospacedDigit()) + .foregroundStyle(tint) + .lineLimit(1) + .fixedSize() + } + .task(id: row.showsElapsed) { + guard row.showsElapsed else { return } + while !Task.isCancelled { + now = Date() + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + } + } + + private var label: String { + guard let elapsed = row.elapsedLabel(now: now) else { return row.phaseLabel } + return "\(row.phaseLabel) \(elapsed)" + } +} + +struct ActivityStatusDot: View { + let tone: ActivityTone + var active: Bool = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + let tint = activityToneColor(tone) + ZStack { + if active && !reduceMotion { + Circle() + .fill(tint) + .frame(width: 7, height: 7) + .phaseAnimator([false, true]) { circle, expanded in + circle + .scaleEffect(expanded ? 2.1 : 1) + .opacity(expanded ? 0 : 0.4) + } animation: { _ in + .easeOut(duration: 1.5) + } + } + Circle() + .fill(tint) + .frame(width: 7, height: 7) + } + .frame(width: 7, height: 7) + .accessibilityHidden(true) + } +} + +/// Provider logo on its brand-tinted disc. Falls back to the ADE mark's neutral +/// disc when the item carries no provider. +struct ActivityProviderMark: View { + let slug: String? + let size: CGFloat + var pulse: Bool = false + + var body: some View { + let resolved = slug ?? "ade" + let color = ADESharedTheme.brandColor(for: resolved) + Circle() + .fill(color.opacity(0.16)) + .frame(width: size, height: size) + .overlay { + if let assetName = ADESharedTheme.providerAssetName(for: resolved) { + Image(assetName) + .resizable() + .scaledToFit() + .frame(width: size * 0.66, height: size * 0.66) + } else { + Image(systemName: "terminal.fill") + .font(.system(size: size * 0.46, weight: .semibold)) + .foregroundStyle(color) + } + } + .overlay(Circle().strokeBorder(color.opacity(0.3), lineWidth: 0.7)) + .accessibilityHidden(true) + } +} + +/// Neutral tower glyph + machine name. Machine identity is deliberately not +/// tinted: amber means "your move" and nothing else, so it can never also mean +/// "this ran somewhere else". +struct ActivityMachineChip: View { + let name: String + let online: Bool + var lastSeenLabel: String? + + var body: some View { + HStack(spacing: 4) { + Image(systemName: online ? "desktopcomputer" : "wifi.slash") + .font(.system(.caption2, design: .rounded).weight(.semibold)) + .accessibilityHidden(true) + Text(lastSeenLabel.map { "\(name) · \($0)" } ?? name) + .font(.system(.caption2, design: .rounded).weight(.medium)) + .lineLimit(1) + } + .foregroundStyle(online ? ADEColor.textSecondary : ADEColor.textMuted) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(ADEColor.surfaceBackground.opacity(online ? 0.7 : 0.45), in: Capsule()) + .accessibilityElement(children: .ignore) + .accessibilityLabel( + [name, online ? "online" : "offline", lastSeenLabel.map { "last seen \($0)" }] + .compactMap { $0 } + .joined(separator: ", ") + ) + } +} + +struct ActivityLaneChip: View { + let name: String + + var body: some View { + Text(name) + .font(.system(.caption2, design: .rounded).weight(.medium)) + .foregroundStyle(ADEColor.accent) + .lineLimit(1) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(ADEColor.accent.opacity(0.12), in: Capsule()) + } +} + +/// The plan bar iOS carried in the contract and never rendered: "3 of 7" plus +/// the current step, over a hairline track. +struct ActivityPlanProgressBar: View { + let progress: AccountAttentionPlanProgress + let tone: ActivityTone + + private var fraction: Double { + guard progress.total > 0 else { return 0 } + return min(1, max(0, Double(progress.completed) / Double(progress.total))) + } + + var body: some View { + let tint = activityToneColor(tone) + VStack(alignment: .leading, spacing: 3) { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule() + .fill(ADEColor.recessedBackground) + Capsule() + .fill(tint.opacity(0.75)) + .frame(width: max(2, geometry.size.width * fraction)) + } + } + .frame(height: 3) + + HStack(spacing: 5) { + Text("\(progress.completed) of \(progress.total)") + .font(.system(.caption2, design: .rounded).weight(.semibold).monospacedDigit()) + .foregroundStyle(ADEColor.textSecondary) + if let current = progress.current, !current.isEmpty { + Text(current) + .font(.system(.caption2, design: .rounded)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel( + "Plan progress: \(progress.completed) of \(progress.total)" + + (progress.current.flatMap { $0.isEmpty ? nil : ", \($0)" } ?? "") + ) + } +} + +/// Inline banner above the rows of a machine that is no longer reachable. The +/// rows below it dim rather than disappear — an offline machine's work still +/// happened, it just cannot be acted on from here. +struct ActivityOfflineMachineBanner: View { + let machineName: String + let lastSeenLabel: String? + + var body: some View { + HStack(spacing: 7) { + Image(systemName: "wifi.slash") + .font(.system(.caption2, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + .accessibilityHidden(true) + Text(lastSeenLabel.map { "\(machineName) · \($0)" } ?? machineName) + .font(.system(.caption2, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + Rectangle() + .fill(ADEColor.border.opacity(0.55)) + .frame(height: 1) + } + .accessibilityElement(children: .combine) + .accessibilityLabel( + lastSeenLabel.map { "\(machineName) is offline. Last seen \($0)." } + ?? "\(machineName) is offline." + ) + } +} diff --git a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift deleted file mode 100644 index 11348d2b4..000000000 --- a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift +++ /dev/null @@ -1,781 +0,0 @@ -import Combine -import Foundation -import SwiftUI - -/// Presentation bucket for a drawer row — what glyph and hue it wears. -/// -/// `awaitingInput` is the only amber kind, and that is deliberate: amber means -/// "your move" and nothing else (see `AgentRunPhase` and the desktop's -/// `sessionStatusPresentation`). `blocked` is a separate, neutral kind for -/// exactly that reason — it used to share `awaitingInput`, which put a raised -/// hand and an unmet dependency under one bell and one colour. -@available(iOS 17.0, *) -public enum AttentionKind: String, Codable, Hashable, Sendable { - case awaitingInput - case blocked - case failed - 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. -/// -/// Built by `AttentionDrawerModel.rebuild(from:)` from the active -/// `WorkspaceSnapshot` so the drawer doesn't need its own transport. -@available(iOS 17.0, *) -public struct AttentionItem: Identifiable, Equatable { - public let id: String - public let kind: AttentionKind - public let title: String - public let subtitle: String - public let providerSlug: String? - public let sessionId: String? - public let itemId: String? - public let prId: String? - 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, - kind: AttentionKind, - title: String, - subtitle: String, - providerSlug: String? = nil, - sessionId: String? = nil, - itemId: String? = nil, - prId: String? = nil, - prNumber: Int? = nil, - deepLink: URL? = nil, - 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 - self.title = title - self.subtitle = subtitle - self.providerSlug = providerSlug - self.sessionId = sessionId - self.itemId = itemId - self.prId = prId - 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 .blocked: return "Blocked" - 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" - // "Stale", not "Offline": the run is reachable and silent. Calling it - // offline points at the network, which is the one thing that is not - // wrong here. - case .stale: return "Stale" - } - } -} - -/// Source of truth for the in-app Attention Drawer. -/// -/// 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 { - defaults.set( - lastSeenAt.timeIntervalSince1970, - forKey: Self.lastSeenAtKey - ) - recomputeUnreadCount() - } - } - - 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 - let stored = defaults.double(forKey: Self.lastSeenAtKey) - self.lastSeenAt = stored > 0 - ? Date(timeIntervalSince1970: stored) - : .distantPast - self.dismissedItemIDs = Set(defaults.stringArray(forKey: Self.dismissedItemIDsKey) ?? []) - self.seenItemIDs = Set(defaults.stringArray(forKey: Self.seenItemIDsKey) ?? []) - } - - // MARK: - Reducer - - /// Rebuild `items` from the current workspace snapshot. Items are sorted - /// by kind priority (awaiting > failed > ci > review > merge) then by - /// 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 { - let preview = agent.preview?.trimmingCharacters(in: .whitespacesAndNewlines) - let subtitle = preview.flatMap { $0.isEmpty ? nil : $0 } ?? "Approval needed" - result.append( - AttentionItem( - id: "awaiting:\(agent.sessionId)", - kind: .awaitingInput, - title: Self.humanAgentTitle(agent), - subtitle: subtitle, - providerSlug: agent.provider, - sessionId: agent.sessionId, - itemId: agent.pendingInputItemId, - deepLink: URL(string: "ade://session/\(agent.sessionId)"), - timestamp: agent.lastActivityAt, - machineId: machineId, - machineName: machineName, - machineOnline: machineOnline, - projectId: projectId, - projectName: projectName, - laneName: agent.laneName - ) - ) - } else if Self.isAgentFailed(agent) { - result.append( - AttentionItem( - id: "failed:\(agent.sessionId)", - kind: .failed, - title: Self.humanAgentTitle(agent), - subtitle: "Agent failed", - providerSlug: agent.provider, - sessionId: agent.sessionId, - deepLink: URL(string: "ade://session/\(agent.sessionId)"), - 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) { - let isBlocked = Self.nonEmpty(agent.phase)?.lowercased() == "blocked" - live.append( - AttentionItem( - id: "live:\(agent.sessionId)", - kind: machineOnline - ? (isBlocked ? .blocked : .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 - ) - ) - } - } - - for pr in snapshot.prs where pr.state == "open" { - let prTimestamp = pr.updatedAt ?? generated - if pr.checks == "failing" { - result.append( - AttentionItem( - id: "ci:\(pr.id)", - kind: .ciFailing, - title: "PR #\(pr.number) · \(pr.title)", - subtitle: "Checks failing", - prId: pr.id, - prNumber: pr.number, - deepLink: URL(string: "ade://pr/\(pr.number)"), - timestamp: prTimestamp, - machineId: machineId, - machineName: machineName, - machineOnline: machineOnline, - projectId: projectId, - projectName: projectName - ) - ) - } else if pr.mergeReady { - result.append( - AttentionItem( - id: "merge:\(pr.id)", - kind: .mergeReady, - title: "PR #\(pr.number) · \(pr.title)", - subtitle: "Ready to merge", - prId: pr.id, - prNumber: pr.number, - deepLink: URL(string: "ade://pr/\(pr.number)"), - timestamp: prTimestamp, - machineId: machineId, - machineName: machineName, - machineOnline: machineOnline, - projectId: projectId, - projectName: projectName - ) - ) - } else if pr.review == "pending" || pr.review == "changes_requested" { - result.append( - AttentionItem( - id: "review:\(pr.id)", - kind: .reviewRequested, - title: "PR #\(pr.number) · \(pr.title)", - subtitle: pr.review == "changes_requested" - ? "Changes requested" - : "Review requested", - prId: pr.id, - prNumber: pr.number, - deepLink: URL(string: "ade://pr/\(pr.number)"), - timestamp: prTimestamp, - machineId: machineId, - machineName: machineName, - machineOnline: machineOnline, - projectId: projectId, - projectName: projectName - ) - ) - } - } - - pruneDismissedItems(activeIDs: Set(result.map(\.id))) - result.removeAll { dismissedItemIDs.contains($0.id) } - 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 - /// 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() { - let visible = visibleItems(in: .needsYou) - guard !visible.isEmpty else { return } - - let visibleIds = Set(visible.map(\.id)) - dismissedItemIDs.formUnion(visibleIds) - let accountIds = accountBackedItemIDs.intersection(visibleIds) - persistDismissedItems() - items.removeAll { visibleIds.contains($0.id) } - validateSelectedProject() - recomputeUnreadCount() - if !accountIds.isEmpty { - Task { await AccountService.shared.acknowledgeAttentionItems(Array(accountIds), dismiss: true) } - } - } - - // MARK: - Bell affordance - - /// Count label for the drawer badge. Returns `nil` at zero, `"9+"` for - /// anything > 9 so the 16pt circle never grows past two glyphs. - public var badgeLabel: String? { - guard unreadCount > 0 else { return nil } - return unreadCount > 9 ? "9+" : "\(unreadCount)" - } - - // MARK: - Private - - private func recomputeUnreadCount() { - 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) { - let pruned = dismissedItemIDs.intersection(activeIDs) - guard pruned != dismissedItemIDs else { return } - dismissedItemIDs = pruned - persistDismissedItems() - } - - private func persistDismissedItems() { - 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 - case .failed: return 1 - case .ciFailing: return 2 - case .reviewRequested: return 3 - case .mergeReady: return 4 - case .running: return 5 - // Blocked sorts below live work and above a silent one: it is not - // asking for anything, but it has not gone quiet either. - case .blocked: return 6 - case .stale: return 7 - case .open: return 8 - case .completed: return 8 - case .merged: return 8 - } - } - - 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 = 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 "Working" - 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 "Done" - 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: - kind = .awaitingInput - collection = .needsYou - // Blocked already filed itself under `live` rather than `needsYou` — - // the drawer never thought it was the user's move. It now looks the - // part too, instead of borrowing the amber bell from a raised hand. - case .blocked: - kind = .blocked - collection = .live - 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 - -@available(iOS 17.0, *) -extension AttentionDrawerModel { - /// Wire the drawer model up to a live `SyncService`: rebuild whenever - /// the service's `activeSessions` or App Group workspace snapshot changes. The - /// workspace snapshot is read from the App Group since `SyncService` - /// already writes the authoritative blob there — no separate transport. - /// - /// Returns the set of cancellables so callers (typically `SyncService` - /// itself) can retain them for the drawer's lifetime. - func bind(to syncService: SyncService) -> Set { - var bag: Set = [] - - 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(), - agents: syncService.activeSessions, - prs: [], - connection: "disconnected" - ) - self.rebuild(from: snapshot) - } - - syncService.$activeSessions - .receive(on: DispatchQueue.main) - .sink { _ in refresh() } - .store(in: &bag) - - syncService.$localStateRevision - .receive(on: DispatchQueue.main) - .sink { _ in refresh() } - .store(in: &bag) - - syncService.$workspaceSnapshotRevision - .receive(on: DispatchQueue.main) - .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 deleted file mode 100644 index 3d5570eeb..000000000 --- a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift +++ /dev/null @@ -1,1010 +0,0 @@ -import AppIntents -import SwiftUI - -/// 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 drawer: AttentionDrawerModel - @EnvironmentObject private var accountService: AccountService - @Environment(\.dismiss) private var dismiss - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var didAppear = false - - 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 needsYou.isEmpty && live.isEmpty && recent.isEmpty { - emptyState - } else { - priorityStack - } - } - .navigationTitle("Attention") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("Done") { dismiss() } - } - ToolbarItem(placement: .topBarTrailing) { - 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(needsYou.isEmpty) - } label: { - Image(systemName: "ellipsis.circle") - } - .accessibilityLabel("Attention actions") - } - } - .adeScreenBackground() - .adeNavigationGlass() - } - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - .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 - } - } - } - } - - 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 { - Circle() - .fill( - RadialGradient( - colors: [PrGlassPalette.purple.opacity(0.30), .clear], - center: .center, - startRadius: 0, - endRadius: 56 - ) - ) - .frame(width: 120, height: 120) - .blur(radius: 10) - - Circle() - .fill(.ultraThinMaterial) - .frame(width: 64, height: 64) - .overlay(Circle().strokeBorder(PrGlassPalette.accentGradient, lineWidth: 1).opacity(0.6)) - - Image(systemName: "sparkles") - .font(.system(size: 28, weight: .regular)) - .foregroundStyle(PrGlassPalette.purple.opacity(0.95)) - .modifier(DrawerPulseEffect(active: !reduceMotion)) - } - - VStack(spacing: 6) { - Text("All clear") - .font(.title3.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Text("Agent work from your connected machines will gather here.") - .font(.subheadline) - .foregroundStyle(ADEColor.textSecondary) - .multilineTextAlignment(.center) - } - Spacer() - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.horizontal, 32) - .accessibilityElement(children: .combine) - .accessibilityLabel("All clear. No pending attention items.") - } - - private func selectProject(_ id: String?) { - if reduceMotion { - drawer.selectProject(id) - } else { - withAnimation(.snappy(duration: 0.28)) { - drawer.selectProject(id) - } - } - } - - private func follow(_ item: AttentionItem) { - guard let url = item.deepLink else { return } - drawer.markSeen(item.id) - dismiss() - DispatchQueue.main.asyncAfter(deadline: .now() + (reduceMotion ? 0 : 0.18)) { - DeepLinkRouter.shared.handle(url) - } - } -} - -@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) - } -} - -@available(iOS 17.0, *) -private struct AttentionHeroCard: 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: 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) - } - - 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)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(2) - .multilineTextAlignment(.leading) - Text(item.subtitle) - .font(.caption) - .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) - Text(item.timestamp, style: .relative) - .font(.caption2) - .foregroundStyle(ADEColor.textSecondary) - } - } - .buttonStyle(.plain) - - AttentionDrawerActionRow(item: item, open: open, markSeen: markSeen) - } - .padding(14) - .background(ADEColor.cardBackground.opacity(0.96), in: RoundedRectangle(cornerRadius: 15, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 15, style: .continuous) - .strokeBorder(tint.opacity(0.24), lineWidth: 0.7) - ) - } -} - -@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 AttentionRecentRow: View { - let item: AttentionItem - let open: () -> Void - - var body: some View { - 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) - ) - } -} - -@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)) - - 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) - 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 .mergeReady: - Button(action: open) { - AttentionDrawerActionLabel(prLabel("Review merge"), systemImage: "checkmark.seal", variant: .primary(ADEColor.success)) - } - .buttonStyle(.plain) - - // Nothing to approve, rerun, or restart on any of these — a blocked row - // included, which is the point: it is waiting on something that is not - // a button in this drawer. - case .running, .blocked, .open, .completed, .merged, .stale: - Button(action: open) { - AttentionDrawerActionLabel("Open", systemImage: "arrow.right", variant: .secondary) - } - .buttonStyle(.plain) - } - } - - private func prLabel(_ verb: String) -> String { - guard let number = item.prNumber, number > 0 else { return "\(verb) PR" } - return "\(verb) #\(number)" - } -} - -@available(iOS 17.0, *) -private enum AttentionDrawerActionVariant { - case primary(Color) - case secondary - case danger - - var foreground: Color { - switch self { - case .primary(let tint): return tint - case .secondary: return ADEColor.textPrimary - case .danger: return ADEColor.danger - } - } - - var background: Color { - switch self { - case .primary(let tint): return tint.opacity(0.18) - case .secondary: return ADEColor.surfaceBackground.opacity(0.72) - case .danger: return ADEColor.danger.opacity(0.14) - } - } - - var stroke: Color { - switch self { - case .primary(let tint): return tint.opacity(0.32) - case .secondary: return ADEColor.glassBorder - case .danger: return ADEColor.danger.opacity(0.30) - } - } -} - -@available(iOS 17.0, *) -private struct AttentionDrawerActionLabel: View { - let title: String - let systemImage: String - let variant: AttentionDrawerActionVariant - - init(_ title: String, systemImage: String, variant: AttentionDrawerActionVariant) { - self.title = title - self.systemImage = systemImage - self.variant = variant - } - - var body: some View { - HStack(spacing: 5) { - Image(systemName: systemImage) - .font(.system(size: 10, weight: .bold)) - Text(title) - .font(.caption.weight(.semibold)) - .lineLimit(1) - .minimumScaleFactor(0.76) - } - .foregroundStyle(variant.foreground) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .padding(.horizontal, 10) - .background(variant.background, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(variant.stroke, lineWidth: 0.6) - ) - .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()) - } - } -} - -@available(iOS 17.0, *) -private enum AttentionIcon { - static func symbol(for kind: AttentionKind) -> String { - switch kind { - case .awaitingInput: return "bell.badge.fill" - case .blocked: return "hourglass" - case .failed: return "xmark.octagon.fill" - case .ciFailing: return "exclamationmark.triangle.fill" - case .reviewRequested: return "eye.fill" - case .mergeReady: return "checkmark.seal.fill" - // The dashed circle the widgets and the desktop sidebar use for work - // in flight, rather than a heartbeat trace only this surface knew. - case .running: return "circle.dotted" - case .open: return "arrow.triangle.pull" - case .completed: return "checkmark.circle.fill" - case .merged: return "arrow.triangle.merge" - // A clock, not `wifi.slash`: a stale run is reachable and silent, so - // the question is how long it has been quiet, not whether the network - // dropped. - case .stale: return "clock.badge.exclamationmark" - } - } - - /// Amber lives on exactly one kind here — `awaitingInput` — and everything - /// that merely reports a fact takes a hue that makes no claim on the user. - static func tint(for kind: AttentionKind) -> Color { - switch kind { - 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 .blocked, .stale: return ADESharedTheme.statusIdle - } - } -} - -@available(iOS 17.0, *) -private struct AttentionBadge: View { - let kind: AttentionKind - let size: CGFloat - let pulse: Bool - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - var body: some View { - let color = AttentionIcon.tint(for: kind) - ZStack { - Circle() - .fill(color.opacity(0.13)) - .frame(width: size, height: size) - - if pulse && kind == .awaitingInput && !reduceMotion { - Circle() - .stroke(color, lineWidth: 1.5) - .frame(width: size, height: size) - .phaseAnimator([false, true]) { circle, expanded in - circle - .scaleEffect(expanded ? 1.5 : 1) - .opacity(expanded ? 0 : 0.9) - } animation: { _ in - .easeOut(duration: 1.6) - } - } - - Image(systemName: AttentionIcon.symbol(for: kind)) - .font(.system(size: size * 0.5, weight: .semibold)) - .foregroundStyle(color) - .modifier(BellWiggle(active: pulse && kind == .awaitingInput && !reduceMotion)) - } - .accessibilityHidden(true) - } -} - -@available(iOS 17.0, *) -private struct BrandDot: View { - let slug: String - let size: CGFloat - let pulse: Bool - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - var body: some View { - let color = ADESharedTheme.brandColor(for: slug) - ZStack { - if pulse && !reduceMotion { - Circle() - .fill(color) - .frame(width: size, height: size) - .phaseAnimator([false, true]) { circle, expanded in - circle - .scaleEffect(expanded ? 1.6 : 1) - .opacity(expanded ? 0 : 0.34) - } animation: { _ in - .easeInOut(duration: 1.5) - } - } - Circle() - .fill(color.opacity(0.18)) - .frame(width: size, height: size) - .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) - } -} - -@available(iOS 17.0, *) -private struct BellWiggle: ViewModifier { - let active: Bool - - func body(content: Content) -> some View { - if active { - content.keyframeAnimator(initialValue: 0.0, repeating: true) { view, rotation in - view.rotationEffect(.degrees(rotation)) - } keyframes: { _ in - KeyframeTrack { - 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 { - content - } - } -} - -@available(iOS 17.0, *) -private struct DrawerPulseEffect: ViewModifier { - let active: Bool - - func body(content: Content) -> some View { - if active { - content.symbolEffect(.pulse, options: .repeating) - } else { - content - } - } -} diff --git a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift index 524fecc7e..0de52a21b 100644 --- a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift +++ b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift @@ -859,7 +859,7 @@ struct ADEHubBackButton: View { struct ADERootToolbarControls: View { @EnvironmentObject private var syncService: SyncService - @EnvironmentObject private var drawer: AttentionDrawerModel + @EnvironmentObject private var drawer: ActivityDrawerModel /// Disambiguator folded into inspector ids so two simultaneous instances of /// this control (e.g. the active and incoming root tab during a transition) @@ -938,7 +938,7 @@ struct ADERootToolbarControls: View { icon: "bell.fill", tint: hasUnread ? ADESharedTheme.warningAmber : PrsGlass.textSecondary, isAlive: hasUnread, - accessibilityLabel: "Attention items: \(drawer.unreadCount)", + accessibilityLabel: hasUnread ? "Activity, \(drawer.unreadCount) need you" : "Activity", action: { syncService.attentionDrawerPresented = true } ) } @@ -1020,7 +1020,7 @@ struct ADERootToolbarLeading: View { HStack(spacing: 10) { ADEConnectionDot() ADEProjectHubButton() - AttentionDrawerButton() + ActivityBellButton() } .fixedSize(horizontal: true, vertical: false) } @@ -1124,7 +1124,7 @@ struct ADERootToolbarLeadingItems: ToolbarContent { .sharedBackgroundVisibility(.hidden) ToolbarItem(placement: .topBarLeading) { - AttentionDrawerButton() + ActivityBellButton() } .sharedBackgroundVisibility(.hidden) } diff --git a/apps/ios/ADE/Views/Hub/HubComponents.swift b/apps/ios/ADE/Views/Hub/HubComponents.swift index 962c351b4..5ee13f451 100644 --- a/apps/ios/ADE/Views/Hub/HubComponents.swift +++ b/apps/ios/ADE/Views/Hub/HubComponents.swift @@ -28,6 +28,10 @@ struct HubTopBar: View { HubConnectionPill() .layoutPriority(1) + // The hub was the one root without a bell, which made the phone's home + // screen the only place you could not see that something needed you. + ActivityBellButton() + HubCircularButton(systemImage: "plus", tint: ADEColor.accent, action: onAdd) .accessibilityLabel("Add project") @@ -186,7 +190,14 @@ struct HubProjectPresentation: Equatable, Identifiable { let laneCount: Int let chatCount: Int let lanes: [HubLanePresentation] + /// Chats on this project awaiting input, and chats currently producing. Both + /// were computed by the roster and used only as a sort tiebreak until now. + let attentionCount: Int + let runningCount: Int let metaLine: String + /// "2 need you · 3 working", or nil when the project is quiet. Rendered + /// beside the lane/chat counts with a status dot per clause. + let statusLine: String? fileprivate let renderSignature: Int var id: String { project.id } @@ -198,7 +209,9 @@ struct HubProjectPresentation: Equatable, Identifiable { isLoading: Bool, laneCount: Int, chatCount: Int, - lanes: [HubLanePresentation] + lanes: [HubLanePresentation], + attentionCount: Int = 0, + runningCount: Int = 0 ) { self.project = project self.isActive = isActive @@ -207,9 +220,15 @@ struct HubProjectPresentation: Equatable, Identifiable { self.laneCount = laneCount self.chatCount = chatCount self.lanes = lanes + self.attentionCount = attentionCount + self.runningCount = runningCount let lanePart = "\(laneCount) lane\(laneCount == 1 ? "" : "s")" let chatPart = "\(chatCount) chat\(chatCount == 1 ? "" : "s")" self.metaLine = "\(lanePart) · \(chatPart)" + self.statusLine = hubProjectStatusLine( + attentionCount: attentionCount, + runningCount: runningCount + ) self.renderSignature = hubProjectRenderSignature( project: project, isActive: isActive, @@ -217,7 +236,9 @@ struct HubProjectPresentation: Equatable, Identifiable { isLoading: isLoading, laneCount: laneCount, chatCount: chatCount, - lanes: lanes + lanes: lanes, + attentionCount: attentionCount, + runningCount: runningCount ) } @@ -320,9 +341,13 @@ private func hubProjectRenderSignature( isLoading: Bool, laneCount: Int, chatCount: Int, - lanes: [HubLanePresentation] + lanes: [HubLanePresentation], + attentionCount: Int, + runningCount: Int ) -> Int { var hasher = Hasher() + hasher.combine(attentionCount) + hasher.combine(runningCount) hasher.combine(project.id) hasher.combine(project.displayName) hasher.combine(hubProjectIconSignature(project.iconDataUrl)) @@ -337,6 +362,15 @@ private func hubProjectRenderSignature( return hasher.finalize() } +/// Sentence-case, count-first, and silent when there is nothing to report — +/// "0 need you" is filler that trains people to stop reading the line. +func hubProjectStatusLine(attentionCount: Int, runningCount: Int) -> String? { + var clauses: [String] = [] + if attentionCount > 0 { clauses.append("\(attentionCount) need you") } + if runningCount > 0 { clauses.append("\(runningCount) working") } + return clauses.isEmpty ? nil : clauses.joined(separator: " · ") +} + private func hubProjectIconSignature(_ dataUrl: String?) -> String { guard let dataUrl, !dataUrl.isEmpty else { return "" } let byteCount = dataUrl.utf8.count @@ -450,7 +484,9 @@ func buildHubProjectPresentation( isLoading: false, laneCount: roster.lanes.count, chatCount: chatCount, - lanes: lanes + lanes: lanes, + attentionCount: roster.attentionCount, + runningCount: roster.runningCount ) } @@ -543,12 +579,35 @@ struct HubProjectCard: View, Equatable { .buttonStyle(.plain) // Lane/chat counts live to the left of the open arrow now that the name - // owns the full leading run. - Text(presentation.metaLine) - .font(.system(.caption, design: .rounded)) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(1) - .fixedSize() + // owns the full leading run, with the live status stacked above them. + VStack(alignment: .trailing, spacing: 2) { + if presentation.attentionCount > 0 || presentation.runningCount > 0 { + HStack(spacing: 5) { + if presentation.attentionCount > 0 { + HubStatusDot(status: "awaiting-input") + Text("\(presentation.attentionCount) need you") + .font(.system(.caption2, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.warning) + } + if presentation.runningCount > 0 { + HubStatusDot(status: "active") + Text("\(presentation.runningCount) working") + .font(.system(.caption2, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.success) + } + } + .lineLimit(1) + .fixedSize() + .accessibilityElement(children: .combine) + .accessibilityLabel(presentation.statusLine ?? "") + } + + Text(presentation.metaLine) + .font(.system(.caption, design: .rounded)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + .fixedSize() + } if presentation.isSwitching { ProgressView().controlSize(.small) @@ -706,18 +765,29 @@ struct HubChatRow: View, Equatable { let onDelete: () -> Void var body: some View { - // Deliberately minimal: provider logo, chat name, and the relative - // timestamp. Nothing else competes for the eye at the hub's glance level. + // Provider logo, a status dot, the chat name, and the relative timestamp. + // The status the roster already carried was never rendered here, so a row + // asking for input looked exactly like one that finished an hour ago. Button(action: onOpen) { HStack(spacing: 10) { WorkProviderBareLogo(provider: row.providerKey, fallbackSymbol: "terminal.fill", tint: ADEColor.textSecondary, size: compact ? 16 : 20) + HubStatusDot(status: row.statusString) + Text(row.title) .font(.system(.footnote, design: .rounded).weight(.medium)) .foregroundStyle(ADEColor.textPrimary) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) + if let status = hubChatStatusLabel(row.statusString) { + Text(status) + .font(.system(.caption2, design: .rounded).weight(.semibold)) + .foregroundStyle(workChatStatusTint(row.statusString)) + .lineLimit(1) + .fixedSize() + } + if let activity = row.activityLabel { Text(activity) .font(.system(.caption2, design: .rounded)) @@ -729,7 +799,9 @@ struct HubChatRow: View, Equatable { .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityLabel(row.title) + .accessibilityLabel( + hubChatStatusLabel(row.statusString).map { "\(row.title), \($0)" } ?? row.title + ) .accessibilityHint(row.chat.isChatTool ? "Opens chat." : "Opens session.") // The hub uses a scrolling LazyVStack (not a List), where SwiftUI // `.swipeActions` are unavailable — so pin/archive/close are offered through @@ -752,6 +824,16 @@ struct HubChatRow: View, Equatable { } } +/// Row status in one word, and only when it says something. A resting chat +/// gets its dot and nothing else — the timestamp already tells that story. +func hubChatStatusLabel(_ status: String) -> String? { + switch status { + case "awaiting-input": return "Needs you" + case "active": return "Working" + default: return nil + } +} + struct HubStatusDot: View { let status: String var body: some View { diff --git a/apps/ios/ADE/Views/Hub/HubLiveStrip.swift b/apps/ios/ADE/Views/Hub/HubLiveStrip.swift new file mode 100644 index 000000000..4215bc0be --- /dev/null +++ b/apps/ios/ADE/Views/Hub/HubLiveStrip.swift @@ -0,0 +1,63 @@ +import SwiftUI + +/// "Live now" — a horizontal strip of the agents currently working, across +/// every machine on the account rather than only the paired one. +/// +/// It reads the account snapshot through `ActivityDrawerModel`, so a session on +/// the Studio shows up on the phone's home screen without opening anything. +/// Hidden entirely when nothing is live: an empty strip is a permanent reminder +/// that nothing is happening, which is the opposite of the point. +struct HubLiveStrip: View { + @EnvironmentObject private var drawer: ActivityDrawerModel + + private var rows: [ActivityRowPresentation] { drawer.liveNow } + + var body: some View { + if !rows.isEmpty { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 7) { + Text("Live now") + .font(.system(.caption, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + Text("\(rows.count)") + .font(.system(.caption2, design: .rounded).weight(.semibold).monospacedDigit()) + .foregroundStyle(ADEColor.textMuted) + .contentTransition(.numericText()) + Spacer(minLength: 0) + } + .padding(.horizontal, 2) + .accessibilityElement(children: .ignore) + .accessibilityLabel( + "Live now, \(rows.count) \(rows.count == 1 ? "session" : "sessions")" + ) + + ScrollView(.horizontal) { + HStack(spacing: 10) { + ForEach(rows) { row in + ActivityRow( + row: row, + density: .compact, + dimmed: !row.machineOnline + ) { + open(row) + } + } + } + .padding(.horizontal, 2) + .padding(.vertical, 1) + } + .scrollIndicators(.hidden) + } + // A container, not a leaf: the header carries the summary and each + // card stays individually reachable. A bare `.accessibilityLabel` + // here would attach to nothing and never be spoken. + .accessibilityElement(children: .contain) + } + } + + private func open(_ row: ActivityRowPresentation) { + guard let url = row.deepLink else { return } + drawer.markSeen(row.id) + DeepLinkRouter.shared.handle(url) + } +} diff --git a/apps/ios/ADE/Views/Hub/HubScreen.swift b/apps/ios/ADE/Views/Hub/HubScreen.swift index 5fa3b3e5a..c63115010 100644 --- a/apps/ios/ADE/Views/Hub/HubScreen.swift +++ b/apps/ios/ADE/Views/Hub/HubScreen.swift @@ -206,6 +206,10 @@ struct HubScreen: View { ) ScrollView { LazyVStack(spacing: 12) { + // Everything running right now, across every machine on the account — + // not just the paired one. Hidden entirely when nothing is live. + HubLiveStrip() + // Keep the project catalog mounted while a switch is in flight: only // fall back to the connecting card when there's nothing to show yet. // The switching row carries its own spinner and the others disable, diff --git a/apps/ios/ADE/Views/Lanes/LaneHelpers.swift b/apps/ios/ADE/Views/Lanes/LaneHelpers.swift index 44f25a28f..a97f4f7e0 100644 --- a/apps/ios/ADE/Views/Lanes/LaneHelpers.swift +++ b/apps/ios/ADE/Views/Lanes/LaneHelpers.swift @@ -11,7 +11,7 @@ func lanePriorityBadge(snapshot: LaneListSnapshot) -> some View { } else if snapshot.runtime.bucket == "running" { LaneTypeBadge(text: "Running", tint: ADEColor.success) } else if snapshot.runtime.bucket == "awaiting-input" { - LaneTypeBadge(text: "Attention", tint: ADEColor.warning) + LaneTypeBadge(text: "Activity", tint: ADEColor.warning) } else if snapshot.lane.archivedAt != nil { LaneTypeBadge(text: "Archived", tint: ADEColor.textMuted) } else if let rebaseSuggestion = snapshot.rebaseSuggestion { diff --git a/apps/ios/ADE/Views/Work/WorkLaneOrder.swift b/apps/ios/ADE/Views/Work/WorkLaneOrder.swift new file mode 100644 index 000000000..7aff135fc --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkLaneOrder.swift @@ -0,0 +1,269 @@ +import Foundation + +/// Lane ordering and the singleton/headerless rule for the Work session list. +/// +/// The iOS port of `apps/desktop/src/renderer/components/terminals/workLaneOrder.ts` +/// plus the `headerlessLaneIds` memo in `SessionListPane.tsx`. Both are pure — +/// callers derive `quiet` / `pinned` / activity and hand over plain data — so the +/// rules are unit-testable without mounting a list, exactly as on desktop. +/// +/// iOS has no manual lane drag and no per-lane handoff jobs today, so those two +/// inputs are always at their defaults here. They are modelled anyway: they are +/// the two rules that decide whether a lane KEEPS its header, and leaving them +/// out is how a port silently loses a rule the moment the surface catches up. + +// MARK: - Sort mode + +/// Mirrors the desktop `WorkLaneSortMode`. iOS exposes no sort-mode picker yet, +/// so every call site passes `.created` — the mode desktop also falls back to. +enum WorkLaneSortMode: String, CaseIterable { + case activity + case name + case created + case manual +} + +func normalizeWorkLaneSortMode(_ value: String?) -> WorkLaneSortMode { + WorkLaneSortMode(rawValue: value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "") + ?? .created +} + +// MARK: - Filing tier + +/// Pins outrank quietness: a pinned lane stays up top even when every one of its +/// sessions has settled. +enum WorkLaneTier: Int { + case pinned = 0 + case active = 1 + case quiet = 2 +} + +func workLaneTier(pinned: Bool, quiet: Bool) -> WorkLaneTier { + if pinned { return .pinned } + return quiet ? .quiet : .active +} + +/// One lane's ordering inputs. `lastActivityAt` is the most recent session +/// activity in the lane, nil when the lane has none. +struct WorkLaneOrderInput: Equatable { + let id: String + let name: String + let laneType: String + let createdAt: String + let lastActivityAt: Date? + /// Every session in the lane is settled, snoozed, or archived. + let quiet: Bool + let pinned: Bool + + init( + id: String, + name: String, + laneType: String, + createdAt: String, + lastActivityAt: Date? = nil, + quiet: Bool = false, + pinned: Bool = false + ) { + self.id = id + self.name = name + self.laneType = laneType + self.createdAt = createdAt + self.lastActivityAt = lastActivityAt + self.quiet = quiet + self.pinned = pinned + } + + var tier: WorkLaneTier { workLaneTier(pinned: pinned, quiet: quiet) } +} + +/// Descending compare that always sorts nil last, in either direction. +private func compareDescNilsLast(_ a: Date?, _ b: Date?) -> Int { + if a == b { return 0 } + guard let a else { return 1 } + guard let b else { return -1 } + if a == b { return 0 } + return a > b ? -1 : 1 +} + +private func compareByMode( + _ a: WorkLaneOrderInput, + _ b: WorkLaneOrderInput, + mode: WorkLaneSortMode, + manualIndex: [String: Int] +) -> Int { + switch mode { + case .activity: + return compareDescNilsLast(a.lastActivityAt, b.lastActivityAt) + case .name: + let result = a.name.compare( + b.name, + options: [.caseInsensitive, .numeric, .diacriticInsensitive] + ) + return result == .orderedSame ? 0 : (result == .orderedAscending ? -1 : 1) + case .manual: + // A lane with no recorded position sorts after every placed lane, in the + // fallback order below, so a newly created lane appears predictably rather + // than jumping to an arbitrary slot. + let ai = manualIndex[a.id] ?? Int.max + let bi = manualIndex[b.id] ?? Int.max + return ai == bi ? 0 : (ai < bi ? -1 : 1) + case .created: + return compareDescNilsLast( + workLaneOrderParsedDate(a.createdAt), + workLaneOrderParsedDate(b.createdAt) + ) + } +} + +/// Full ordering key, in priority order: +/// +/// 1. the primary lane, always first — in every mode +/// 2. tier: pinned → active → quiet +/// 3. the active sort mode +/// 4. createdAt desc, then id — a total, stable tiebreak +/// +/// Step 4 exists so the comparator is total: without it, two lanes that tie on +/// the mode key can swap places between renders and the list visibly jitters. +func compareWorkLanes( + _ a: WorkLaneOrderInput, + _ b: WorkLaneOrderInput, + mode: WorkLaneSortMode = .created, + manualIndex: [String: Int] = [:] +) -> Int { + let aPrimary = a.laneType == "primary" ? 0 : 1 + let bPrimary = b.laneType == "primary" ? 0 : 1 + if aPrimary != bPrimary { return aPrimary - bPrimary } + + let tierDelta = a.tier.rawValue - b.tier.rawValue + if tierDelta != 0 { return tierDelta } + + let modeDelta = compareByMode(a, b, mode: mode, manualIndex: manualIndex) + if modeDelta != 0 { return modeDelta } + + let createdDelta = compareDescNilsLast( + workLaneOrderParsedDate(a.createdAt), + workLaneOrderParsedDate(b.createdAt) + ) + if createdDelta != 0 { return createdDelta } + + let idResult = a.id.compare(b.id) + return idResult == .orderedSame ? 0 : (idResult == .orderedAscending ? -1 : 1) +} + +/// Order lanes by the full key. `inputs` supplies the derived quiet/pinned/ +/// activity facts a `LaneSummary` does not carry; a lane with no entry is +/// treated as active and unpinned. +func orderWorkLanes( + _ lanes: [LaneSummary], + inputs: [String: WorkLaneOrderInput], + mode: WorkLaneSortMode = .created, + manualOrder: [String] = [] +) -> [LaneSummary] { + var manualIndex: [String: Int] = [:] + for (index, id) in manualOrder.enumerated() where manualIndex[id] == nil { + manualIndex[id] = index + } + return lanes.enumerated().sorted { lhs, rhs in + let a = inputs[lhs.element.id] ?? WorkLaneOrderInput(lane: lhs.element) + let b = inputs[rhs.element.id] ?? WorkLaneOrderInput(lane: rhs.element) + let delta = compareWorkLanes(a, b, mode: mode, manualIndex: manualIndex) + // Enumeration offset keeps the sort stable for genuinely equal lanes, which + // the total comparator above only leaves for duplicate ids. + return delta == 0 ? lhs.offset < rhs.offset : delta < 0 + }.map(\.element) +} + +extension WorkLaneOrderInput { + init(lane: LaneSummary, lastActivityAt: Date? = nil, quiet: Bool = false, pinned: Bool = false) { + self.init( + id: lane.id, + name: lane.name, + laneType: lane.laneType, + createdAt: lane.createdAt, + lastActivityAt: lastActivityAt, + quiet: quiet, + pinned: pinned + ) + } +} + +// MARK: - Headerless (singleton) lanes + +/// One lane's inputs to the singleton rule. +struct WorkHeaderlessLaneInput: Equatable { + let laneId: String + /// TOP-LEVEL rows only, from the UNFILTERED roster. A chat with terminal + /// children is one unit and must not summon a header; reading the unfiltered + /// roster is what stops the list reshaping while the user types in search. + let topLevelSessionCount: Int + /// A pin is an explicit "keep this where I can see it" — the pin glyph lives + /// on the header, so a pinned lane keeps it. + let pinned: Bool + /// A pending handoff placeholder counts as a second row, so a lane does not + /// lose its header for the second it takes the real session to land. Always + /// false today: iOS has no handoff-job records. + let hasPendingHandoff: Bool + /// A lane whose machine is unreachable keeps its header: the header is the + /// only thing that can carry the dimmed, folded-shut group treatment, and + /// "that machine is gone" is precisely when its work should stop occupying a + /// prime row. + let machineOnline: Bool + + init( + laneId: String, + topLevelSessionCount: Int, + pinned: Bool = false, + hasPendingHandoff: Bool = false, + machineOnline: Bool = true + ) { + self.laneId = laneId + self.topLevelSessionCount = topLevelSessionCount + self.pinned = pinned + self.hasPendingHandoff = hasPendingHandoff + self.machineOnline = machineOnline + } +} + +/// Lanes that render their group WITHOUT a header — the singleton form. +/// +/// One chat per lane is the common workflow, and it used to produce +/// header/card/header/card with the lane name usually duplicating the chat +/// title. The lone card carries the lane identity instead +/// (`WorkSessionGroup.isHeaderless` → `showsLaneIdentity` on the row). +/// +/// Manual sort opts out entirely: a singleton has no header to grab. +func workHeaderlessLaneIds( + _ lanes: [WorkHeaderlessLaneInput], + sortMode: WorkLaneSortMode = .created +) -> Set { + guard sortMode != .manual else { return [] } + var ids: Set = [] + for lane in lanes { + if lane.pinned { continue } + if lane.hasPendingHandoff { continue } + if !lane.machineOnline { continue } + if lane.topLevelSessionCount == 1 { ids.insert(lane.laneId) } + } + return ids +} + +// MARK: - Shared date parsing + +private let workLaneOrderISO8601: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter +}() + +private let workLaneOrderISO8601NoFractional: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter +}() + +func workLaneOrderParsedDate(_ value: String?) -> Date? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return workLaneOrderISO8601.date(from: trimmed) ?? workLaneOrderISO8601NoFractional.date(from: trimmed) +} diff --git a/apps/ios/ADE/Views/Work/WorkRootComponents.swift b/apps/ios/ADE/Views/Work/WorkRootComponents.swift index 97e4fc647..7281af7c0 100644 --- a/apps/ios/ADE/Views/Work/WorkRootComponents.swift +++ b/apps/ios/ADE/Views/Work/WorkRootComponents.swift @@ -557,6 +557,9 @@ struct WorkSessionListRow: View { let isArchived: Bool let transitionNamespace: Namespace.ID? var compact: Bool = false + /// True when no lane header sits above this row — the singleton form, where + /// the row carries the lane identity itself. + var showsLaneIdentity: Bool = true var isLaneDeleting = false @Binding var selectedSessionId: String? let isSelecting: Bool @@ -653,7 +656,8 @@ struct WorkSessionListRow: View { isMuted: isMuted, transitionNamespace: transitionNamespace, isSelectedTransitionSource: selectedSessionId == session.id, - compact: compact + compact: compact, + showsLaneIdentity: showsLaneIdentity ) .equatable() } @@ -1075,6 +1079,13 @@ private struct WorkSessionRowRenderSignature: Equatable { let pullRequestState: String? let status: String let canonicalPhase: CanonicalSessionPhase + /// The rendered capsule and dot, not just the phase behind them: the badge + /// kind moves on its own when planning starts or stops, and the tone is what + /// the dot is painted with. + let badgeKind: SessionBadgeKind? + let rowTone: ActivityTone + let model: String? + let showsLaneIdentity: Bool let settledAt: String? let statusNote: String? let attentionRequestedAt: String? @@ -1107,7 +1118,8 @@ private struct WorkSessionRowRenderSignature: Equatable { isArchived: Bool, isMuted: Bool, isSelectedTransitionSource: Bool, - compact: Bool + compact: Bool, + showsLaneIdentity: Bool ) { self.sessionId = session.id self.title = chatSummary?.title ?? session.title @@ -1130,6 +1142,10 @@ private struct WorkSessionRowRenderSignature: Equatable { self.pullRequestState = pullRequest.map { lanePrStateLabel($0.state) } self.status = status self.canonicalPhase = canonical.phase + self.badgeKind = workSessionStatusBadge(session: session, summary: chatSummary)?.kind + self.rowTone = workSessionRowTone(session: session, summary: chatSummary) + self.model = chatSummary?.model + self.showsLaneIdentity = showsLaneIdentity self.settledAt = session.settledAt self.statusNote = session.statusNote self.attentionRequestedAt = session.attentionRequestedAt @@ -1161,6 +1177,9 @@ struct WorkSessionRow: View, Equatable { let transitionNamespace: Namespace.ID? let isSelectedTransitionSource: Bool var compact: Bool = false + /// The singleton form: no lane header above this row, so the row shows the + /// lane itself. Under a lane header the chip would just repeat the header. + var showsLaneIdentity: Bool = true private let renderSignature: WorkSessionRowRenderSignature init( @@ -1173,7 +1192,8 @@ struct WorkSessionRow: View, Equatable { isMuted: Bool = false, transitionNamespace: Namespace.ID?, isSelectedTransitionSource: Bool, - compact: Bool = false + compact: Bool = false, + showsLaneIdentity: Bool = true ) { self.session = session self.lane = lane @@ -1185,6 +1205,7 @@ struct WorkSessionRow: View, Equatable { self.transitionNamespace = transitionNamespace self.isSelectedTransitionSource = isSelectedTransitionSource self.compact = compact + self.showsLaneIdentity = showsLaneIdentity self.renderSignature = WorkSessionRowRenderSignature( session: session, lane: lane, @@ -1194,7 +1215,8 @@ struct WorkSessionRow: View, Equatable { isArchived: isArchived, isMuted: isMuted, isSelectedTransitionSource: isSelectedTransitionSource, - compact: compact + compact: compact, + showsLaneIdentity: showsLaneIdentity ) } @@ -1270,8 +1292,11 @@ struct WorkSessionRow: View, Equatable { HStack(alignment: .center, spacing: 6) { Group { if isSettled { + // Hollow, not filled: settled work is put away, and the ring says + // that without spending a solid dot on it. Tinted rather than + // white so it still carries the phase's hue. Circle() - .stroke(Color.white.opacity(0.35), lineWidth: 1) + .stroke(rowTint.opacity(0.7), lineWidth: 1) } else { Circle() .fill(rowTint) @@ -1319,25 +1344,45 @@ struct WorkSessionRow: View, Equatable { .foregroundStyle(ADEColor.textMuted) .lineLimit(1) - Text("·") - .font(.caption2) - .foregroundStyle(ADEColor.textMuted.opacity(0.5)) - - if let laneAccent = LaneColorPalette.color(forHex: lane?.color) { - Circle() - .fill(laneAccent) - .frame(width: 6, height: 6) - } else { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 10, weight: .semibold)) + // The model the turn actually runs on. iOS carried it in the chat + // summary and never showed it, so two rows on the same provider were + // indistinguishable. + if let model = renderSignature.model, !model.isEmpty { + Text("·") + .font(.caption2) + .foregroundStyle(ADEColor.textMuted.opacity(0.5)) + Text(shortModelLabel(model)) + .font(.caption2) .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(-1) + } + + // Under a lane header the lane chip only repeats the header, so it is + // spent here on the model instead. A headerless (singleton) row is the + // only thing carrying the lane, and always shows it. + if showsLaneIdentity { + Text("·") + .font(.caption2) + .foregroundStyle(ADEColor.textMuted.opacity(0.5)) + + if let laneAccent = LaneColorPalette.color(forHex: lane?.color) { + Circle() + .fill(laneAccent) + .frame(width: 6, height: 6) + } else { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(ADEColor.textMuted) + } + Text(session.laneName) + .font(.caption2) + .foregroundStyle(LaneColorPalette.color(forHex: lane?.color) ?? ADEColor.textMuted) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(-1) } - Text(session.laneName) - .font(.caption2) - .foregroundStyle(LaneColorPalette.color(forHex: lane?.color) ?? ADEColor.textMuted) - .lineLimit(1) - .truncationMode(.middle) - .layoutPriority(-1) if lane?.status.dirty == true { Circle() @@ -1417,10 +1462,11 @@ struct WorkSessionRow: View, Equatable { providerTint(chatSummary?.provider ?? session.toolType) } - /// Canonical attention capsule (needs_you / failed / stale); nil for calm - /// states so the row never shifts layout when no capsule renders. + /// The row's status capsule, in the full shared vocabulary — needs you, + /// failed, stale, working, planning, done. Nil for the resting states, so the + /// row never shifts layout to say that nothing is happening. var capsuleBadge: SessionBadge? { - canonicalState.badge + workSessionStatusBadge(session: session, summary: chatSummary) } var canonicalState: CanonicalSessionState { @@ -1442,14 +1488,20 @@ struct WorkSessionRow: View, Equatable { workIsPendingChatCreationSession(session) } + /// The status dot's hue. Reads the canonical phase through the shared tone + /// table rather than the coarse four-value status string, so the dot and the + /// capsule above it can never tell different stories. var rowTint: Color { if isPendingSyncCreation { return ADEColor.textMuted } if isArchived { return ADEColor.warning } - return workChatStatusTint(status) + return activityToneColor(renderSignature.rowTone) } var accessibilityLabel: String { var parts = [chatSummary?.title ?? session.title, session.laneName, sessionStatusLabel(for: status)] + if let model = renderSignature.model, !model.isEmpty { + parts.append(shortModelLabel(model)) + } if session.pinned { parts.append("pinned") } diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift index 45ab96673..ce83bd633 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift @@ -53,6 +53,10 @@ extension WorkRootScreen { buffers: searchTextSnapshot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? [:] : syncService.terminalBuffers ) let organization = WorkSessionOrganization(rawValue: sessionOrganizationRaw) ?? .byStatus + // A pin is an explicit "keep this where I can see it": it lifts the lane to + // the top tier and keeps its header, singleton or not. Same store the Lanes + // tab writes, so one pin means one thing across both surfaces. + let pinnedLaneIdsSnapshot = workPinnedLaneIds sessionPresentationRebuildTask = Task.detached(priority: .utility) { try? await Task.sleep(for: .milliseconds(40)) @@ -70,7 +74,8 @@ extension WorkRootScreen { orderedLanes: lanesSnapshot, pullRequests: pullRequestsSnapshot, githubPrs: githubPrsSnapshot, - deletingLaneIds: deletingLaneIds + deletingLaneIds: deletingLaneIds, + pinnedLaneIds: pinnedLaneIdsSnapshot ) await MainActor.run { guard generation == sessionPresentationRebuildGeneration, !Task.isCancelled else { return } diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index b9d338601..0a8f440a8 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -112,6 +112,9 @@ struct WorkRootSessionPresentationTaskKey: Equatable { struct WorkRootScreen: View { @Environment(\.accessibilityReduceMotion) var reduceMotion @EnvironmentObject var syncService: SyncService + /// Machine presence for the offline banner. Injected on the root content in + /// `ContentView`, the same place the bell above this list reads it from. + @EnvironmentObject private var activityDrawer: ActivityDrawerModel /// App-level dictation singleton. Re-injected into pushed composer /// destinations below since `navigationDestination` builds outside the view /// tree and does not inherit environment objects. @@ -167,6 +170,10 @@ struct WorkRootScreen: View { /// the new project's live roster while the database reload catches up. @State var loadedProjectionProjectId: String? @AppStorage("ade.work.archivedSessionIds") var archivedSessionIdsStorage = "" + /// Read-only mirror of the Lanes tab's pin store. Pins decide the top lane + /// tier and keep a lane's header, so the Work list has to see them; it never + /// writes here, so pinning stays a Lanes-tab gesture with one owner. + @AppStorage("ade.lanes.pinnedIds") private var pinnedLaneIdsStorage: String = "" @State var sessionOrganizationRaw = WorkSessionOrganization.byLane.rawValue @State var collapsedSectionIdsStorage = "" /// The project+host scope the five view-state properties above currently hold. @@ -450,6 +457,22 @@ struct WorkRootScreen: View { sessionPresentation.sessionGroups } + /// Lanes the user has pinned, read from the Lanes tab's store. + var workPinnedLaneIds: Set { + Set(pinnedLaneIdsStorage.split(separator: ",").map(String.init).filter { !$0.isEmpty }) + } + + /// Machines that own work in this project and are no longer reachable. The + /// connected host is online by definition, so anything here is a second Mac + /// whose lanes reached this list through the account feed. + var offlineMachineBanners: [WorkOfflineMachineBanner] { + workOfflineMachineBanners( + scopes: activityDrawer.offlineScopes, + activeProjectId: syncService.activeProjectId, + laneIds: Set(lanes.map(\.id)) + ) + } + var isWorkRootActive: Bool { isTabActive && path.isEmpty } @@ -562,6 +585,19 @@ struct WorkRootScreen: View { .listRowSeparator(.hidden) } + // Above the list, not per row: every row below belongs to the same + // project, so one banner explains the whole outage instead of + // repeating itself down the column. + ForEach(offlineMachineBanners) { banner in + ActivityOfflineMachineBanner( + machineName: banner.machineName, + lastSeenLabel: banner.lastSeenLabel + ) + .listRowInsets(EdgeInsets(top: 2, leading: 16, bottom: 6, trailing: 16)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + if displaySessions.isEmpty { ADEEmptyStateView( symbol: isLive ? "bubble.left.and.bubble.right" : "terminal", @@ -892,14 +928,32 @@ struct WorkRootScreen: View { /// A quiet lane is collapsed unless explicitly expanded; every other section /// is expanded unless explicitly collapsed. + /// + /// A headerless lane is never collapsed: there is no header to collapse it + /// with, so a collapsed one would be a row the user could not get back. private func workGroupIsCollapsed(_ group: WorkSessionGroup) -> Bool { - group.isQuiet + if group.isHeaderless { return false } + return group.isQuiet ? !collapsedSectionIds.contains(group.quietOpenSectionId) : collapsedSectionIds.contains(group.id) } @ViewBuilder private func workSessionGroupRows(_ group: WorkSessionGroup) -> some View { + // The singleton form: one top-level row in the lane, so the header would be + // a divider carrying a name the row already says. The row takes the lane + // identity instead (its meta line and its "Go to lane" / PR actions). + if group.isHeaderless { + ForEach(group.sessions.filter { sessionPresentation.topLevelDisplaySessionIds.contains($0.id) }) { session in + workSessionRows(session, showsLaneIdentity: true) + } + } else { + workSessionGroupRowsWithHeader(group) + } + } + + @ViewBuilder + private func workSessionGroupRowsWithHeader(_ group: WorkSessionGroup) -> some View { let isLaneDeleting = group.laneId.map(syncService.pendingLaneDeletionIds.contains) ?? false let collapsed = workGroupIsCollapsed(group) let isQuietRow = group.isQuiet && collapsed @@ -947,7 +1001,14 @@ struct WorkRootScreen: View { // An expanded quiet lane holds only settled rows: the full card's // preview line and meta row are about work in flight, of which there is // none here. - workSessionRows(session, compact: group.isQuiet) + // + // A row under a lane header does not repeat the lane name — the header + // two rows up already says it, and the space is worth more as the model. + workSessionRows( + session, + compact: group.isQuiet, + showsLaneIdentity: group.laneId == nil + ) } } } @@ -955,7 +1016,8 @@ struct WorkRootScreen: View { @ViewBuilder private func workSessionRows( _ session: TerminalSessionSummary, - compact: Bool = false + compact: Bool = false, + showsLaneIdentity: Bool = true ) -> some View { WorkSessionListRow( session: session, @@ -970,6 +1032,7 @@ struct WorkRootScreen: View { ? sessionTransitionNamespace : nil, compact: compact, + showsLaneIdentity: showsLaneIdentity, isLaneDeleting: syncService.pendingLaneDeletionIds.contains(session.laneId), selectedSessionId: $selectedSessionTransitionId, isSelecting: isSelecting, diff --git a/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift b/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift index 849f54c92..461daa5eb 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift @@ -26,17 +26,41 @@ enum CanonicalSessionPhase: Equatable { case settled } -/// The three attention states that earn a capsule. Calm phases get no badge. +/// The states that earn a capsule on a Work row. +/// +/// The first three are the attention states — the only ones `badge` has ever +/// carried, and the only ones that survive on the `CanonicalSessionState` value +/// so nothing downstream starts treating "Working" as something to act on. The +/// rest are the descriptive half of the vocabulary, reached through +/// `workSessionStatusBadge`, which is what the row actually renders. +/// +/// The truly resting phases (ready / idle / stopped / ended) still earn no +/// capsule: their story is the neutral dot and the timestamp, and a row must not +/// shift layout to say "nothing is happening". enum SessionBadgeKind: Equatable { case needsYou case failed case stale + case working + case planning + case done } struct SessionBadge: Equatable { let kind: SessionBadgeKind - /// Short capsule copy; calm states get no badge at all. + /// Short capsule copy; resting states get no badge at all. let label: String + /// Hue token from the shared `ActivityPhaseVocabulary`, so a Work row and an + /// Activity row describing the same session cannot pick different colours. + let tone: ActivityTone + let glyph: ActivityGlyph? + + init(kind: SessionBadgeKind, label: String, tone: ActivityTone, glyph: ActivityGlyph? = nil) { + self.kind = kind + self.label = label + self.tone = tone + self.glyph = glyph + } } struct CanonicalSessionState: Equatable { @@ -53,12 +77,24 @@ struct CanonicalSessionState: Equatable { /// (e.g. the 7-day chat reclassification in `normalizedWorkChatSessionStatus`). let sessionStaleAfterSeconds: TimeInterval = 3 * 60 * 60 +/// The attention badges, worded and hued by the shared vocabulary rather than +/// by a second table that could drift away from it. private let badgeByKind: [SessionBadgeKind: SessionBadge] = [ - .needsYou: SessionBadge(kind: .needsYou, label: "Needs you"), - .failed: SessionBadge(kind: .failed, label: "Failed"), - .stale: SessionBadge(kind: .stale, label: "Stale"), + .needsYou: workSessionBadge(kind: .needsYou, phase: .needsYou), + .failed: workSessionBadge(kind: .failed, phase: .failed), + .stale: workSessionBadge(kind: .stale, phase: .stale), ] +private func workSessionBadge(kind: SessionBadgeKind, phase: AccountAttentionPhase) -> SessionBadge { + let presentation = ActivityPhaseVocabulary.presentation(for: phase) + return SessionBadge( + kind: kind, + label: presentation.label, + tone: presentation.tone, + glyph: presentation.glyph + ) +} + private func isSilentPast(_ lastActivityAt: String?, now: Date, thresholdSeconds: TimeInterval) -> Bool { guard let at = workParsedDate(lastActivityAt) else { return false } return now.timeIntervalSince(at) >= thresholdSeconds @@ -235,6 +271,76 @@ func workSessionCapsuleBadge( ).badge } +// MARK: - The full status vocabulary + +/// Canonical phase → the shared Activity phase, so a Work row reads its label +/// and its hue out of the same table the drawer, the hub strip, and the widget +/// use. The four resting phases have no Activity phase of their own and are +/// carried as additive raw values, which `ActivityPhaseVocabulary` answers with +/// the quiet neutral presentation they want. +func workActivityPhase(for phase: CanonicalSessionPhase) -> AccountAttentionPhase { + switch phase { + case .starting: return .starting + case .running: return .running + case .needsYou: return .needsYou + case .failed: return .failed + case .stale: return .stale + // Settled is a declared "this is finished and filed", which is exactly what + // the emerald `completed` presentation says. + case .settled: return .completed + case .ready: return .unrecognized("ready") + case .idle: return .unrecognized("idle") + case .stopped: return .unrecognized("stopped") + case .ended: return .unrecognized("ended") + } +} + +/// Planning is a PRESENTATION fact, never a canonical phase — the same split +/// desktop makes, where `chatActivityMode` is derived from the chat's +/// interaction mode and folded in at render time +/// (`chatSessionProjection.ts`: `interactionMode === "plan"`). +func workSessionIsPlanning(summary: AgentChatSessionSummary?) -> Bool { + summary?.interactionMode?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "plan" +} + +/// The capsule a Work row renders: the full vocabulary, not just the attention +/// third. Nil for the resting phases, which say what they need to say with the +/// neutral dot alone. +func workSessionStatusBadge( + session: TerminalSessionSummary, + summary: AgentChatSessionSummary?, + now: Date = Date() +) -> SessionBadge? { + let canonical = workCanonicalSessionState(session: session, summary: summary, now: now) + if canonical.phase == .running && workSessionIsPlanning(summary: summary) { + return workSessionBadge(kind: .planning, phase: .unrecognized("planning")) + } + switch canonical.phase { + case .needsYou, .failed, .stale: + return canonical.badge + case .starting, .running: + return workSessionBadge(kind: .working, phase: workActivityPhase(for: canonical.phase)) + case .settled: + return workSessionBadge(kind: .done, phase: .completed) + case .ready, .idle, .stopped, .ended: + return nil + } +} + +/// The hue of the row's status dot. Same table as the capsule, so a row whose +/// capsule says "Working" can never wear a green dot. +func workSessionRowTone( + session: TerminalSessionSummary, + summary: AgentChatSessionSummary?, + now: Date = Date() +) -> ActivityTone { + let canonical = workCanonicalSessionState(session: session, summary: summary, now: now) + if canonical.phase == .running && workSessionIsPlanning(summary: summary) { + return ActivityPhaseVocabulary.presentation(for: .unrecognized("planning")).tone + } + return ActivityPhaseVocabulary.presentation(for: workActivityPhase(for: canonical.phase)).tone +} + /// Canonical state for a concrete Work row. This is the one bridge from the /// mobile summary/awaiting projection into the scalar canonical state machine. func workCanonicalSessionState( @@ -659,17 +765,20 @@ struct WorkSessionLifecycleTag: View { } } -/// Small attention capsule shown next to a Work row title. Amber for needs_you -/// (matching the app's existing amber chip language), red for failed, and an -/// outlined muted capsule with a clock glyph for stale. Calm states render -/// nothing, so callers gate on a non-nil badge to avoid any layout shift. +/// Small status capsule shown next to a Work row title. The hue comes from the +/// shared tone token — amber for needs_you and nothing else, blue for work in +/// flight, emerald for finished, red for failed, violet for planning — so the +/// row cannot describe a session differently from the drawer or the widget. +/// Neutral badges render outlined rather than filled, which keeps a calm row +/// calm. Resting states have no badge at all, so callers gate on a non-nil +/// badge and the row never shifts layout. struct WorkSessionStatusCapsule: View { let badge: SessionBadge var body: some View { HStack(spacing: 3) { - if badge.kind == .stale { - Image(systemName: "clock") + if let glyph = badge.glyph, showsGlyph { + Image(systemName: glyph.systemImage) .font(.system(size: 8, weight: .semibold)) } Text(badge.label) @@ -685,20 +794,25 @@ struct WorkSessionStatusCapsule: View { .accessibilityLabel(accessibilityLabel) } + /// Only where the word alone is ambiguous: "Stale" wants the clock that asks + /// how long, "Planning" wants the list that says what kind of work. The rest + /// read fine as plain words and stay uncluttered. + private var showsGlyph: Bool { + badge.kind == .stale || badge.kind == .planning + } + + private var isOutlined: Bool { badge.tone == .neutral } + private var tint: Color { - switch badge.kind { - case .needsYou: return ADEColor.warning - case .failed: return ADEColor.danger - case .stale: return ADEColor.textMuted - } + activityToneColor(badge.tone) } private var fill: Color { - badge.kind == .stale ? Color.clear : tint.opacity(0.14) + isOutlined ? Color.clear : tint.opacity(0.14) } private var stroke: Color { - badge.kind == .stale ? tint.opacity(0.4) : tint.opacity(0.3) + isOutlined ? tint.opacity(0.4) : tint.opacity(0.3) } private var accessibilityLabel: String { @@ -706,6 +820,9 @@ struct WorkSessionStatusCapsule: View { case .needsYou: return "Needs your input" case .failed: return "Failed" case .stale: return "Stale, no recent activity" + case .working: return "Working" + case .planning: return "Planning" + case .done: return "Done" } } } diff --git a/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift b/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift index 7df190cbb..670b946c5 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift @@ -95,6 +95,11 @@ struct WorkSessionGroup: Identifiable, Equatable { /// the `status:snoozed` tail, so they can't be here). The section renders as a /// single thin row instead of a full header over nothing. let isQuiet: Bool + /// The singleton form: one top-level row, so the group renders with no header + /// at all and the lone row carries the lane identity instead. Orthogonal to + /// `isQuiet` — a quiet lane still has a header to fold, and the two rules are + /// derived independently (see `workHeaderlessLaneIds`). + let isHeaderless: Bool enum Icon: Equatable { case statusDot @@ -112,7 +117,8 @@ struct WorkSessionGroup: Identifiable, Equatable { laneColor: String? = nil, laneIcon: LaneIcon? = nil, isOrphaned: Bool = false, - isQuiet: Bool = false + isQuiet: Bool = false, + isHeaderless: Bool = false ) { self.id = id self.label = label @@ -123,6 +129,7 @@ struct WorkSessionGroup: Identifiable, Equatable { self.laneIcon = laneIcon self.isOrphaned = isOrphaned self.isQuiet = isQuiet + self.isHeaderless = isHeaderless } /// Inverted collapse marker: a quiet lane starts collapsed, and only an @@ -145,6 +152,7 @@ struct WorkSessionGroup: Identifiable, Equatable { && lhs.laneIcon == rhs.laneIcon && lhs.isOrphaned == rhs.isOrphaned && lhs.isQuiet == rhs.isQuiet + && lhs.isHeaderless == rhs.isHeaderless && lhs.sessions.map(\.id) == rhs.sessions.map(\.id) } } @@ -246,11 +254,13 @@ func buildWorkRootSessionPresentation( orderedLanes: [LaneSummary], pullRequests: [PullRequestListItem] = [], githubPrs: [GitHubPrListItem] = [], - deletingLaneIds: Set = [] + deletingLaneIds: Set = [], + pinnedLaneIds: Set = [], + laneSortMode: WorkLaneSortMode = .created, + now: Date = Date() ) -> WorkRootSessionPresentation { let committedIds = Set(sessions.map(\.id)) let draftValues = optimisticSessions.values.filter { !committedIds.contains($0.id) } - let workOrderedLanes = sortWorkLanesForTabs(orderedLanes) let laneById = Dictionary(orderedLanes.map { ($0.id, $0) }, uniquingKeysWith: { _, new in new }) let lanePrTagsByLaneId = lanePrTagByLaneId( lanes: orderedLanes, @@ -312,6 +322,30 @@ func buildWorkRootSessionPresentation( } } + // Lane ordering and the singleton rule both read the UNFILTERED roster, so + // neither the shelf a lane sits on nor whether it has a header changes while + // the user types in search. Same precedent as the quiet-lane derivation. + let workOrderedLanes = orderWorkLanes( + orderedLanes, + inputs: workLaneOrderInputs( + lanes: orderedLanes, + sessions: mergedSessions, + chatSummaries: chatSummaries, + archivedSessionIds: archivedSessionIds, + pinnedLaneIds: pinnedLaneIds, + now: now + ), + mode: laneSortMode + ) + let headerlessLaneIds = workHeaderlessLaneIds( + workHeaderlessLaneInputs( + lanes: orderedLanes, + sessions: mergedSessions, + pinnedLaneIds: pinnedLaneIds + ), + sortMode: laneSortMode + ) + let sessionGroups = workSessionGroups( organization: organization, sessions: displaySessions, @@ -320,7 +354,9 @@ func buildWorkRootSessionPresentation( statusBySessionId: statusBySessionId, archivedSessionIds: archivedSessionIds, orderedLanes: workOrderedLanes, - deletingLaneIds: deletingLaneIds + deletingLaneIds: deletingLaneIds, + headerlessLaneIds: headerlessLaneIds, + now: now ) return WorkRootSessionPresentation( @@ -419,6 +455,10 @@ private func workRootSessionPresentationRenderSignature( for group in sessionGroups { hasher.combine(group.id) hasher.combine(group.label) + hasher.combine(group.isQuiet) + // Gaining or losing a header reshapes the whole section; without this the + // equatable short-circuit freezes the old shape on screen. + hasher.combine(group.isHeaderless) hasher.combine(group.sessions.map(\.id)) } for key in childGroupsByParentId.keys.sorted() { @@ -449,21 +489,66 @@ private func workRootSessionPresentationRenderSignature( return hasher.finalize() } -func sortWorkLanesForTabs(_ lanes: [LaneSummary]) -> [LaneSummary] { - lanes.enumerated().sorted { lhsPair, rhsPair in - let lhs = lhsPair.element - let rhs = rhsPair.element - let lhsPrimary = lhs.laneType == "primary" - let rhsPrimary = rhs.laneType == "primary" - if lhsPrimary != rhsPrimary { return lhsPrimary } - - let lhsDate = parseWorkSessionTimestamp(lhs.createdAt) - let rhsDate = parseWorkSessionTimestamp(rhs.createdAt) - if let lhsDate, let rhsDate, lhsDate != rhsDate { - return lhsDate > rhsDate - } - return lhsPair.offset < rhsPair.offset - }.map(\.element) +/// Derive the per-lane ordering facts a `LaneSummary` does not carry: the most +/// recent session activity, whether the lane is quiet, and whether it is pinned. +func workLaneOrderInputs( + lanes: [LaneSummary], + sessions: [TerminalSessionSummary], + chatSummaries: [String: AgentChatSessionSummary], + archivedSessionIds: Set, + pinnedLaneIds: Set, + now: Date = Date() +) -> [String: WorkLaneOrderInput] { + var latestByLaneId: [String: Date] = [:] + for session in sessions { + guard let activity = workParsedDate( + workSessionActivityTimestamp(session: session, summary: chatSummaries[session.id]) + ) else { continue } + if let current = latestByLaneId[session.laneId], current >= activity { continue } + latestByLaneId[session.laneId] = activity + } + + var inputs: [String: WorkLaneOrderInput] = [:] + inputs.reserveCapacity(lanes.count) + for lane in lanes { + inputs[lane.id] = WorkLaneOrderInput( + lane: lane, + lastActivityAt: latestByLaneId[lane.id], + quiet: workLaneSessionsAreQuiet( + laneId: lane.id, + sessions: sessions, + chatSummaries: chatSummaries, + archivedSessionIds: archivedSessionIds, + now: now + ), + pinned: pinnedLaneIds.contains(lane.id) + ) + } + return inputs +} + +/// Per-lane inputs to the singleton rule. Counts TOP-LEVEL rows only — a chat +/// with terminal children is one unit — over the unfiltered roster. +func workHeaderlessLaneInputs( + lanes: [LaneSummary], + sessions: [TerminalSessionSummary], + pinnedLaneIds: Set +) -> [WorkHeaderlessLaneInput] { + let rosterIds = Set(sessions.map(\.id)) + var topLevelByLaneId: [String: Int] = [:] + for session in sessions { + let parentId = session.chatSessionId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let isChild = !parentId.isEmpty && parentId != session.id && rosterIds.contains(parentId) + guard !isChild else { continue } + topLevelByLaneId[session.laneId, default: 0] += 1 + } + return lanes.map { lane in + WorkHeaderlessLaneInput( + laneId: lane.id, + topLevelSessionCount: topLevelByLaneId[lane.id] ?? 0, + pinned: pinnedLaneIds.contains(lane.id) + ) + } } func workSessionChildGroupsByParentId(sessions: [TerminalSessionSummary]) -> [String: WorkSessionChildGroup] { @@ -550,6 +635,7 @@ func workSessionGroups( archivedSessionIds: Set, orderedLanes: [LaneSummary], deletingLaneIds: Set = [], + headerlessLaneIds: Set = [], now: Date = Date() ) -> [WorkSessionGroup] { var snoozed: [TerminalSessionSummary] = [] @@ -575,7 +661,8 @@ func workSessionGroups( groups = workSessionGroupsByLane( sessions: awake, orderedLanes: orderedLanes, - deletingLaneIds: deletingLaneIds + deletingLaneIds: deletingLaneIds, + headerlessLaneIds: headerlessLaneIds ).map { group in group.markingQuiet( workLaneGroupIsQuiet( @@ -618,7 +705,25 @@ extension WorkSessionGroup { sessions: sessions, laneColor: laneColor, laneIcon: laneIcon, - isQuiet: quiet + isOrphaned: isOrphaned, + isQuiet: quiet, + isHeaderless: isHeaderless + ) + } + + func markingHeaderless(_ headerless: Bool) -> WorkSessionGroup { + guard headerless != isHeaderless else { return self } + return WorkSessionGroup( + id: id, + label: label, + icon: icon, + tint: tint, + sessions: sessions, + laneColor: laneColor, + laneIcon: laneIcon, + isOrphaned: isOrphaned, + isQuiet: isQuiet, + isHeaderless: headerless ) } } @@ -736,7 +841,8 @@ func workSessionGroupsByStatus( func workSessionGroupsByLane( sessions: [TerminalSessionSummary], orderedLanes: [LaneSummary], - deletingLaneIds: Set = [] + deletingLaneIds: Set = [], + headerlessLaneIds: Set = [] ) -> [WorkSessionGroup] { var byLaneId: [String: [TerminalSessionSummary]] = [:] for session in sessions { @@ -754,7 +860,11 @@ func workSessionGroupsByLane( tint: LaneColorPalette.displayColor(forHex: lane.color), sessions: list, laneColor: lane.color, - laneIcon: lane.icon + laneIcon: lane.icon, + // Derived from the unfiltered roster, so a search that narrows a busy + // lane to one hit never collapses its header mid-keystroke. `list` may + // still hold that row's terminal children — they render nested under it. + isHeaderless: headerlessLaneIds.contains(lane.id) )) } // Surface any sessions whose lane isn't in the ordered list (e.g., soft-deleted lanes) @@ -833,6 +943,45 @@ func workSessionGroupsByTime(sessions: [TerminalSessionSummary]) -> [WorkSession return groups } +// MARK: - Offline machine banner + +/// One "this machine is gone" banner for the Work list. Presentation only: the +/// rows themselves keep working, they just stop pretending they can be acted on. +struct WorkOfflineMachineBanner: Identifiable, Equatable { + let id: String + let machineName: String + let lastSeenLabel: String? +} + +/// Which offline machines own work in the project the Work list is showing. +/// +/// The connected host is online by definition — that is what "connected" means — +/// so anything this returns is a foreign machine whose lanes are visible through +/// the account feed. Scope match is by project id, falling back to lane id for +/// items published before a project id was carried. +func workOfflineMachineBanners( + scopes: [ActivityOfflineScope], + activeProjectId: String?, + laneIds: Set = [], + now: Date = Date() +) -> [WorkOfflineMachineBanner] { + let project = activeProjectId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + var seen: Set = [] + var banners: [WorkOfflineMachineBanner] = [] + for scope in scopes { + let matchesProject = !project.isEmpty && scope.projectId == project + let matchesLane = scope.laneId.map(laneIds.contains) ?? false + guard matchesProject || matchesLane else { continue } + guard seen.insert(scope.machineKey).inserted else { continue } + banners.append(WorkOfflineMachineBanner( + id: scope.machineKey, + machineName: scope.machineName, + lastSeenLabel: scope.lastSeenLabel(now: now) + )) + } + return banners.sorted { $0.machineName.localizedCaseInsensitiveCompare($1.machineName) == .orderedAscending } +} + /// Persistence helper for the comma-separated collapsed-section-ids string stored in AppStorage. func workParseCollapsedSectionIds(_ raw: String) -> Set { Set(raw.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }) diff --git a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift index bd1763ae3..3b95a83fc 100644 --- a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift @@ -289,6 +289,22 @@ func shortProviderLabel(_ toolType: String?) -> String { return raw.replacingOccurrences(of: "-", with: " ").capitalized } +/// Compact model label for a session meta line. Strips the vendor prefix and the +/// date suffix a model id carries — `anthropic/claude-sonnet-4-5-20250929` reads +/// as `claude-sonnet-4-5` — because on a phone row the version is the only part +/// that distinguishes two rows on the same provider. +func shortModelLabel(_ model: String?) -> String { + let raw = model?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !raw.isEmpty else { return "" } + let withoutVendor = raw.split(separator: "/").last.map(String.init) ?? raw + let parts = withoutVendor.split(separator: "-") + // A trailing 8-digit build date is noise; anything else is part of the name. + if let last = parts.last, last.count == 8, last.allSatisfy(\.isNumber) { + return parts.dropLast().joined(separator: "-") + } + return withoutVendor +} + func providerIcon(_ provider: String) -> String { switch providerFamilyKey(provider) { case "codex", "openai": @@ -581,18 +597,29 @@ func workChatStatusSortRank(_ status: String) -> Int { } } -func workChatStatusTint(_ status: String) -> Color { +/// Tone for the coarse four-value chat status string, for the surfaces that only +/// ever hold that string (the hub roster, personal chats, the session settings +/// sheet). Rows that hold a real session use `workSessionRowTone` instead, which +/// reads the canonical phase. +/// +/// Both route through `ActivityTone`, which is what enforces the one-hue rule: +/// amber is "your move" and nothing else. That rule moved two colours here — an +/// active chat is blue (work is happening) rather than the green that now means +/// "finished", and an idle chat is neutral rather than the amber it used to +/// borrow, which made resting chats shout as loudly as blocked ones. +func workChatStatusTone(_ status: String) -> ActivityTone { switch status { - case "awaiting-input": return ADEColor.warning - case "active": return ADEColor.success - // Match desktop, where idle/needs-attention chats render as amber. Previously - // idle was rendered with the purple accent, which read as "running" and - // diverged from the desktop status-dot semantics. - case "idle": return ADEColor.warning - default: return ADEColor.textSecondary + case "awaiting-input": return .amber + case "active": return .blue + case "idle": return .neutral + default: return .neutral } } +func workChatStatusTint(_ status: String) -> Color { + activityToneColor(workChatStatusTone(status)) +} + func workChatStatusIcon(_ status: String) -> String { switch status { case "awaiting-input": return "exclamationmark.bubble.fill" diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index c2197bb08..854669c9b 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -15298,7 +15298,14 @@ final class ADETests: XCTestCase { ) newer.createdAt = "2026-03-25T00:00:00.000Z" - let ordered = sortWorkLanesForTabs([older, primary, newer]) + let ordered = orderWorkLanes( + [older, primary, newer], + inputs: [ + primary.id: WorkLaneOrderInput(lane: primary), + older.id: WorkLaneOrderInput(lane: older), + newer.id: WorkLaneOrderInput(lane: newer), + ] + ) XCTAssertEqual(ordered.map(\.id), ["lane-primary", "lane-newer", "lane-older"]) } diff --git a/apps/ios/ADETests/ActivityAckQueueTests.swift b/apps/ios/ADETests/ActivityAckQueueTests.swift new file mode 100644 index 000000000..cd3531dc8 --- /dev/null +++ b/apps/ios/ADETests/ActivityAckQueueTests.swift @@ -0,0 +1,233 @@ +import XCTest +@testable import ADE + +@MainActor +final class ActivityAckQueueTests: XCTestCase { + func testFailedAcknowledgmentPersistsUntilRefreshFlushesAppliedItem() throws { + let suiteName = "ActivityAckQueueTests.flush.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let key = "pending-acks" + let ownerId = "account-a" + let store = AccountAttentionPendingAckStore(defaults: defaults, key: key) + let pending = AccountAttentionPendingAck( + itemId: "agent:machine-a:session-a", + seenAt: Date(timeIntervalSince1970: 100), + dismissedAt: Date(timeIntervalSince1970: 101), + sourceRevision: 7 + ) + + // A failed relay send leaves the optimistic mutation durable. Recreating + // the store models the next foreground refresh after process suspension. + store.enqueue([pending], for: ownerId) + let restored = AccountAttentionPendingAckStore(defaults: defaults, key: key) + XCTAssertEqual(restored.entries(for: ownerId), [pending]) + + // The refresh queue drain removes only ids the relay reports as applied. + restored.remove(itemIds: [pending.itemId], for: ownerId) + XCTAssertTrue(restored.entries(for: ownerId).isEmpty) + } + + func testDedupeKeepsNewestAcknowledgmentStatePerItem() throws { + let suiteName = "ActivityAckQueueTests.dedupe.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = AccountAttentionPendingAckStore(defaults: defaults, key: "pending-acks") + let older = AccountAttentionPendingAck( + itemId: " item-a ", + seenAt: Date(timeIntervalSince1970: 100), + dismissedAt: nil, + sourceRevision: 2 + ) + let newer = AccountAttentionPendingAck( + itemId: "item-a", + seenAt: Date(timeIntervalSince1970: 200), + dismissedAt: Date(timeIntervalSince1970: 201), + sourceRevision: 3 + ) + + store.enqueue([newer, older], for: "account-a") + + XCTAssertEqual(store.entries(for: "account-a"), [newer]) + XCTAssertTrue(store.entries(for: "account-b").isEmpty) + } + + func testQueueCapsEachOwnerAtTwoHundredAndEvictsOldestEntries() throws { + let suiteName = "ActivityAckQueueTests.cap.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = AccountAttentionPendingAckStore(defaults: defaults, key: "pending-acks") + let start = Date(timeIntervalSince1970: 1_000) + let entries = (0..<205).map { index in + AccountAttentionPendingAck( + itemId: String(format: "item-%03d", index), + seenAt: start, + dismissedAt: nil, + sourceRevision: index, + queuedAt: start.addingTimeInterval(TimeInterval(index)) + ) + } + + store.enqueue(entries, for: "account-a") + store.enqueue([entries[0]], for: "account-b") + + let retained = store.entries(for: "account-a") + XCTAssertEqual(retained.count, 200) + XCTAssertEqual(retained.first?.itemId, "item-005") + XCTAssertEqual(retained.last?.itemId, "item-204") + XCTAssertEqual(store.entries(for: "account-b").map(\.itemId), ["item-000"]) + } + + func testFlushPruningDropsEntriesOlderThanTwentyFourHoursOnlyForThatOwner() throws { + let suiteName = "ActivityAckQueueTests.expiry.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = AccountAttentionPendingAckStore(defaults: defaults, key: "pending-acks") + let now = Date(timeIntervalSince1970: 200_000) + let entries = [ + AccountAttentionPendingAck( + itemId: "expired", + seenAt: now, + dismissedAt: nil, + sourceRevision: 1, + queuedAt: now.addingTimeInterval(-AccountAttentionPendingAckStore.maximumAge - 1) + ), + AccountAttentionPendingAck( + itemId: "at-cutoff", + seenAt: now, + dismissedAt: nil, + sourceRevision: 2, + queuedAt: now.addingTimeInterval(-AccountAttentionPendingAckStore.maximumAge) + ), + AccountAttentionPendingAck( + itemId: "fresh", + seenAt: now, + dismissedAt: nil, + sourceRevision: 3, + queuedAt: now.addingTimeInterval(-60) + ), + ] + store.enqueue(entries, for: "account-a") + store.enqueue([entries[0]], for: "account-b") + + let retained = store.pruneExpired(for: "account-a", now: now) + + XCTAssertEqual(retained.map(\.itemId), ["at-cutoff", "fresh"]) + XCTAssertEqual(store.entries(for: "account-a"), retained) + XCTAssertEqual(store.entries(for: "account-b").map(\.itemId), ["expired"]) + } + + func testFailedBatchDoesNotStarveTheNextBatch() async throws { + let suiteName = "ActivityAckQueueTests.batch.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let ownerId = "account-a" + let store = AccountAttentionPendingAckStore(defaults: defaults, key: "pending-acks") + let timestamp = Date(timeIntervalSince1970: 3_000) + let entries = (0..<65).map { index in + AccountAttentionPendingAck( + itemId: String(format: "item-%03d", index), + seenAt: timestamp, + dismissedAt: nil, + sourceRevision: index, + queuedAt: timestamp.addingTimeInterval(TimeInterval(index)) + ) + } + store.enqueue(entries, for: ownerId) + let revisions = Dictionary(uniqueKeysWithValues: entries.map { ($0.itemId, $0.sourceRevision!) }) + var batches: [[String]] = [] + + let outcome = await flushAccountAttentionAckEntries( + store.entries(for: ownerId), + ownerId: ownerId, + revisionById: revisions, + store: store, + acknowledge: { itemIds, _, _ in + batches.append(itemIds) + if batches.count == 1 { + throw AccountAttentionRelayClient.RelayError.transport + } + return AccountAttentionAcknowledgmentResult(applied: itemIds, stale: []) + } + ) + + XCTAssertEqual(batches.map(\.count), [64, 1]) + XCTAssertEqual(batches.last, ["item-064"]) + XCTAssertEqual(outcome.attemptedItemIds.count, 65) + XCTAssertNotNil(outcome.failureMessage) + let remaining = store.entries(for: ownerId) + XCTAssertEqual(remaining.count, 64) + XCTAssertTrue(remaining.allSatisfy { $0.attemptCount == 1 }) + XCTAssertFalse(remaining.contains { $0.itemId == "item-064" }) + } + + func testFifthFailedAttemptEvictsTheEntry() async throws { + let suiteName = "ActivityAckQueueTests.attempts.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let ownerId = "account-a" + let store = AccountAttentionPendingAckStore(defaults: defaults, key: "pending-acks") + let entry = AccountAttentionPendingAck( + itemId: "permanent-failure", + seenAt: Date(timeIntervalSince1970: 4_000), + dismissedAt: nil, + sourceRevision: 9, + queuedAt: Date(timeIntervalSince1970: 4_000), + attemptCount: AccountAttentionPendingAckStore.maximumFailedAttempts - 1 + ) + store.enqueue([entry], for: ownerId) + + let outcome = await flushAccountAttentionAckEntries( + store.entries(for: ownerId), + ownerId: ownerId, + revisionById: [entry.itemId: 9], + store: store, + acknowledge: { _, _, _ in + throw AccountAttentionRelayClient.RelayError.transport + } + ) + + XCTAssertEqual(outcome.attemptedItemIds, Set([entry.itemId])) + XCTAssertNotNil(outcome.failureMessage) + XCTAssertTrue(store.entries(for: ownerId).isEmpty) + } + + func testStaleAcknowledgmentsRefreshAndRetryExactlyOnce() async { + var refreshCount = 0 + var retryCount = 0 + let staleIds: Set = ["item-a", "item-b"] + + let retriedIds = await retryAccountAttentionAcknowledgmentsOnce( + itemIds: staleIds, + refresh: { refreshCount += 1 }, + retry: { itemIds in + retryCount += 1 + return itemIds + } + ) + + XCTAssertEqual(refreshCount, 1) + XCTAssertEqual(retryCount, 1) + XCTAssertEqual(retriedIds, staleIds) + } + + func testHardOwnerMismatchClearsOnlyRejectedOwnerQueue() throws { + let suiteName = "ActivityAckQueueTests.owner.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = AccountAttentionPendingAckStore(defaults: defaults, key: "pending-acks") + let entry = AccountAttentionPendingAck( + itemId: "item-a", + seenAt: Date(timeIntervalSince1970: 100), + dismissedAt: nil, + sourceRevision: 1 + ) + store.enqueue([entry], for: "account-a") + store.enqueue([entry], for: "account-b") + + store.clear(for: "account-a") + + XCTAssertTrue(store.entries(for: "account-a").isEmpty) + XCTAssertEqual(store.entries(for: "account-b"), [entry]) + } +} diff --git a/apps/ios/ADETests/ActivityContractDecodingTests.swift b/apps/ios/ADETests/ActivityContractDecodingTests.swift new file mode 100644 index 000000000..877654162 --- /dev/null +++ b/apps/ios/ADETests/ActivityContractDecodingTests.swift @@ -0,0 +1,292 @@ +import XCTest +@testable import ADE + +final class ActivityContractDecodingTests: XCTestCase { + func testUnknownPhaseDropsOnlyThatItemAndIgnoresUnknownTopLevelFields() throws { + let data = Data(#""" + { + "contractVersion": 1, + "revision": 42, + "generatedAt": "2026-08-01T12:00:00Z", + "itemsTruncated": true, + "futureTopLevel": { "enabled": true }, + "items": [ + { + "contractVersion": 1, + "id": "known-item", + "revision": 4, + "fingerprint": "known-fingerprint", + "kind": "agent", + "eventKind": "agent_running", + "phase": "running", + "machine": { + "machineKey": "machine:one", + "name": "Studio", + "online": true + }, + "project": { + "projectId": "project:one", + "name": "ADE" + }, + "title": "Known run", + "preview": "Working", + "privacyPreview": "Agent activity", + "destination": { + "kind": "session", + "sessionId": "session-known" + }, + "actions": [], + "occurredAt": "2026-08-01T11:59:00Z", + "updatedAt": "2026-08-01T12:00:00Z" + }, + { + "contractVersion": 1, + "id": "future-item", + "revision": 5, + "fingerprint": "future-fingerprint", + "kind": "agent", + "eventKind": "agent_running", + "phase": "future_phase", + "machine": { + "machineKey": "machine:one", + "name": "Studio", + "online": true + }, + "project": { + "projectId": "project:one", + "name": "ADE" + }, + "title": "Future run", + "preview": "Doing something new", + "privacyPreview": "Agent activity", + "destination": { + "kind": "session", + "sessionId": "session-future" + }, + "actions": [], + "occurredAt": "2026-08-01T11:59:30Z", + "updatedAt": "2026-08-01T12:00:00Z" + } + ] + } + """#.utf8) + + let snapshot = try XCTUnwrap(ADESharedContainer.decodeAttentionSnapshot(from: data)) + + XCTAssertEqual(snapshot.revision, 42) + XCTAssertEqual(snapshot.items.map(\.id), ["known-item"]) + XCTAssertEqual(snapshot.itemsTruncated, true) + } + + func testUnknownEnumValuesDecodeAndReencodeTheirRawValues() throws { + try assertUnknownRoundTrip( + AccountAttentionItemKind.self, + rawValue: "future_item", + expected: .unrecognized("future_item") + ) + try assertUnknownRoundTrip( + AccountAttentionPhase.self, + rawValue: "future_phase", + expected: .unrecognized("future_phase") + ) + try assertUnknownRoundTrip( + AccountAttentionEventKind.self, + rawValue: "future_event", + expected: .unrecognized("future_event") + ) + try assertUnknownRoundTrip( + AccountAttentionActionKind.self, + rawValue: "future_action", + expected: .unrecognized("future_action") + ) + } + + func testMalformedItemDropsWithoutInvalidatingSnapshot() throws { + let data = Data(#""" + { + "contractVersion": 1, + "revision": 2, + "generatedAt": "2026-08-01T12:00:00Z", + "items": [ + { + "contractVersion": 1, + "id": "survivor", + "revision": 1, + "fingerprint": "survivor-fingerprint", + "kind": "agent", + "eventKind": "agent_running", + "phase": "running", + "machine": { "machineKey": "machine:one", "name": "Studio", "online": true }, + "project": { "projectId": "project:one", "name": "ADE" }, + "title": "Survivor", + "preview": "Working", + "privacyPreview": "Agent activity", + "destination": { "kind": "session", "sessionId": "session-survivor" }, + "actions": [], + "occurredAt": "2026-08-01T11:59:00Z", + "updatedAt": "2026-08-01T12:00:00Z" + }, + { + "contractVersion": 1, + "revision": 2, + "kind": "agent" + } + ] + } + """#.utf8) + + let snapshot = try XCTUnwrap(ADESharedContainer.decodeAttentionSnapshot(from: data)) + + XCTAssertEqual(snapshot.items.map(\.id), ["survivor"]) + } + + func testUnknownDestinationKeepsRowRenderableWithoutADeepLink() throws { + let data = Data(#""" + { + "contractVersion": 1, + "revision": 3, + "generatedAt": "2026-08-01T12:00:00Z", + "items": [{ + "contractVersion": 1, + "id": "future-destination", + "revision": 1, + "fingerprint": "future-destination:1", + "kind": "agent", + "eventKind": "agent_running", + "phase": "running", + "machine": { "machineKey": "machine:one", "name": "Studio", "online": true }, + "project": { "projectId": "project:one", "name": "ADE" }, + "title": "Future destination", + "preview": "Still render this row", + "privacyPreview": "Agent activity", + "destination": { "kind": "future_surface", "route": "somewhere-new" }, + "actions": [], + "occurredAt": "2026-08-01T11:59:00Z", + "updatedAt": "2026-08-01T12:00:00Z" + }] + } + """#.utf8) + + let snapshot = try XCTUnwrap(ADESharedContainer.decodeAttentionSnapshot(from: data)) + let item = try XCTUnwrap(snapshot.items.first) + let row = ActivityRowPresentation(item: item) + + XCTAssertEqual(item.destination, .unrecognized("future_surface")) + XCTAssertNil(item.deepLinkURL) + XCTAssertEqual(row.id, "future-destination") + XCTAssertEqual(row.title, "Future destination") + XCTAssertNil(row.deepLink) + XCTAssertNil(row.sessionId) + XCTAssertNil(row.prNumber) + } + + func testActivityTierAndStatusSinceRoundTrip() throws { + let statusSince = Date(timeIntervalSince1970: 1_754_046_000) + let snapshot = AccountAttentionSnapshot( + revision: 7, + generatedAt: Date(timeIntervalSince1970: 1_754_046_100), + items: [ + makeItem( + id: "round-trip", + phase: .running, + activityTier: "ambient", + statusSince: statusSince + ) + ], + itemsTruncated: false + ) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + + let encoded = try encoder.encode(snapshot) + let decoded = try XCTUnwrap(ADESharedContainer.decodeAttentionSnapshot(from: encoded)) + let item = try XCTUnwrap(decoded.items.first) + + XCTAssertEqual(item.activityTier, "ambient") + XCTAssertEqual(item.tier, .ambient) + XCTAssertEqual(item.statusSince, statusSince) + XCTAssertEqual(decoded.itemsTruncated, false) + } + + func testTierDefaultsFromPhaseWhenActivityTierIsAbsent() { + XCTAssertEqual(makeItem(id: "needs-you", phase: .needsYou).tier, .signal) + XCTAssertEqual(makeItem(id: "blocked", phase: .blocked).tier, .signal) + XCTAssertEqual(makeItem(id: "failed", phase: .failed).tier, .signal) + XCTAssertEqual(makeItem(id: "checks-failing", phase: .checksFailing).tier, .signal) + XCTAssertEqual(makeItem(id: "review-requested", phase: .reviewRequested).tier, .signal) + XCTAssertEqual(makeItem(id: "changes-requested", phase: .changesRequested).tier, .signal) + XCTAssertEqual(makeItem(id: "merge-ready", phase: .mergeReady).tier, .signal) + XCTAssertEqual(makeItem(id: "starting", phase: .starting).tier, .ambient) + XCTAssertEqual(makeItem(id: "running", phase: .running).tier, .ambient) + XCTAssertEqual(makeItem(id: "completed", phase: .completed).tier, .ambient) + XCTAssertEqual(makeItem(id: "stale", phase: .stale).tier, .ambient) + XCTAssertEqual(makeItem(id: "open", phase: .open).tier, .ambient) + XCTAssertEqual(makeItem(id: "merged", phase: .merged).tier, .ambient) + XCTAssertEqual(makeItem(id: "closed", phase: .closed).tier, .ambient) + XCTAssertEqual( + makeItem(id: "future", phase: .unrecognized("future_phase")).tier, + .ambient + ) + XCTAssertTrue(makeItem(id: "legacy-signal", phase: .needsYou).needsInbox) + XCTAssertFalse( + makeItem(id: "idle-needs-you", phase: .needsYou, activityTier: "idle").needsInbox + ) + XCTAssertEqual( + makeItem(id: "unknown-tier", phase: .running, activityTier: "future_tier").tier, + .ambient, + "only an explicit idle publisher tier may derive idle" + ) + } + + private func assertUnknownRoundTrip( + _ type: Value.Type, + rawValue: String, + expected: Value, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + let encodedRawValue = try JSONEncoder().encode(rawValue) + let decoded = try JSONDecoder().decode(type, from: encodedRawValue) + XCTAssertEqual(decoded, expected, file: file, line: line) + + let reencoded = try JSONEncoder().encode(decoded) + XCTAssertEqual( + try JSONDecoder().decode(String.self, from: reencoded), + rawValue, + file: file, + line: line + ) + } + + private func makeItem( + id: String, + phase: AccountAttentionPhase, + activityTier: String? = nil, + statusSince: Date? = nil + ) -> AccountAttentionItem { + let timestamp = Date(timeIntervalSince1970: 1_754_046_000) + return AccountAttentionItem( + id: id, + revision: 1, + fingerprint: "fingerprint-\(id)", + kind: .agent, + eventKind: .agentRunning, + phase: phase, + activityTier: activityTier, + statusSince: statusSince, + machine: AccountAttentionMachine( + machineKey: "machine:one", + name: "Studio", + online: true, + lastSeenAt: timestamp + ), + project: AccountAttentionProject(projectId: "project:one", name: "ADE"), + title: "Agent run", + preview: "Working", + privacyPreview: "Agent activity", + destination: .session(sessionId: "session-\(id)", itemId: nil, eventId: nil), + occurredAt: timestamp, + updatedAt: timestamp + ) + } +} diff --git a/apps/ios/ADETests/ActivityDrawerModelTests.swift b/apps/ios/ADETests/ActivityDrawerModelTests.swift new file mode 100644 index 000000000..3386175ca --- /dev/null +++ b/apps/ios/ADETests/ActivityDrawerModelTests.swift @@ -0,0 +1,440 @@ +import XCTest +@testable import ADE + +@MainActor +final class ActivityDrawerModelTests: XCTestCase { + private var defaults: UserDefaults! + private var suiteName: String! + + override func setUp() { + super.setUp() + suiteName = "ade.activity-drawer.tests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + defaults.removePersistentDomain(forName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + // MARK: - Two buckets + + func testAgentRowsFileUnderSessionsAndPullRequestsUnderInbox() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + + model.rebuild(from: snapshot(items: [ + item(id: "agent-live", phase: .running, now: now), + item(id: "agent-needs", phase: .needsYou, now: now), + pullRequest(id: "pr-ci", phase: .checksFailing, number: 992, now: now), + ])) + + XCTAssertEqual(Set(model.sessions.map(\.id)), ["agent-live", "agent-needs"]) + XCTAssertEqual(model.inbox.map(\.id), ["pr-ci"]) + } + + func testSessionsAreGroupedNeedsYouThenWorkingThenDone() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + + model.rebuild(from: snapshot(items: [ + item(id: "done", phase: .completed, now: now), + item(id: "working", phase: .running, now: now), + item(id: "needs", phase: .needsYou, now: now), + ])) + + XCTAssertEqual(model.sessionSections.map(\.band), [.needsYou, .working, .done]) + XCTAssertEqual(model.sessionSections.map { $0.rows.map(\.id) }, [["needs"], ["working"], ["done"]]) + } + + func testFinishedButUnseenAgentRowsAlsoLandInTheInbox() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + + model.rebuild(from: snapshot(items: [ + item(id: "done-unseen", phase: .completed, now: now), + item(id: "done-seen", phase: .completed, now: now, seenAt: now), + ])) + + XCTAssertEqual(model.inbox.map(\.id), ["done-unseen"]) + XCTAssertEqual(Set(model.sessions.map(\.id)), ["done-unseen", "done-seen"]) + } + + func testIdleTierNeedsYouNeverReachesTheNeedsYouSection() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + + model.rebuild(from: snapshot(items: [ + item(id: "roster-row", phase: .needsYou, now: now, activityTier: "idle"), + ])) + + XCTAssertTrue(model.sessionSections.filter { $0.band == .needsYou }.isEmpty) + XCTAssertEqual(model.unreadCount, 0, "a roster-derived row must never badge the bell") + } + + func testDismissedAndExpiredItemsAreFilteredOut() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + + model.rebuild(from: snapshot(items: [ + item(id: "alive", phase: .running, now: now), + item(id: "server-dismissed", phase: .running, now: now, dismissedAt: now), + item(id: "expired", phase: .running, now: now, expiresAt: now.addingTimeInterval(-1)), + ])) + + XCTAssertEqual(model.sessions.map(\.id), ["alive"]) + } + + // MARK: - Per-item acknowledgement + + func testDismissRemovesOneRowAndPersistsIt() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + model.rebuild(from: snapshot(items: [ + item(id: "a", phase: .needsYou, now: now), + item(id: "b", phase: .needsYou, now: now), + ])) + + model.dismiss("a") + + XCTAssertEqual(model.sessions.map(\.id), ["b"]) + XCTAssertEqual(defaults.stringArray(forKey: ActivityDrawerModel.dismissedItemIDsKey), ["a"]) + } + + func testDismissedRowStaysDismissedAcrossRebuilds() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + let items = [item(id: "a", phase: .needsYou, now: now)] + model.rebuild(from: snapshot(items: items)) + + model.dismiss("a") + model.rebuild(from: snapshot(items: items, revision: 2)) + + XCTAssertTrue(model.sessions.isEmpty) + } + + func testDismissalIsPrunedOnceTheBackingRowDisappears() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + model.rebuild(from: snapshot(items: [item(id: "a", phase: .needsYou, now: now)])) + model.dismiss("a") + + model.rebuild(from: snapshot(items: [item(id: "other", phase: .running, now: now)], revision: 2)) + model.rebuild(from: snapshot(items: [item(id: "a", phase: .needsYou, now: now)], revision: 3)) + + XCTAssertEqual(model.sessions.map(\.id), ["a"], "a later regression must resurface") + } + + func testBulkDismissIsScopedToOneBucket() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + model.rebuild(from: snapshot(items: [ + item(id: "agent", phase: .needsYou, now: now), + pullRequest(id: "pr", phase: .checksFailing, number: 1, now: now), + ])) + + model.dismissVisible(in: .inbox) + + XCTAssertEqual(model.sessions.map(\.id), ["agent"]) + XCTAssertTrue(model.inbox.isEmpty) + } + + // MARK: - Badge + + func testBadgeCountsOnlySignalTierNeedsYou() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + + model.rebuild(from: snapshot(items: [ + item(id: "needs", phase: .needsYou, now: now), + item(id: "failed", phase: .failed, now: now), + item(id: "working", phase: .running, now: now), + pullRequest(id: "pr", phase: .checksFailing, number: 3, now: now), + ])) + + XCTAssertEqual(model.unreadCount, 2) + XCTAssertEqual(model.badgeLabel, "2") + } + + func testBadgeCapsAtNinePlus() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + let items = (0..<12).map { item(id: "needs-\($0)", phase: .needsYou, now: now) } + + model.rebuild(from: snapshot(items: items)) + + XCTAssertEqual(model.unreadCount, 12) + XCTAssertEqual(model.badgeLabel, "9+") + } + + func testMarkAllSeenSilencesTheBellWithoutRemovingRows() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + model.rebuild(from: snapshot(items: [item(id: "needs", phase: .needsYou, now: now)])) + XCTAssertEqual(model.unreadCount, 1) + + model.markAllSeen() + + XCTAssertEqual(model.unreadCount, 0) + XCTAssertNil(model.badgeLabel) + XCTAssertEqual(model.sessions.map(\.id), ["needs"], "seen is not dismissed") + } + + func testMarkSeenIsPersistedPerItem() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + model.rebuild(from: snapshot(items: [ + item(id: "a", phase: .needsYou, now: now), + item(id: "b", phase: .needsYou, now: now), + ])) + + model.markSeen("a") + + XCTAssertEqual(model.unreadCount, 1) + XCTAssertEqual(defaults.stringArray(forKey: ActivityDrawerModel.seenItemIDsKey), ["a"]) + } + + // MARK: - Offline machines + + func testOfflineRowsSitBehindABannerForTheirMachine() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + let offline = AccountAttentionMachine( + machineKey: "laptop", + name: "MacBook", + online: false, + lastSeenAt: now.addingTimeInterval(-3_600) + ) + + model.rebuild(from: snapshot(items: [ + item(id: "online", phase: .running, now: now), + item(id: "offline", phase: .running, now: now, machine: offline), + ])) + + let entries = model.sessionSections.first { $0.band == .working }?.entries ?? [] + XCTAssertEqual(entries.map(\.id), ["online", "offline:laptop", "offline"]) + if case .offlineMachine(_, let name, let lastSeen) = entries[1] { + XCTAssertEqual(name, "MacBook") + XCTAssertEqual(lastSeen, "last seen 1h ago") + } else { + XCTFail("expected an offline banner before the offline row") + } + } + + // MARK: - Source, for honest empty states + + func testAccountSnapshotReportsAnAccountSource() { + let model = ActivityDrawerModel(defaults: defaults) + + model.rebuild(from: snapshot(items: [])) + + XCTAssertEqual(model.source, .account) + XCTAssertTrue(model.isEmpty, "empty-and-reachable is 'all clear', not 'unreachable'") + } + + func testClearAllReportsNoSourceSoTheDrawerCanSaySo() { + let model = ActivityDrawerModel(defaults: defaults) + model.rebuild(from: snapshot(items: [item(id: "a", phase: .running, now: Date())])) + + model.clearAll() + + XCTAssertEqual(model.source, .none) + XCTAssertTrue(model.isEmpty) + } + + func testTruncationFlagRidesTheSnapshot() { + let model = ActivityDrawerModel(defaults: defaults) + + model.rebuild(from: snapshot(items: [], truncated: true)) + + XCTAssertTrue(model.itemsTruncated) + } + + // MARK: - Live-now strip + + func testLiveNowExcludesFinishedAndIdleTierRows() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + + model.rebuild(from: snapshot(items: [ + item(id: "running", phase: .running, now: now), + item(id: "needs", phase: .needsYou, now: now), + item(id: "done", phase: .completed, now: now), + item(id: "roster", phase: .running, now: now, activityTier: "idle"), + ])) + + XCTAssertEqual(Set(model.liveNow.map(\.id)), ["running", "needs"]) + } + + // MARK: - Machine-local fallback + + func testWorkspaceFallbackProducesTheSameRowShape() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + + model.rebuild(from: WorkspaceSnapshot( + generatedAt: now, + agents: [ + agent(sessionId: "s-awaiting", status: "running", awaitingInput: true, at: now), + agent(sessionId: "s-running", status: "running", awaitingInput: false, at: now), + ], + prs: [ + PrSnapshot( + id: "pr-1", + number: 7, + title: "Activity revamp", + checks: "failing", + review: "pending", + state: "open", + mergeReady: false, + updatedAt: now + ), + ], + connection: "connected", + machineName: "This Mac", + projectName: "ADE" + )) + + XCTAssertEqual(model.source, .machineFallback) + XCTAssertEqual(model.sessions.map(\.id), ["awaiting:s-awaiting", "live:s-running"]) + XCTAssertEqual(model.inbox.map(\.id), ["ci:pr-1"]) + let failingPR = model.inbox.first + XCTAssertEqual(failingPR?.tier, .signal) + XCTAssertEqual(failingPR?.band, .needsYou) + XCTAssertTrue(failingPR?.needsInbox == true) + XCTAssertEqual(model.sessions.first?.phaseLabel, "Needs you") + XCTAssertEqual(model.sessions.first?.machineName, "This Mac") + XCTAssertTrue( + model.sessions.first?.inlineActionsAllowed == true, + "rows from the paired host may run inline intents" + ) + } + + func testDisconnectedMachineDowngradesLiveWorkToStale() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + + model.rebuild(from: WorkspaceSnapshot( + generatedAt: now, + agents: [agent(sessionId: "s", status: "running", awaitingInput: false, at: now)], + prs: [], + connection: "disconnected" + )) + + XCTAssertEqual(model.sessions.first?.phaseLabel, "Stale") + } + + // MARK: - Fixtures + + private func snapshot( + items: [AccountAttentionItem], + revision: Int = 1, + truncated: Bool = false + ) -> AccountAttentionSnapshot { + AccountAttentionSnapshot( + revision: revision, + generatedAt: Date(), + machines: nil, + items: items, + tombstones: nil, + itemsTruncated: truncated + ) + } + + private func item( + id: String, + phase: AccountAttentionPhase, + now: Date, + activityTier: String? = nil, + machine: AccountAttentionMachine? = nil, + seenAt: Date? = nil, + dismissedAt: Date? = nil, + expiresAt: Date? = nil + ) -> AccountAttentionItem { + AccountAttentionItem( + id: id, + revision: 1, + fingerprint: "\(id):1", + kind: .agent, + eventKind: .agentRunning, + phase: phase, + activityTier: activityTier, + machine: machine ?? AccountAttentionMachine( + machineKey: "studio", + name: "Studio Mac", + online: true, + lastSeenAt: now + ), + project: AccountAttentionProject(projectId: "ade", name: "ADE"), + title: id, + preview: "Working", + privacyPreview: "Agent working", + destination: .session(sessionId: id, itemId: nil, eventId: nil), + occurredAt: now, + updatedAt: now, + seenAt: seenAt, + dismissedAt: dismissedAt, + expiresAt: expiresAt + ) + } + + private func pullRequest( + id: String, + phase: AccountAttentionPhase, + number: Int, + now: Date + ) -> AccountAttentionItem { + AccountAttentionItem( + id: id, + revision: 1, + fingerprint: "\(id):1", + kind: .pullRequest, + eventKind: .prChecksFailing, + phase: phase, + machine: AccountAttentionMachine( + machineKey: "studio", + name: "Studio Mac", + online: true, + lastSeenAt: now + ), + project: AccountAttentionProject(projectId: "ade", name: "ADE"), + title: "PR #\(number)", + preview: "Checks failing", + privacyPreview: "Checks failing", + destination: .pullRequest( + prId: id, + repoOwner: nil, + repoName: nil, + number: number, + tab: "overview", + eventId: nil + ), + occurredAt: now, + updatedAt: now + ) + } + + private func agent( + sessionId: String, + status: String, + awaitingInput: Bool, + at: Date + ) -> AgentSnapshot { + AgentSnapshot( + sessionId: sessionId, + provider: "claude", + title: "Session \(sessionId)", + status: status, + awaitingInput: awaitingInput, + lastActivityAt: at, + elapsedSeconds: 12, + preview: "Working", + progress: nil, + phase: nil, + toolCalls: 0 + ) + } +} diff --git a/apps/ios/ADETests/ActivityPollingTests.swift b/apps/ios/ADETests/ActivityPollingTests.swift new file mode 100644 index 000000000..b15d364b4 --- /dev/null +++ b/apps/ios/ADETests/ActivityPollingTests.swift @@ -0,0 +1,126 @@ +import XCTest +@testable import ADE + +private actor ActivityPollSleepHarness { + private var requests: [(UInt64, CheckedContinuation)] = [] + + func sleep(nanoseconds: UInt64) async throws { + try await withCheckedThrowingContinuation { continuation in + requests.append((nanoseconds, continuation)) + } + } + + var requestCount: Int { requests.count } + var intervals: [UInt64] { requests.map(\.0) } + + func resumeFirst() { + guard !requests.isEmpty else { return } + let request = requests.removeFirst() + request.1.resume() + } +} + +@MainActor +final class ActivityPollingTests: XCTestCase { + func testStartIsIdempotentAndGenerationStopsOldLoop() async { + let sleeper = ActivityPollSleepHarness() + var pollingEnabled = true + var refreshCount = 0 + let service = AccountService( + attentionPollSleep: { nanoseconds in + try await sleeper.sleep(nanoseconds: nanoseconds) + }, + attentionPollSignedIn: { pollingEnabled }, + attentionPollRefresh: { + refreshCount += 1 + pollingEnabled = false + } + ) + + service.startAttentionPolling() + await waitForRequestCount(1, sleeper: sleeper) + let firstGeneration = service.currentAttentionPollGeneration + + service.startAttentionPolling() + XCTAssertEqual(service.currentAttentionPollGeneration, firstGeneration) + let idempotentRequestCount = await sleeper.requestCount + XCTAssertEqual(idempotentRequestCount, 1) + + service.stopAttentionPolling() + service.startAttentionPolling() + await waitForRequestCount(2, sleeper: sleeper) + XCTAssertGreaterThan(service.currentAttentionPollGeneration, firstGeneration) + let intervals = await sleeper.intervals + XCTAssertEqual( + intervals, + [ActivityPollInterval, ActivityPollInterval] + ) + + // The injected sleeper intentionally ignores task cancellation. Resuming + // the old generation must still not refresh or clear the newer task. + await sleeper.resumeFirst() + await Task.yield() + XCTAssertEqual(refreshCount, 0) + XCTAssertTrue(service.isAttentionPolling) + + await sleeper.resumeFirst() + await waitForRefreshCount(1) { refreshCount } + await waitForPollingStop(service) + XCTAssertEqual(refreshCount, 1) + XCTAssertFalse(service.isAttentionPolling) + } + + func testSleepingPollLoopDoesNotRetainAccountService() async { + let sleeper = ActivityPollSleepHarness() + weak var weakService: AccountService? + + do { + let service = AccountService( + attentionPollSleep: { nanoseconds in + try await sleeper.sleep(nanoseconds: nanoseconds) + }, + attentionPollSignedIn: { true }, + attentionPollRefresh: {} + ) + weakService = service + service.startAttentionPolling() + await waitForRequestCount(1, sleeper: sleeper) + } + + for _ in 0..<1_000 where weakService != nil { + await Task.yield() + } + XCTAssertNil(weakService) + await sleeper.resumeFirst() + } + + private func waitForRequestCount( + _ expected: Int, + sleeper: ActivityPollSleepHarness + ) async { + for _ in 0..<1_000 { + if await sleeper.requestCount >= expected { return } + await Task.yield() + } + XCTFail("Timed out waiting for \(expected) poll sleeps") + } + + private func waitForRefreshCount( + _ expected: Int, + current: () -> Int + ) async { + for _ in 0..<1_000 { + if current() >= expected { return } + await Task.yield() + } + XCTFail("Timed out waiting for \(expected) poll refreshes") + } + + private func waitForPollingStop(_ service: AccountService) async { + for _ in 0..<1_000 { + if !service.isAttentionPolling { return } + await Task.yield() + } + XCTFail("Timed out waiting for polling to stop") + } +} diff --git a/apps/ios/ADETests/ActivityRowPresentationTests.swift b/apps/ios/ADETests/ActivityRowPresentationTests.swift new file mode 100644 index 000000000..39a803d09 --- /dev/null +++ b/apps/ios/ADETests/ActivityRowPresentationTests.swift @@ -0,0 +1,293 @@ +import XCTest +@testable import ADE + +/// The anti-drift test for the iOS half of the status vocabulary. Every +/// expectation here is transcribed from +/// `apps/desktop/src/shared/sessionStatusPresentation.ts` (session phases) and +/// `renderer/components/activity/activityPresentation.ts` +/// (`NON_SESSION_PRESENTATION` + `NON_SESSION_STATUS_DETAILS`). If a hue or a +/// word moves on desktop and not here, this fails — which is the whole point. +final class ActivityRowPresentationTests: XCTestCase { + + // MARK: - Phase parity table + + func testPhaseTableMatchesDesktopVocabulary() { + let expectations: [(AccountAttentionPhase, String, ActivityTone, ActivityGlyph?, Bool, Bool)] = [ + (.starting, "Starting", .blue, .working, false, false), + (.running, "Working", .blue, .working, true, false), + (.needsYou, "Needs you", .amber, .needsYou, false, true), + (.completed, "Done", .emerald, .done, false, true), + (.failed, "Failed", .red, .failed, false, true), + (.stale, "Stale", .neutral, .stale, true, false), + (.blocked, "Blocked", .neutral, nil, false, false), + (.checksFailing, "Checks failing", .red, .failed, false, true), + (.reviewRequested, "Review requested", .violet, .review, false, true), + (.changesRequested, "Changes requested", .red, .failed, false, true), + (.mergeReady, "Ready to merge", .emerald, .done, false, true), + (.open, "Open", .blue, nil, false, false), + (.merged, "Merged", .emerald, .merged, false, true), + (.closed, "Closed", .neutral, nil, false, false), + ] + + for (phase, label, tone, glyph, showsElapsed, prominent) in expectations { + let presentation = ActivityPhaseVocabulary.presentation(for: phase) + XCTAssertEqual(presentation.label, label, "label for \(phase.rawValue)") + XCTAssertEqual(presentation.tone, tone, "tone for \(phase.rawValue)") + XCTAssertEqual(presentation.glyph, glyph, "glyph for \(phase.rawValue)") + XCTAssertEqual(presentation.showsElapsed, showsElapsed, "elapsed for \(phase.rawValue)") + XCTAssertEqual(presentation.prominent, prominent, "prominence for \(phase.rawValue)") + } + } + + func testAmberIsSpentOnExactlyOnePhase() { + let amber: [AccountAttentionPhase] = [ + .starting, .running, .needsYou, .completed, .failed, .stale, .blocked, + .checksFailing, .reviewRequested, .changesRequested, .mergeReady, + .open, .merged, .closed, + ].filter { ActivityPhaseVocabulary.presentation(for: $0).tone == .amber } + + XCTAssertEqual(amber, [.needsYou]) + } + + func testUnknownPhaseIsNeutralAndSilent() { + let presentation = ActivityPhaseVocabulary.presentation(for: .unrecognized("teleporting")) + + XCTAssertEqual(presentation.tone, .neutral) + XCTAssertEqual(presentation.label, "Unknown") + XCTAssertNil(presentation.glyph) + XCTAssertFalse(presentation.prominent) + } + + func testPlanningPhaseKeepsTheDesktopVioletLabel() { + let presentation = ActivityPhaseVocabulary.presentation(for: .unrecognized("planning")) + + XCTAssertEqual(presentation.label, "Planning") + XCTAssertEqual(presentation.tone, .violet) + XCTAssertTrue(presentation.showsElapsed) + } + + // MARK: - Bands + + func testBandsFileNeedsYouFirstAndOutcomesLast() { + XCTAssertEqual(ActivityPhaseVocabulary.band(for: .needsYou), .needsYou) + XCTAssertEqual(ActivityPhaseVocabulary.band(for: .failed), .needsYou) + XCTAssertEqual(ActivityPhaseVocabulary.band(for: .running), .working) + XCTAssertEqual(ActivityPhaseVocabulary.band(for: .blocked), .working) + XCTAssertEqual(ActivityPhaseVocabulary.band(for: .completed), .done) + XCTAssertEqual(ActivityPhaseVocabulary.band(for: .merged), .done) + } + + func testIdleTierNeverReachesTheNeedsYouBand() { + let row = ActivityRowPresentation( + item: makeItem(phase: .needsYou, activityTier: "idle") + ) + + XCTAssertEqual(row.tier, .idle) + XCTAssertEqual(row.band, .working, "an idle row must never sit at the top of the drawer") + } + + func testSignalTierNeedsYouStaysInTheNeedsYouBand() { + let row = ActivityRowPresentation(item: makeItem(phase: .needsYou)) + + XCTAssertEqual(row.tier, .signal) + XCTAssertEqual(row.band, .needsYou) + } + + // MARK: - Elapsed + + func testElapsedAnchorsOnStatusSinceWhenPresent() { + let now = Date() + let row = ActivityRowPresentation( + item: makeItem( + phase: .running, + statusSince: now.addingTimeInterval(-42), + occurredAt: now.addingTimeInterval(-9_000) + ) + ) + + XCTAssertEqual(row.elapsedSince, now.addingTimeInterval(-42)) + XCTAssertEqual(row.elapsedLabel(now: now), "42s") + } + + func testElapsedFallsBackToOccurredAtWithoutStatusSince() { + let now = Date() + let row = ActivityRowPresentation( + item: makeItem(phase: .running, occurredAt: now.addingTimeInterval(-180)) + ) + + XCTAssertEqual(row.elapsedLabel(now: now), "3m") + } + + func testElapsedIsSuppressedForPhasesWhereAgeIsNoise() { + let now = Date() + let row = ActivityRowPresentation( + item: makeItem(phase: .failed, occurredAt: now.addingTimeInterval(-180)) + ) + + XCTAssertNil(row.elapsedLabel(now: now)) + } + + func testDurationFormattingIsLossyAboveTheHour() { + XCTAssertEqual(ActivityRowPresentation.formatDuration(0), "0s") + XCTAssertEqual(ActivityRowPresentation.formatDuration(59), "59s") + XCTAssertEqual(ActivityRowPresentation.formatDuration(60), "1m") + XCTAssertEqual(ActivityRowPresentation.formatDuration(3_599), "59m") + XCTAssertEqual(ActivityRowPresentation.formatDuration(3_600), "1h") + XCTAssertEqual(ActivityRowPresentation.formatDuration(86_400), "1d") + XCTAssertNil(ActivityRowPresentation.formatDuration(-1)) + } + + func testExtremeElapsedAndLastSeenValuesAreRejectedWithoutIntegerConversion() { + let now = Date(timeIntervalSince1970: 1_754_046_000) + let extremePast = Date(timeIntervalSince1970: -1e300) + let elapsed = ActivityRowPresentation( + item: makeItem(phase: .running, occurredAt: extremePast) + ) + let offline = ActivityRowPresentation( + item: makeItem( + phase: .running, + occurredAt: now, + machine: AccountAttentionMachine( + machineKey: "corrupt", + name: "Corrupt timestamp", + online: false, + lastSeenAt: extremePast + ) + ) + ) + + XCTAssertNil(ActivityRowPresentation.formatDuration(1e300)) + XCTAssertNil(ActivityRowPresentation.formatDuration(-1e300)) + XCTAssertNil(elapsed.elapsedLabel(now: now)) + XCTAssertNil(offline.lastSeenLabel(now: now)) + } + + // MARK: - Machine presence + + func testOfflineMachineCarriesLastSeenCopyAndStopsPulsing() { + let now = Date() + let row = ActivityRowPresentation( + item: makeItem( + phase: .running, + machine: AccountAttentionMachine( + machineKey: "studio", + name: "Studio Mac", + online: false, + lastSeenAt: now.addingTimeInterval(-7_200) + ) + ) + ) + + XCTAssertFalse(row.machineOnline) + XCTAssertFalse(row.isActive, "a row on an unreachable machine must not read as live") + XCTAssertEqual(row.lastSeenLabel(now: now), "last seen 2h ago") + } + + func testOnlineMachineHasNoLastSeenCopy() { + let row = ActivityRowPresentation(item: makeItem(phase: .running)) + + XCTAssertTrue(row.machineOnline) + XCTAssertNil(row.lastSeenLabel()) + } + + // MARK: - Field projection + + func testStatusNoteFallsBackThroughPreviewDetailThenPrivacyPreview() { + XCTAssertEqual( + ActivityRowPresentation(item: makeItem(phase: .running, preview: "Editing the router")).statusNote, + "Editing the router" + ) + XCTAssertEqual( + ActivityRowPresentation(item: makeItem(phase: .running, preview: " ", detail: "Ran 4 tools")).statusNote, + "Ran 4 tools" + ) + XCTAssertEqual( + ActivityRowPresentation( + item: makeItem(phase: .running, preview: "", detail: nil, privacyPreview: "Agent working") + ).statusNote, + "Agent working" + ) + XCTAssertNil( + ActivityRowPresentation( + item: makeItem(phase: .running, preview: "", detail: nil, privacyPreview: "") + ).statusNote + ) + } + + func testPullRequestItemsCarryTheirNumberAndNoSession() { + let row = ActivityRowPresentation( + item: makeItem( + phase: .checksFailing, + kind: .pullRequest, + destination: .pullRequest( + prId: "pr-1", + repoOwner: "arul", + repoName: "ade", + number: 992, + tab: "checks", + eventId: nil + ) + ) + ) + + XCTAssertTrue(row.isPullRequest) + XCTAssertEqual(row.prNumber, 992) + XCTAssertNil(row.sessionId) + } + + func testModelAndLaneAreProjectedRatherThanDropped() { + let row = ActivityRowPresentation( + item: makeItem(phase: .running, laneName: "activity-revamp", model: "claude-fable-5") + ) + + XCTAssertEqual(row.laneName, "activity-revamp") + XCTAssertEqual(row.modelLabel, "claude-fable-5") + XCTAssertEqual(row.scopeLabel, "Studio Mac · ADE") + } + + // MARK: - Fixture + + private func makeItem( + id: String = "item-1", + phase: AccountAttentionPhase, + kind: AccountAttentionItemKind = .agent, + activityTier: String? = nil, + statusSince: Date? = nil, + occurredAt: Date = Date(), + machine: AccountAttentionMachine? = nil, + laneName: String? = nil, + model: String? = nil, + preview: String = "Working", + detail: String? = nil, + privacyPreview: String = "Agent working", + destination: AccountAttentionDestination? = nil + ) -> AccountAttentionItem { + AccountAttentionItem( + id: id, + revision: 1, + fingerprint: "\(id):1", + kind: kind, + eventKind: .agentRunning, + phase: phase, + activityTier: activityTier, + statusSince: statusSince, + machine: machine ?? AccountAttentionMachine( + machineKey: "studio", + name: "Studio Mac", + online: true, + lastSeenAt: occurredAt + ), + project: AccountAttentionProject(projectId: "ade", name: "ADE"), + laneName: laneName, + provider: "claude", + model: model, + title: "Wire the drawer", + preview: preview, + privacyPreview: privacyPreview, + detail: detail, + destination: destination ?? .session(sessionId: "s-1", itemId: nil, eventId: nil), + occurredAt: occurredAt, + updatedAt: occurredAt + ) + } +} diff --git a/apps/ios/ADETests/ActivityWidgetPresentationTests.swift b/apps/ios/ADETests/ActivityWidgetPresentationTests.swift new file mode 100644 index 000000000..b8c273be1 --- /dev/null +++ b/apps/ios/ADETests/ActivityWidgetPresentationTests.swift @@ -0,0 +1,155 @@ +import XCTest +@testable import ADE + +/// The lock-screen widget's two decisions, as pure functions: which rows the +/// rectangular family lists, and where a tap goes. +/// +/// The deep link is the one that mattered — the widget used to follow whatever +/// sorted first, which on a busy account is usually PR traffic, so the single +/// glance-and-tap surface could not reliably reach the session blocked on you. +final class ActivityWidgetPresentationTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_780_000_000) + + // MARK: - Deep link ranking + + func testDeepLinkPrefersTheTopNeedsYouRow() { + let url = ActivityWidgetPresentation.deepLink( + for: [ + makeItem(id: "pr", phase: .checksFailing, sessionId: "pr-session", updatedAt: now), + makeItem(id: "live", phase: .running, sessionId: "live-session", updatedAt: now), + makeItem(id: "asked", phase: .needsYou, sessionId: "asked-session", updatedAt: now.addingTimeInterval(-600)), + ], + now: now + ) + + XCTAssertEqual(url.absoluteString.contains("asked-session"), true) + } + + func testDeepLinkFallsBackToTheTopLiveRow() { + let url = ActivityWidgetPresentation.deepLink( + for: [ + makeItem(id: "done", phase: .completed, sessionId: "done-session", updatedAt: now), + makeItem(id: "older-live", phase: .running, sessionId: "older-session", updatedAt: now.addingTimeInterval(-900)), + makeItem(id: "live", phase: .running, sessionId: "live-session", updatedAt: now), + ], + now: now + ) + + XCTAssertEqual(url.absoluteString.contains("live-session"), true) + } + + func testDeepLinkFallsBackToActivityWhenNothingIsActionable() { + let url = ActivityWidgetPresentation.deepLink( + for: [makeItem(id: "done", phase: .completed, sessionId: "done-session", updatedAt: now)], + now: now + ) + + XCTAssertEqual(url, ActivityWidgetPresentation.activityURL) + } + + func testDeepLinkIgnoresDismissedAndExpiredRows() { + let url = ActivityWidgetPresentation.deepLink( + for: [ + makeItem( + id: "dismissed", + phase: .needsYou, + sessionId: "dismissed-session", + updatedAt: now, + dismissedAt: now + ), + makeItem( + id: "expired", + phase: .needsYou, + sessionId: "expired-session", + updatedAt: now, + expiresAt: now.addingTimeInterval(-1) + ), + ], + now: now + ) + + XCTAssertEqual(url, ActivityWidgetPresentation.activityURL) + } + + // MARK: - Compact lines + + func testCompactLinesTakeTheTopTwoInBandOrder() { + let lines = ActivityWidgetPresentation.compactLines( + for: [ + makeItem(id: "done", phase: .completed, sessionId: "s-done", updatedAt: now), + makeItem(id: "live", phase: .running, sessionId: "s-live", updatedAt: now), + makeItem(id: "asked", phase: .needsYou, sessionId: "s-asked", updatedAt: now.addingTimeInterval(-600)), + ], + now: now + ) + + XCTAssertEqual(lines.map(\.id), ["asked", "live"]) + XCTAssertEqual(lines.map(\.phaseLabel), ["Needs you", "Working"]) + XCTAssertEqual(lines.map(\.tone), [.amber, .blue]) + } + + func testOverflowCountsTheRowsTheLinesLeftOff() { + let items = (0..<5).map { index in + makeItem(id: "item-\(index)", phase: .running, sessionId: "s-\(index)", updatedAt: now) + } + + XCTAssertEqual(ActivityWidgetPresentation.overflowCount(for: items, now: now), 3) + XCTAssertEqual(ActivityWidgetPresentation.overflowCount(for: Array(items.prefix(2)), now: now), 0) + } + + /// A lock screen is readable by anyone holding the phone, which is the whole + /// point of the setting — the title has to be the publisher's redacted one. + func testHideDetailsSwapsInThePrivacyPreview() { + let lines = ActivityWidgetPresentation.compactLines( + for: [makeItem(id: "asked", phase: .needsYou, sessionId: "s-asked", updatedAt: now)], + hideDetails: true, + now: now + ) + + XCTAssertEqual(lines.first?.title, "Agent needs you") + } + + func testRankingIsStableForRowsThatTieOnEveryKey() { + let first = makeItem(id: "b", phase: .running, sessionId: "s-b", updatedAt: now) + let second = makeItem(id: "a", phase: .running, sessionId: "s-a", updatedAt: now) + + XCTAssertEqual(ActivityWidgetPresentation.ranked([first, second]).map(\.id), ["a", "b"]) + XCTAssertEqual(ActivityWidgetPresentation.ranked([second, first]).map(\.id), ["a", "b"]) + } + + // MARK: - Fixtures + + private func makeItem( + id: String, + phase: AccountAttentionPhase, + sessionId: String, + updatedAt: Date, + dismissedAt: Date? = nil, + expiresAt: Date? = nil + ) -> AccountAttentionItem { + AccountAttentionItem( + id: id, + revision: 1, + fingerprint: "\(id):1", + kind: .agent, + eventKind: .agentRunning, + phase: phase, + machine: AccountAttentionMachine( + machineKey: "studio", + name: "Studio Mac", + online: true, + lastSeenAt: updatedAt + ), + project: AccountAttentionProject(projectId: "ade", name: "ADE"), + provider: "claude", + title: "Wire the widget", + preview: "Working", + privacyPreview: "Agent needs you", + destination: .session(sessionId: sessionId, itemId: nil, eventId: nil), + occurredAt: updatedAt, + updatedAt: updatedAt, + dismissedAt: dismissedAt, + expiresAt: expiresAt + ) + } +} diff --git a/apps/ios/ADETests/AttentionDrawerModelTests.swift b/apps/ios/ADETests/AttentionDrawerModelTests.swift deleted file mode 100644 index 4415063cd..000000000 --- a/apps/ios/ADETests/AttentionDrawerModelTests.swift +++ /dev/null @@ -1,1097 +0,0 @@ -import XCTest -@testable import ADE - -@available(iOS 17.0, *) -@MainActor -final class AttentionDrawerModelTests: XCTestCase { - private var defaults: UserDefaults! - private var suiteName: String! - - override func setUp() { - super.setUp() - suiteName = "ade.attention-drawer.tests.\(UUID().uuidString)" - defaults = UserDefaults(suiteName: suiteName) - defaults.removePersistentDomain(forName: suiteName) - } - - override func tearDown() { - defaults.removePersistentDomain(forName: suiteName) - defaults = nil - suiteName = nil - super.tearDown() - } - - // MARK: - Empty snapshot - - func testEmptySnapshotProducesNoItems() { - let model = AttentionDrawerModel(defaults: defaults) - - model.rebuild(from: .empty) - - XCTAssertTrue(model.items.isEmpty) - XCTAssertEqual(model.unreadCount, 0) - XCTAssertNil(model.badgeLabel) - } - - // MARK: - Mixed attention - - func testMixedSnapshotBuildsAwaitingFailedCiAndMergeItems() { - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - - let awaitingAgent = AgentSnapshot( - sessionId: "s-awaiting", - provider: "claude", - title: "Approve import", - status: "running", - awaitingInput: true, - lastActivityAt: now, - elapsedSeconds: 30, - preview: "Choose the release target", - pendingInputItemId: "pending-approval-1", - progress: nil, - phase: nil, - toolCalls: 0 - ) - let failedAgent = AgentSnapshot( - sessionId: "s-failed", - provider: "codex", - title: "Broken test run", - status: "failed", - awaitingInput: false, - lastActivityAt: now.addingTimeInterval(-120), - elapsedSeconds: 80, - preview: nil, - progress: nil, - phase: nil, - toolCalls: 0 - ) - let healthyAgent = AgentSnapshot( - sessionId: "s-healthy", - provider: "cursor", - title: "Idle", - status: "running", - awaitingInput: false, - lastActivityAt: now.addingTimeInterval(-60), - elapsedSeconds: 60, - preview: nil, - progress: nil, - phase: nil, - toolCalls: 0 - ) - - let ciFailingPr = PrSnapshot( - id: "pr-1", - number: 412, - title: "Migrate auth", - checks: "failing", - review: "pending", - state: "open", - mergeReady: false - ) - let mergeReadyPr = PrSnapshot( - id: "pr-2", - number: 401, - title: "Tidy logs", - checks: "passing", - review: "approved", - state: "open", - mergeReady: true - ) - let reviewPr = PrSnapshot( - id: "pr-3", - number: 408, - title: "Add caching", - checks: "passing", - review: "pending", - state: "open", - mergeReady: false - ) - let mergedPr = PrSnapshot( - id: "pr-4", - number: 390, - title: "Closed already", - checks: "failing", - review: "approved", - state: "merged", - mergeReady: false - ) - - let snapshot = WorkspaceSnapshot( - generatedAt: now, - agents: [awaitingAgent, failedAgent, healthyAgent], - prs: [ciFailingPr, mergeReadyPr, reviewPr, mergedPr], - connection: "connected" - ) - - model.rebuild(from: snapshot) - - XCTAssertEqual(model.items.count, 5, "healthy agent + merged PR should be filtered out") - - // Priority order: awaiting, failed, ci, review, merge. - XCTAssertEqual(model.items.map(\.kind), [ - .awaitingInput, - .failed, - .ciFailing, - .reviewRequested, - .mergeReady, - ]) - - let awaiting = try? XCTUnwrap(model.items.first) - XCTAssertEqual(awaiting?.sessionId, "s-awaiting") - XCTAssertEqual(awaiting?.itemId, "pending-approval-1") - XCTAssertEqual(awaiting?.deepLink, URL(string: "ade://session/s-awaiting")) - XCTAssertEqual(awaiting?.subtitle, "Choose the release target") - - let ci = model.items.first(where: { $0.kind == .ciFailing }) - XCTAssertEqual(ci?.prNumber, 412) - XCTAssertEqual(ci?.deepLink, URL(string: "ade://pr/412")) - } - - func testItemsOfSameKindAreSortedNewestFirst() { - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - - let older = AgentSnapshot( - sessionId: "older", - provider: "claude", - title: "A", - status: "failed", - awaitingInput: false, - lastActivityAt: now.addingTimeInterval(-500), - elapsedSeconds: 0, - preview: nil, - progress: nil, - phase: nil, - toolCalls: 0 - ) - let newer = AgentSnapshot( - sessionId: "newer", - provider: "claude", - title: "B", - status: "failed", - awaitingInput: false, - lastActivityAt: now, - elapsedSeconds: 0, - preview: nil, - progress: nil, - phase: nil, - toolCalls: 0 - ) - - model.rebuild(from: .init( - generatedAt: now, - agents: [older, newer], - prs: [], - connection: "connected" - )) - - 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 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( - 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() { - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - - let awaiting = AgentSnapshot( - sessionId: "s1", - provider: "claude", - title: "Do thing", - status: "running", - awaitingInput: true, - lastActivityAt: now, - elapsedSeconds: 10, - preview: nil, - progress: nil, - phase: nil, - toolCalls: 0 - ) - model.rebuild(from: .init( - generatedAt: now, - agents: [awaiting], - prs: [], - connection: "connected" - )) - - XCTAssertEqual(model.unreadCount, 1) - XCTAssertEqual(model.badgeLabel, "1") - - model.markAllSeen() - - XCTAssertEqual(model.unreadCount, 0) - XCTAssertNil(model.badgeLabel) - XCTAssertEqual(model.items.count, 1, "items stay; only unread count clears") - - let stored = defaults.double(forKey: AttentionDrawerModel.lastSeenAtKey) - XCTAssertGreaterThan(stored, 0, "markAllSeen should persist the new lastSeenAt") - } - - func testClearVisibleItemsHidesCurrentCardsAndPersistsDismissal() { - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - let snapshot = WorkspaceSnapshot( - generatedAt: now, - agents: [], - prs: [ - PrSnapshot( - id: "pr-1", - number: 9101, - title: "Mobile attention CI failing", - checks: "failing", - review: "approved", - state: "open", - mergeReady: false - ) - ], - connection: "connected" - ) - - model.rebuild(from: snapshot) - XCTAssertEqual(model.items.map(\.id), ["ci:pr-1"]) - - model.clearVisibleItems() - - XCTAssertTrue(model.items.isEmpty) - XCTAssertEqual(model.unreadCount, 0) - XCTAssertEqual( - Set(defaults.stringArray(forKey: AttentionDrawerModel.dismissedItemIDsKey) ?? []), - ["ci:pr-1"] - ) - - let freshModel = AttentionDrawerModel(defaults: defaults) - freshModel.rebuild(from: snapshot) - 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() - let failing = WorkspaceSnapshot( - generatedAt: now, - agents: [], - prs: [ - PrSnapshot( - id: "pr-1", - number: 9101, - title: "Mobile attention CI failing", - checks: "failing", - review: "approved", - state: "open", - mergeReady: false - ) - ], - connection: "connected" - ) - - model.rebuild(from: failing) - model.clearVisibleItems() - model.rebuild(from: failing) - XCTAssertTrue(model.items.isEmpty) - - model.rebuild(from: .init( - generatedAt: now.addingTimeInterval(1), - agents: [], - prs: [], - connection: "connected" - )) - model.rebuild(from: .init( - generatedAt: now.addingTimeInterval(2), - agents: [], - prs: failing.prs, - connection: "connected" - )) - - XCTAssertEqual(model.items.map(\.id), ["ci:pr-1"]) - } - - func testBadgeCapsAtNinePlus() { - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - - let agents = (0..<12).map { idx in - AgentSnapshot( - sessionId: "s-\(idx)", - provider: "claude", - title: "T\(idx)", - status: "running", - awaitingInput: true, - lastActivityAt: now.addingTimeInterval(TimeInterval(idx)), - elapsedSeconds: 0, - preview: nil, - progress: nil, - phase: nil, - toolCalls: 0 - ) - } - model.rebuild(from: .init( - generatedAt: now, - agents: agents, - prs: [], - connection: "connected" - )) - - XCTAssertEqual(model.unreadCount, 12) - XCTAssertEqual(model.badgeLabel, "9+") - } - - func testUnreadCountOnlyCountsItemsNewerThanLastSeenAt() { - // Seed a lastSeenAt in the future so nothing qualifies as unread. - defaults.set( - Date().addingTimeInterval(3_600).timeIntervalSince1970, - forKey: AttentionDrawerModel.lastSeenAtKey - ) - - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - - let awaiting = AgentSnapshot( - sessionId: "s1", - provider: "claude", - title: "Do thing", - status: "running", - awaitingInput: true, - lastActivityAt: now, - elapsedSeconds: 0, - preview: nil, - progress: nil, - phase: nil, - toolCalls: 0 - ) - model.rebuild(from: .init( - generatedAt: now, - agents: [awaiting], - prs: [], - connection: "connected" - )) - - XCTAssertEqual(model.items.count, 1) - XCTAssertEqual(model.unreadCount, 0, "all items are older than a future lastSeenAt") - } - - func testViewedOpenPrDoesNotRebadgeWhenSnapshotRegenerates() { - let model = AttentionDrawerModel(defaults: defaults) - let prUpdatedAt = Date().addingTimeInterval(-60) - let firstSnapshot = WorkspaceSnapshot( - generatedAt: Date(), - agents: [], - prs: [ - PrSnapshot( - id: "pr-still-open", - number: 83, - title: "Still open", - checks: "failing", - review: "approved", - state: "open", - mergeReady: false, - updatedAt: prUpdatedAt - ) - ], - connection: "connected" - ) - - model.rebuild(from: firstSnapshot) - XCTAssertEqual(model.unreadCount, 1) - - model.markAllSeen() - model.rebuild(from: WorkspaceSnapshot( - generatedAt: Date().addingTimeInterval(2), - agents: [], - prs: firstSnapshot.prs, - connection: "connected" - )) - - XCTAssertEqual(model.items.map(\.id), ["ci:pr-still-open"]) - XCTAssertEqual(model.unreadCount, 0) - XCTAssertNil(model.badgeLabel) - } - - // MARK: - Inline summary - - func testInlineSummaryIgnoresClosedPrsWhenPickingFocus() { - let now = Date() - let snapshot = WorkspaceSnapshot( - generatedAt: now, - agents: [], - prs: [ - PrSnapshot( - id: "closed-failing", - number: 14, - title: "Already merged", - checks: "failing", - review: "approved", - state: "merged", - mergeReady: false - ), - PrSnapshot( - id: "open-review", - number: 42, - title: "Needs review", - checks: "passing", - review: "pending", - state: "open", - mergeReady: false - ), - ], - connection: "connected" - ) - - XCTAssertEqual(ADESharedContainer.inlineSummary(for: snapshot), "ADE · #42 ·") - } - - func testInlineSummaryReturnsIdleWhenOnlyClosedPrsExist() { - let snapshot = WorkspaceSnapshot( - generatedAt: Date(), - agents: [], - prs: [ - PrSnapshot( - id: "closed", - number: 9, - title: "Merged", - checks: "failing", - review: "approved", - state: "closed", - mergeReady: false - ), - ], - connection: "connected" - ) - - XCTAssertEqual(ADESharedContainer.inlineSummary(for: snapshot), "ADE · idle") - } - - func testAccountAttentionPhaseLabelsUseUnifiedVocabulary() { - // Same words as `AgentRunPhase.label` and the desktop sidebar. The - // drawer and the Lock Screen sit on one device; two names for one - // session state is the bug this vocabulary exists to prevent. - XCTAssertEqual(AccountAttentionPhase.running.displayLabel, "Working") - 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, "Done") - XCTAssertEqual(AccountAttentionPhase.stale.displayLabel, "Stale") - XCTAssertEqual(AgentRunPhase.running.label, AccountAttentionPhase.running.displayLabel) - XCTAssertEqual(AgentRunPhase.completed.label, AccountAttentionPhase.completed.displayLabel) - } - - func testWorkspaceAgentPhaseFallbackUsesUnifiedVocabulary() { - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - let agents = [ - AgentSnapshot( - sessionId: "working", - provider: "codex", - title: "Working copy", - status: "running", - awaitingInput: false, - lastActivityAt: now, - elapsedSeconds: 20, - preview: nil, - progress: nil, - phase: "running", - toolCalls: 1 - ), - AgentSnapshot( - sessionId: "done-phase", - provider: "claude", - title: "Done copy", - status: "running", - awaitingInput: false, - lastActivityAt: now.addingTimeInterval(-1), - elapsedSeconds: 30, - preview: nil, - progress: nil, - phase: "completed", - toolCalls: 2 - ), - ] - - model.rebuild(from: WorkspaceSnapshot( - generatedAt: now, - agents: agents, - prs: [], - connection: "connected" - )) - - XCTAssertEqual(model.liveItems.map(\.sessionId), ["working", "done-phase"]) - XCTAssertEqual(model.liveItems.first(where: { $0.sessionId == "working" })?.subtitle, "Working") - XCTAssertEqual(model.liveItems.first(where: { $0.sessionId == "done-phase" })?.subtitle, "Done") - } - - func testWorkspaceBlockedPhaseUsesNeutralBlockedPresentation() { - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - let blocked = AgentSnapshot( - sessionId: "blocked-local", - provider: "codex", - title: "Waiting on dependency", - status: "running", - awaitingInput: false, - lastActivityAt: now, - elapsedSeconds: 20, - preview: "Blocked", - progress: nil, - phase: "blocked", - toolCalls: 1 - ) - - model.rebuild(from: WorkspaceSnapshot( - generatedAt: now, - agents: [blocked], - prs: [], - connection: "connected" - )) - - XCTAssertEqual(model.liveItems.first?.kind, .blocked) - XCTAssertTrue(model.items.isEmpty) - } - - func testBlockedItemIsNotFiledOrColouredAsYourMove() { - // `blocked` used to borrow `awaitingInput`'s amber bell while filing - // itself under `live` — the row said "act on me" in colour and "just - // watching" in placement. `needsInbox` excludes it, so neutral is the - // honest reading. - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - let machine = AccountAttentionMachine( - machineKey: "studio", - accountMachineKey: "account-studio", - name: "Studio Mac", - online: true, - lastSeenAt: now - ) - let blocked = AccountAttentionItem( - id: "blocked", - revision: 1, - fingerprint: "blocked:1", - kind: .agent, - eventKind: .agentRunning, - phase: .blocked, - machine: machine, - project: .init(projectId: "ade", name: "ADE"), - title: "Waiting on a dependency", - preview: "Blocked", - privacyPreview: "Agent blocked", - destination: .session(sessionId: "session-blocked", itemId: nil, eventId: nil), - occurredAt: now, - updatedAt: now - ) - - XCTAssertFalse(blocked.needsInbox, "blocked never reaches the inbox") - - model.rebuild(from: AccountAttentionSnapshot(revision: 1, generatedAt: now, items: [blocked])) - - XCTAssertEqual(model.liveItems.first?.kind, .blocked) - XCTAssertTrue(model.items.isEmpty, "blocked must not land in the Needs you collection") - } - - private func makeAccountItem( - id: String, - revision: Int, - title: String, - 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: eventKind, - phase: phase, - machine: machine ?? .init( - machineKey: "studio", - name: "Studio Mac", - online: true, - lastSeenAt: now - ), - project: .init(projectId: projectId, name: projectName), - 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/HubProjectPresentationTests.swift b/apps/ios/ADETests/HubProjectPresentationTests.swift new file mode 100644 index 000000000..c79c858c8 --- /dev/null +++ b/apps/ios/ADETests/HubProjectPresentationTests.swift @@ -0,0 +1,151 @@ +import XCTest +@testable import ADE + +/// The hub's project card and chat rows carried live counts and a status string +/// that were computed and then never rendered. These lock the presentation seam +/// so the numbers cannot silently go quiet again — including the equatable +/// short-circuit, which is what would freeze them. +final class HubProjectPresentationTests: XCTestCase { + + // MARK: - Status line copy + + func testStatusLineIsSilentWhenNothingIsHappening() { + XCTAssertNil(hubProjectStatusLine(attentionCount: 0, runningCount: 0)) + } + + func testStatusLineNamesOnlyTheNonZeroClauses() { + XCTAssertEqual(hubProjectStatusLine(attentionCount: 2, runningCount: 0), "2 need you") + XCTAssertEqual(hubProjectStatusLine(attentionCount: 0, runningCount: 3), "3 working") + XCTAssertEqual(hubProjectStatusLine(attentionCount: 2, runningCount: 3), "2 need you · 3 working") + } + + // MARK: - Counts reach the card + + func testProjectPresentationCarriesRosterCounts() { + let presentation = buildHubProjectPresentation( + project: project(), + roster: roster(attentionCount: 1, runningCount: 2), + isActive: false, + isSwitching: false + ) + + XCTAssertEqual(presentation.attentionCount, 1) + XCTAssertEqual(presentation.runningCount, 2) + XCTAssertEqual(presentation.statusLine, "1 need you · 2 working") + } + + func testEquatableShortCircuitDoesNotFreezeTheCounts() { + let quiet = buildHubProjectPresentation( + project: project(), + roster: roster(attentionCount: 0, runningCount: 0), + isActive: false, + isSwitching: false + ) + let busy = buildHubProjectPresentation( + project: project(), + roster: roster(attentionCount: 1, runningCount: 0), + isActive: false, + isSwitching: false + ) + + XCTAssertNotEqual(quiet, busy, "a count change must re-render the card") + } + + func testMissingRosterReportsNoLiveCounts() { + let presentation = buildHubProjectPresentation( + project: project(), + roster: nil, + isActive: false, + isSwitching: false + ) + + XCTAssertEqual(presentation.attentionCount, 0) + XCTAssertNil(presentation.statusLine) + } + + // MARK: - Chat row status + + func testChatRowStatusLabelSpeaksOnlyWhenItHasSomethingToSay() { + XCTAssertEqual(hubChatStatusLabel("awaiting-input"), "Needs you") + XCTAssertEqual(hubChatStatusLabel("active"), "Working") + XCTAssertNil(hubChatStatusLabel("idle")) + XCTAssertNil(hubChatStatusLabel("ended")) + } + + func testChatRowPresentationCarriesTheNormalizedStatus() { + let row = HubChatRowPresentation.make( + chat: chat(id: "c-1", status: .running, awaitingInput: true) + ) + + XCTAssertEqual(row.statusString, "awaiting-input") + } + + func testChatRowEquatableTracksAStatusChange() { + let waiting = HubChatRowPresentation.make( + chat: chat(id: "c-1", status: .running, awaitingInput: true) + ) + let working = HubChatRowPresentation.make( + chat: chat(id: "c-1", status: .running, awaitingInput: false) + ) + + XCTAssertNotEqual(waiting, working, "the status dot must not stick on a stale value") + } + + // MARK: - Fixtures + + private func project() -> MobileProjectSummary { + MobileProjectSummary( + id: "p-1", + displayName: "ADE", + laneCount: 2, + isAvailable: true, + isCached: true + ) + } + + private func roster(attentionCount: Int, runningCount: Int) -> RemoteRosterProject { + RemoteRosterProject( + projectId: "p-1", + rootPath: nil, + displayName: "ADE", + iconDataUrl: nil, + lastOpenedAt: nil, + booted: true, + runningCount: runningCount, + attentionCount: attentionCount, + lanes: [ + RemoteRosterLane( + id: "lane-1", + name: "activity-revamp", + color: nil, + icon: nil, + laneType: nil, + branchRef: nil + ), + ], + chats: [chat(id: "c-1", status: .running, awaitingInput: false)] + ) + } + + private func chat( + id: String, + status: RemoteRosterChatStatus, + awaitingInput: Bool + ) -> RemoteRosterChat { + RemoteRosterChat( + id: id, + laneId: "lane-1", + chatSessionId: nil, + title: "Wire the drawer", + provider: "claude", + model: nil, + toolType: "chat", + status: status, + awaitingInput: awaitingInput, + pinned: nil, + archived: nil, + lastActivityAt: "2026-08-01T00:00:00Z", + preview: nil + ) + } +} diff --git a/apps/ios/ADETests/PairingAndDpopTests.swift b/apps/ios/ADETests/PairingAndDpopTests.swift index 3537c6816..45e9709f5 100644 --- a/apps/ios/ADETests/PairingAndDpopTests.swift +++ b/apps/ios/ADETests/PairingAndDpopTests.swift @@ -205,7 +205,7 @@ final class PairingAndDpopTests: XCTestCase { "laneName": "Primary", "provider": "codex", "model": "gpt-5", - "title": "Polish mobile Attention", + "title": "Polish mobile Activity", "preview": "Type checking widgets", "privacyPreview": "Agent working", "detail": null, @@ -314,13 +314,23 @@ final class PairingAndDpopTests: XCTestCase { XCTAssertEqual(payload["itemIds"] as? [String], ["item-a", "item-b"]) XCTAssertNotNil(payload["seenAt"] as? String) XCTAssertNotNil(payload["dismissedAt"] as? String) + XCTAssertEqual( + payload["sourceRevisions"] as? [String: Int], + ["item-a": 7, "item-b": 11] + ) + XCTAssertEqual(payload["expectedAccountOwnerId"] as? String, "account-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(#"{"ok":true,"revision":9}"#.utf8)) + return ( + response, + Data( + #"{"ok":true,"revision":9,"applied":["item-a"],"stale":["item-b"]}"#.utf8 + ) + ) } defer { AccountDirectoryURLProtocolStub.reset() } @@ -330,12 +340,17 @@ final class PairingAndDpopTests: XCTestCase { session: URLSession(configuration: configuration) ) - try await client.acknowledge( + let result = try await client.acknowledge( baseURL: try XCTUnwrap(URL(string: "https://relay.example")), token: "clerk-token", itemIds: ["item-a", "item-b"], - dismiss: true + seenAt: Date(timeIntervalSince1970: 100), + dismissedAt: Date(timeIntervalSince1970: 101), + sourceRevisions: ["item-a": 7, "item-b": 11], + expectedAccountOwnerId: "account-a" ) + XCTAssertEqual(result.applied, ["item-a"]) + XCTAssertEqual(result.stale, ["item-b"]) } func testAccountAttentionDevicePreferencesUseScopedPatch() async throws { diff --git a/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift b/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift index 8a5a8db47..f78a942f0 100644 --- a/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift +++ b/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift @@ -291,6 +291,160 @@ final class WorkSessionCanonicalStateTests: XCTestCase { XCTAssertNil(badge) } + // MARK: - The full status vocabulary + // + // `badge` stays the attention third — the three states something downstream + // may reasonably treat as "act on this". `workSessionStatusBadge` is the wider + // vocabulary the row renders, and every word and hue in it comes from the + // shared `ActivityPhaseVocabulary`, not from a second table here. + + func testStatusBadgeCoversTheDescriptiveStates() { + struct Case { + let name: String + let session: TerminalSessionSummary + let summary: AgentChatSessionSummary? + let kind: SessionBadgeKind? + let label: String? + let tone: ActivityTone + } + + let cases: [Case] = [ + Case( + name: "running agent", + session: makeSession(status: "running", runtimeState: "running", toolType: "codex", startedAt: iso(now)), + summary: nil, + kind: .working, + label: "Working", + tone: .blue + ), + Case( + name: "planning chat", + session: makeSession(status: "running", runtimeState: "running", toolType: "codex-chat", startedAt: iso(now)), + summary: makeChatSummary(status: "active", awaitingInput: false, interactionMode: "plan"), + kind: .planning, + label: "Planning", + tone: .violet + ), + Case( + name: "settled", + session: makeSession(status: "running", runtimeState: "idle", toolType: "codex-chat", settledAt: iso(now)), + summary: nil, + kind: .done, + label: "Done", + tone: .emerald + ), + Case( + name: "blocked on the user", + session: makeSession( + status: "running", + runtimeState: "running", + toolType: "codex-chat", + pendingInputItemId: "approval-1" + ), + summary: nil, + kind: .needsYou, + label: "Needs you", + tone: .amber + ), + Case( + name: "failed", + session: makeSession(status: "ended", runtimeState: "exited", toolType: "codex", exitCode: 130), + summary: nil, + kind: .failed, + label: "Failed", + tone: .red + ), + Case( + name: "stale", + session: makeSession( + status: "running", + runtimeState: "running", + toolType: "codex-chat", + startedAt: iso(now) + ), + summary: makeChatSummary(status: "active", awaitingInput: false), + kind: .stale, + label: "Stale", + tone: .neutral + ), + ] + + for testCase in cases { + var summary = testCase.summary + if testCase.name == "stale" { + summary?.lastActivityAt = silentFor(sessionStaleAfterSeconds + 60) + } + let badge = workSessionStatusBadge(session: testCase.session, summary: summary, now: now) + XCTAssertEqual(badge?.kind, testCase.kind, testCase.name) + XCTAssertEqual(badge?.label, testCase.label, testCase.name) + XCTAssertEqual(badge?.tone, testCase.tone, testCase.name) + XCTAssertEqual( + workSessionRowTone(session: testCase.session, summary: summary, now: now), + testCase.tone, + testCase.name + ) + } + } + + /// Resting states still earn no capsule: the row must not shift layout to say + /// that nothing is happening. + func testStatusBadgeStaysNilForRestingStates() { + let ready = makeSession(status: "ended", runtimeState: "exited", toolType: "codex-chat") + let idle = makeSession(status: "running", runtimeState: "idle", toolType: "codex") + let stopped = makeSession(status: "disposed", runtimeState: "killed", toolType: "codex", exitCode: 130) + + for session in [ready, idle, stopped] { + XCTAssertNil(workSessionStatusBadge(session: session, summary: nil, now: now)) + XCTAssertEqual(workSessionRowTone(session: session, summary: nil, now: now), .neutral) + } + } + + /// Planning is a presentation fact derived from the chat's interaction mode, + /// exactly as on desktop — it never becomes a canonical phase. + func testPlanningNeverBecomesACanonicalPhase() { + let session = makeSession(status: "running", runtimeState: "running", toolType: "codex-chat", startedAt: iso(now)) + let summary = makeChatSummary(status: "active", awaitingInput: false, interactionMode: "plan") + + XCTAssertEqual(workCanonicalSessionState(session: session, summary: summary, now: now).phase, .running) + XCTAssertEqual(workSessionStatusBadge(session: session, summary: summary, now: now)?.kind, .planning) + } + + /// A blocked session is amber whatever mode it is in — planning must never + /// outvote a raised hand. + func testNeedsYouOutranksPlanning() { + let session = makeSession( + status: "running", + runtimeState: "running", + toolType: "codex-chat", + pendingInputItemId: "approval-1" + ) + let summary = makeChatSummary(status: "active", awaitingInput: false, interactionMode: "plan") + + XCTAssertEqual(workSessionStatusBadge(session: session, summary: summary, now: now)?.kind, .needsYou) + } + + func testCanonicalPhasesMapOntoTheSharedVocabulary() { + XCTAssertEqual(workActivityPhase(for: .running), .running) + XCTAssertEqual(workActivityPhase(for: .starting), .starting) + XCTAssertEqual(workActivityPhase(for: .needsYou), .needsYou) + XCTAssertEqual(workActivityPhase(for: .failed), .failed) + XCTAssertEqual(workActivityPhase(for: .stale), .stale) + XCTAssertEqual(workActivityPhase(for: .settled), .completed) + XCTAssertEqual(workActivityPhase(for: .ready), .unrecognized("ready")) + XCTAssertEqual(workActivityPhase(for: .idle), .unrecognized("idle")) + XCTAssertEqual(workActivityPhase(for: .stopped), .unrecognized("stopped")) + XCTAssertEqual(workActivityPhase(for: .ended), .unrecognized("ended")) + } + + /// The one-hue rule, from the other direction: the coarse status string every + /// roster surface holds must not be able to paint amber for a resting chat. + func testChatStatusToneSpendsAmberOnlyOnNeedsYou() { + XCTAssertEqual(workChatStatusTone("awaiting-input"), .amber) + XCTAssertEqual(workChatStatusTone("active"), .blue) + XCTAssertEqual(workChatStatusTone("idle"), .neutral) + XCTAssertEqual(workChatStatusTone("ended"), .neutral) + } + func testWorkSessionRowPreviewUsesFreshOutputBeforeSummaryAndGoal() { var session = makeSession( status: "running", @@ -470,7 +624,8 @@ final class WorkSessionCanonicalStateTests: XCTestCase { exitCode: Int? = nil, pendingInputItemId: String? = nil, lastOutputPreview: String? = nil, - startedAt: String? = nil + startedAt: String? = nil, + settledAt: String? = nil ) -> TerminalSessionSummary { TerminalSessionSummary( id: "s-1", @@ -487,6 +642,7 @@ final class WorkSessionCanonicalStateTests: XCTestCase { startedAt: startedAt ?? iso(now), endedAt: nil, archivedAt: nil, + settledAt: settledAt, exitCode: exitCode, transcriptPath: "", headShaStart: nil, @@ -557,7 +713,8 @@ final class WorkSessionCanonicalStateTests: XCTestCase { private func makeChatSummary( status: String, awaitingInput: Bool?, - pendingInputItemId: String? = nil + pendingInputItemId: String? = nil, + interactionMode: String? = nil ) -> AgentChatSessionSummary { AgentChatSessionSummary( sessionId: "chat-1", @@ -573,7 +730,7 @@ final class WorkSessionCanonicalStateTests: XCTestCase { fastMode: nil, executionMode: nil, permissionMode: nil, - interactionMode: nil, + interactionMode: interactionMode, claudePermissionMode: nil, codexApprovalPolicy: nil, codexSandbox: nil, diff --git a/apps/ios/ADETests/WorkSessionGroupingTests.swift b/apps/ios/ADETests/WorkSessionGroupingTests.swift new file mode 100644 index 000000000..8b1095ad5 --- /dev/null +++ b/apps/ios/ADETests/WorkSessionGroupingTests.swift @@ -0,0 +1,436 @@ +import XCTest +@testable import ADE + +/// The Work list's two structural rules, neither of which had any coverage: +/// the singleton/headerless lane (desktop `SessionListPane.tsx` `headerlessLaneIds`) +/// and lane ordering (desktop `workLaneOrder.ts` `compareWorkLanes`). +/// +/// Both are load-bearing for how the column reads and both are pure, so they are +/// asserted here rather than through a rendered list. +final class WorkSessionGroupingTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_780_000_000) + + // MARK: - Headerless: the singleton rule + + func testSingletonLaneDropsItsHeader() { + let lane = makeLane(id: "lane-a", name: "feature/one") + let presentation = makePresentation( + sessions: [makeSession(id: "s-1", laneId: lane.id)], + lanes: [lane] + ) + + XCTAssertEqual(presentation.sessionGroups.map(\.id), ["lane:lane-a"]) + XCTAssertEqual(presentation.sessionGroups.first?.isHeaderless, true) + } + + func testLaneWithTwoSessionsKeepsItsHeader() { + let lane = makeLane(id: "lane-a", name: "feature/one") + let presentation = makePresentation( + sessions: [ + makeSession(id: "s-1", laneId: lane.id), + makeSession(id: "s-2", laneId: lane.id), + ], + lanes: [lane] + ) + + XCTAssertEqual(presentation.sessionGroups.first?.isHeaderless, false) + } + + /// A chat and the shells it spawned are one unit. Counting them separately + /// would summon a header for what the user reads as a single row. + func testChatWithChildShellsStaysHeaderless() { + let lane = makeLane(id: "lane-a", name: "feature/one") + let presentation = makePresentation( + sessions: [ + makeSession(id: "chat-1", laneId: lane.id), + makeSession(id: "shell-1", laneId: lane.id, chatSessionId: "chat-1"), + makeSession(id: "shell-2", laneId: lane.id, chatSessionId: "chat-1"), + ], + lanes: [lane] + ) + + XCTAssertEqual(presentation.sessionGroups.first?.isHeaderless, true) + } + + func testPinnedLaneKeepsItsHeader() { + let lane = makeLane(id: "lane-a", name: "feature/one") + let presentation = makePresentation( + sessions: [makeSession(id: "s-1", laneId: lane.id)], + lanes: [lane], + pinnedLaneIds: ["lane-a"] + ) + + XCTAssertEqual(presentation.sessionGroups.first?.isHeaderless, false) + } + + func testPendingHandoffKeepsTheHeader() { + let ids = workHeaderlessLaneIds([ + WorkHeaderlessLaneInput(laneId: "lane-a", topLevelSessionCount: 1, hasPendingHandoff: true), + WorkHeaderlessLaneInput(laneId: "lane-b", topLevelSessionCount: 1), + ]) + + XCTAssertEqual(ids, ["lane-b"]) + } + + func testOfflineMachineLaneKeepsTheHeader() { + let ids = workHeaderlessLaneIds([ + WorkHeaderlessLaneInput(laneId: "lane-a", topLevelSessionCount: 1, machineOnline: false), + WorkHeaderlessLaneInput(laneId: "lane-b", topLevelSessionCount: 1), + ]) + + XCTAssertEqual(ids, ["lane-b"]) + } + + func testManualSortModeOptsEveryLaneOutOfTheSingletonForm() { + let ids = workHeaderlessLaneIds( + [ + WorkHeaderlessLaneInput(laneId: "lane-a", topLevelSessionCount: 1), + WorkHeaderlessLaneInput(laneId: "lane-b", topLevelSessionCount: 1), + ], + sortMode: .manual + ) + + XCTAssertTrue(ids.isEmpty) + } + + func testEmptyLaneIsNotHeaderless() { + XCTAssertTrue(workHeaderlessLaneIds([ + WorkHeaderlessLaneInput(laneId: "lane-a", topLevelSessionCount: 0) + ]).isEmpty) + } + + /// Rule 1: the threshold reads the unfiltered roster. Without it a search that + /// narrows a busy lane to one hit would drop the header mid-keystroke, and + /// put it back on the next one. + func testSearchNarrowingALaneToOneRowDoesNotDropTheHeader() { + let lane = makeLane(id: "lane-a", name: "feature/one") + let presentation = makePresentation( + sessions: [ + makeSession(id: "s-1", laneId: lane.id, title: "Fix login"), + makeSession(id: "s-2", laneId: lane.id, title: "Audit sync"), + ], + lanes: [lane], + searchText: "login" + ) + + XCTAssertEqual(presentation.displaySessionIds, ["s-1"]) + XCTAssertEqual(presentation.sessionGroups.first?.isHeaderless, false) + } + + // MARK: - Quiet lanes stay orthogonal + + /// Quiet ("everything here has settled") and headerless ("there is only one + /// row") answer different questions, and a lane can be both. + func testSettledSingletonLaneIsBothQuietAndHeaderless() { + let lane = makeLane(id: "lane-a", name: "feature/one") + let settled = makeSession(id: "s-1", laneId: lane.id, settledAt: iso(now.addingTimeInterval(-60))) + let presentation = makePresentation(sessions: [settled], lanes: [lane]) + + let group = presentation.sessionGroups.first + XCTAssertEqual(group?.isQuiet, true) + XCTAssertEqual(group?.isHeaderless, true) + } + + func testQuietLaneWithTwoSessionsKeepsItsHeader() { + let lane = makeLane(id: "lane-a", name: "feature/one") + let presentation = makePresentation( + sessions: [ + makeSession(id: "s-1", laneId: lane.id, settledAt: iso(now.addingTimeInterval(-60))), + makeSession(id: "s-2", laneId: lane.id, settledAt: iso(now.addingTimeInterval(-90))), + ], + lanes: [lane] + ) + + let group = presentation.sessionGroups.first + XCTAssertEqual(group?.isQuiet, true) + XCTAssertEqual(group?.isHeaderless, false) + } + + // MARK: - Ordering tiers + + func testPrimaryLaneLeadsEveryTier() { + // The primary lane is the oldest and quiet — every other key would sink it. + let primary = makeLane(id: "lane-primary", name: "Primary", laneType: "primary", createdAt: "2026-01-01T00:00:00.000Z") + let pinned = makeLane(id: "lane-pinned", name: "Pinned", createdAt: "2026-06-01T00:00:00.000Z") + let active = makeLane(id: "lane-active", name: "Active", createdAt: "2026-05-01T00:00:00.000Z") + + let ordered = orderWorkLanes( + [active, pinned, primary], + inputs: [ + "lane-primary": WorkLaneOrderInput(lane: primary, quiet: true), + "lane-pinned": WorkLaneOrderInput(lane: pinned, pinned: true), + "lane-active": WorkLaneOrderInput(lane: active), + ] + ) + + XCTAssertEqual(ordered.map(\.id), ["lane-primary", "lane-pinned", "lane-active"]) + } + + func testTierOrderIsPinnedThenActiveThenQuiet() { + let quiet = makeLane(id: "lane-quiet", name: "Quiet", createdAt: "2026-07-01T00:00:00.000Z") + let active = makeLane(id: "lane-active", name: "Active", createdAt: "2026-06-01T00:00:00.000Z") + let pinned = makeLane(id: "lane-pinned", name: "Pinned", createdAt: "2026-05-01T00:00:00.000Z") + + let ordered = orderWorkLanes( + [quiet, active, pinned], + inputs: [ + "lane-quiet": WorkLaneOrderInput(lane: quiet, quiet: true), + "lane-active": WorkLaneOrderInput(lane: active), + // A pin outranks quietness, and it also outranks being the oldest lane. + "lane-pinned": WorkLaneOrderInput(lane: pinned, pinned: true), + ] + ) + + XCTAssertEqual(ordered.map(\.id), ["lane-pinned", "lane-active", "lane-quiet"]) + } + + func testPinOutranksQuietness() { + let pinnedQuiet = makeLane(id: "lane-pinned", name: "Pinned", createdAt: "2026-05-01T00:00:00.000Z") + let active = makeLane(id: "lane-active", name: "Active", createdAt: "2026-06-01T00:00:00.000Z") + + let ordered = orderWorkLanes( + [active, pinnedQuiet], + inputs: [ + "lane-pinned": WorkLaneOrderInput(lane: pinnedQuiet, quiet: true, pinned: true), + "lane-active": WorkLaneOrderInput(lane: active), + ] + ) + + XCTAssertEqual(ordered.map(\.id), ["lane-pinned", "lane-active"]) + } + + func testCreatedModeSortsNewestFirstWithinATier() { + let older = makeLane(id: "lane-older", name: "Older", createdAt: "2026-05-01T00:00:00.000Z") + let newer = makeLane(id: "lane-newer", name: "Newer", createdAt: "2026-06-01T00:00:00.000Z") + + let ordered = orderWorkLanes( + [older, newer], + inputs: [ + "lane-older": WorkLaneOrderInput(lane: older), + "lane-newer": WorkLaneOrderInput(lane: newer), + ] + ) + + XCTAssertEqual(ordered.map(\.id), ["lane-newer", "lane-older"]) + } + + func testActivityModeSortsByLatestActivityAndFilesLanesWithNoneLast() { + let quietest = makeLane(id: "lane-c", name: "C", createdAt: "2026-06-03T00:00:00.000Z") + let busiest = makeLane(id: "lane-a", name: "A", createdAt: "2026-06-01T00:00:00.000Z") + let middle = makeLane(id: "lane-b", name: "B", createdAt: "2026-06-02T00:00:00.000Z") + + let ordered = orderWorkLanes( + [quietest, busiest, middle], + inputs: [ + "lane-c": WorkLaneOrderInput(lane: quietest, lastActivityAt: nil), + "lane-a": WorkLaneOrderInput(lane: busiest, lastActivityAt: now), + "lane-b": WorkLaneOrderInput(lane: middle, lastActivityAt: now.addingTimeInterval(-600)), + ], + mode: .activity + ) + + XCTAssertEqual(ordered.map(\.id), ["lane-a", "lane-b", "lane-c"]) + } + + /// The comparator has to be total, or two lanes that tie on every key swap + /// places between renders and the column visibly jitters. + func testIdBreaksAnOtherwiseCompleteTie() { + let left = makeLane(id: "lane-b", name: "Same", createdAt: "2026-06-01T00:00:00.000Z") + let right = makeLane(id: "lane-a", name: "Same", createdAt: "2026-06-01T00:00:00.000Z") + + let ordered = orderWorkLanes( + [left, right], + inputs: [ + "lane-b": WorkLaneOrderInput(lane: left), + "lane-a": WorkLaneOrderInput(lane: right), + ] + ) + + XCTAssertEqual(ordered.map(\.id), ["lane-a", "lane-b"]) + } + + func testManualModeFilesUnplacedLanesAfterEveryPlacedOne() { + let placed = makeLane(id: "lane-placed", name: "Placed", createdAt: "2026-05-01T00:00:00.000Z") + let unplaced = makeLane(id: "lane-unplaced", name: "Unplaced", createdAt: "2026-07-01T00:00:00.000Z") + + let ordered = orderWorkLanes( + [unplaced, placed], + inputs: [ + "lane-placed": WorkLaneOrderInput(lane: placed), + "lane-unplaced": WorkLaneOrderInput(lane: unplaced), + ], + mode: .manual, + manualOrder: ["lane-placed"] + ) + + XCTAssertEqual(ordered.map(\.id), ["lane-placed", "lane-unplaced"]) + } + + func testPresentationOrdersLanesByTier() { + let primary = makeLane(id: "lane-primary", name: "Primary", laneType: "primary", createdAt: "2026-01-01T00:00:00.000Z") + let quiet = makeLane(id: "lane-quiet", name: "Quiet", createdAt: "2026-07-01T00:00:00.000Z") + let active = makeLane(id: "lane-active", name: "Active", createdAt: "2026-06-01T00:00:00.000Z") + + let presentation = makePresentation( + sessions: [ + makeSession(id: "s-primary", laneId: primary.id), + makeSession(id: "s-quiet", laneId: quiet.id, settledAt: iso(now.addingTimeInterval(-60))), + makeSession(id: "s-active", laneId: active.id), + ], + lanes: [quiet, active, primary] + ) + + XCTAssertEqual( + presentation.sessionGroups.map(\.id), + ["lane:lane-primary", "lane:lane-active", "lane:lane-quiet"] + ) + } + + // MARK: - Offline machine banner + + func testOfflineBannerSurfacesOneEntryPerMachineInThisProject() { + let banners = workOfflineMachineBanners( + scopes: [ + scope(machineKey: "studio", projectId: "project-1", laneId: "lane-a"), + scope(machineKey: "studio", projectId: "project-1", laneId: "lane-b"), + scope(machineKey: "laptop", projectId: "project-1", laneId: "lane-c"), + ], + activeProjectId: "project-1", + now: now + ) + + XCTAssertEqual(banners.map(\.machineName), ["laptop", "studio"]) + XCTAssertEqual(banners.first?.lastSeenLabel, "last seen 2h ago") + } + + func testOfflineBannerIgnoresOtherProjects() { + let banners = workOfflineMachineBanners( + scopes: [scope(machineKey: "studio", projectId: "project-2", laneId: "lane-z")], + activeProjectId: "project-1", + now: now + ) + + XCTAssertTrue(banners.isEmpty) + } + + /// Items published before a project id was carried still match through the + /// lane they name, so an outage is not silently dropped. + func testOfflineBannerFallsBackToLaneScope() { + let banners = workOfflineMachineBanners( + scopes: [scope(machineKey: "studio", projectId: "", laneId: "lane-a")], + activeProjectId: "project-1", + laneIds: ["lane-a"], + now: now + ) + + XCTAssertEqual(banners.map(\.id), ["studio"]) + } + + // MARK: - Fixtures + + private func makePresentation( + sessions: [TerminalSessionSummary], + lanes: [LaneSummary], + pinnedLaneIds: Set = [], + searchText: String = "" + ) -> WorkRootSessionPresentation { + buildWorkRootSessionPresentation( + sessions: sessions, + optimisticSessions: [:], + chatSummaries: [:], + archivedSessionIds: [], + selectedStatus: .all, + selectedLaneId: "all", + searchText: searchText, + organization: .byLane, + orderedLanes: lanes, + pinnedLaneIds: pinnedLaneIds, + now: now + ) + } + + private func scope(machineKey: String, projectId: String, laneId: String?) -> ActivityOfflineScope { + ActivityOfflineScope( + machineKey: machineKey, + machineName: machineKey, + lastSeenAt: now.addingTimeInterval(-2 * 60 * 60), + projectId: projectId, + laneId: laneId + ) + } + + private func iso(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) + } + + private func makeSession( + id: String, + laneId: String, + title: String = "Session", + status: String = "running", + runtimeState: String = "running", + settledAt: String? = nil, + chatSessionId: String? = nil + ) -> TerminalSessionSummary { + TerminalSessionSummary( + id: id, + laneId: laneId, + laneName: laneId, + ptyId: nil, + tracked: true, + pinned: false, + manuallyNamed: nil, + goal: nil, + toolType: "codex-chat", + title: title, + status: status, + startedAt: iso(now.addingTimeInterval(-300)), + endedAt: nil, + archivedAt: nil, + settledAt: settledAt, + exitCode: nil, + transcriptPath: "", + headShaStart: nil, + headShaEnd: nil, + lastOutputPreview: nil, + summary: nil, + runtimeState: settledAt == nil ? runtimeState : "idle", + resumeCommand: nil, + resumeMetadata: nil, + chatIdleSinceAt: nil, + chatSessionId: chatSessionId + ) + } + + private func makeLane( + id: String, + name: String, + laneType: String = "worktree", + createdAt: String = "2026-06-01T00:00:00.000Z" + ) -> LaneSummary { + LaneSummary( + id: id, + name: name, + description: nil, + laneType: laneType, + baseRef: "main", + branchRef: "feature/\(id)", + worktreePath: "", + attachedRootPath: nil, + parentLaneId: nil, + childCount: 0, + stackDepth: 0, + parentStatus: nil, + isEditProtected: false, + status: LaneStatus(dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false), + color: nil, + icon: nil, + tags: [], + folder: nil, + createdAt: createdAt, + archivedAt: nil + ) + } +} diff --git a/apps/ios/ADEWidgets/ADELockScreenWidget.swift b/apps/ios/ADEWidgets/ADELockScreenWidget.swift index 0b73d7051..b25bf850e 100644 --- a/apps/ios/ADEWidgets/ADELockScreenWidget.swift +++ b/apps/ios/ADEWidgets/ADELockScreenWidget.swift @@ -134,6 +134,12 @@ private struct LockScreenPriorityStatus { let tint: Color let destinationURL: URL let metrics: [Metric] + /// Up to two rows for the rectangular family. Empty on the machine-local + /// fallback path, which has no per-item feed to list — that path keeps the + /// single-focus layout. + let lines: [ActivityWidgetPresentation.CompactLine] + /// Visible rows the two lines left off. + let overflowCount: Int struct Metric: Identifiable { let id: String @@ -143,21 +149,32 @@ private struct LockScreenPriorityStatus { 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 visible = ActivityWidgetPresentation.visibleItems(attentionSnapshot.items, now: 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) + // The rectangular family lists rows; the circular and inline families + // still compress everything into the single focus below. + let lines = ActivityWidgetPresentation.compactLines( + for: visible, + hideDetails: hideDetails, + now: now + ) + let overflow = ActivityWidgetPresentation.overflowCount(for: visible, now: now) + // Tapping goes to whatever is actually blocked on the reader, not to + // whatever happened to sort first. + let destination = ActivityWidgetPresentation.deepLink(for: visible, now: now) + // "N need" means N rows are blocked on the reader. It used to count the + // whole inbox — PR traffic and finished-but-unlooked-at rows included — + // which made a quiet account read as a demanding one. + let needsYou = visible.filter { $0.phase == .needsYou } 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"), + needsYou.isEmpty ? nil : Metric(id: "needs", label: "\(needsYou.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 } @@ -172,7 +189,7 @@ private struct LockScreenPriorityStatus { symbol: "moon.zzz.fill", shortLabel: "IDLE", tint: ADESharedTheme.statusIdle, - destinationURL: Self.workspaceURL, + destinationURL: ActivityWidgetPresentation.activityURL, metrics: [] ) return @@ -190,22 +207,24 @@ private struct LockScreenPriorityStatus { symbol: "wifi.slash", shortLabel: "OFF", tint: ADESharedTheme.statusIdle, - destinationURL: focus.deepLinkURL ?? Self.workspaceURL, - metrics: metrics + destinationURL: destination, + metrics: metrics, + lines: lines, + overflowCount: overflow ) return } let presentation = Self.presentation(for: focus.phase) let scope = "\(focus.machine.name) · \(focus.project.name)" - let attentionCount = inbox.count + let attentionCount = needsYou.count let ambientCount = visible.count let privateTitle = focus.privacyPreview .trimmingCharacters(in: .whitespacesAndNewlines) self = .init( kind: presentation.kind, title: hideDetails - ? (privateTitle.isEmpty ? "Attention update" : privateTitle) + ? (privateTitle.isEmpty ? "Activity update" : privateTitle) : focus.title, detail: hideDetails ? "Across your signed-in machines" : scope, inlineText: attentionCount > 0 @@ -219,8 +238,10 @@ private struct LockScreenPriorityStatus { symbol: presentation.symbol, shortLabel: presentation.label, tint: presentation.tint, - destinationURL: focus.deepLinkURL ?? Self.workspaceURL, - metrics: metrics + destinationURL: destination, + metrics: metrics, + lines: lines, + overflowCount: overflow ) } @@ -429,8 +450,12 @@ private struct LockScreenPriorityStatus { shortLabel: String, tint: Color, destinationURL: URL, - metrics: [Metric] + metrics: [Metric], + lines: [ActivityWidgetPresentation.CompactLine] = [], + overflowCount: Int = 0 ) { + self.lines = lines + self.overflowCount = overflowCount self.kind = kind self.title = title self.detail = detail @@ -514,6 +539,7 @@ private struct LockScreenPriorityStatus { case .open, .stale: return 4 case .completed, .merged: return 5 case .closed: return 6 + case .unrecognized: return 7 } } @@ -559,12 +585,21 @@ private struct LockScreenPriorityStatus { return (.idle, "arrow.triangle.merge", "MERGED", ADESharedTheme.statusSuccess) case .closed: return (.idle, "xmark.circle.fill", "CLOSED", ADESharedTheme.statusIdle) + case .unrecognized: + return (.idle, "questionmark.circle", "UNKNOWN", ADESharedTheme.statusIdle) } } } // MARK: - Rectangular +/// Two compact session lines plus the metrics tail when the account feed can +/// supply them, and the original single-focus layout when it cannot (the +/// machine-local fallback path, which has no per-item feed). +/// +/// The rectangular family is the only one with room for more than one fact, and +/// it used to spend all of it on one row — so a lock screen with four agents +/// running looked identical to one with a single agent. private struct LockScreenRectangularView: View { let status: LockScreenPriorityStatus @Environment(\.isLuminanceReduced) private var isLuminanceReduced @@ -572,6 +607,71 @@ private struct LockScreenRectangularView: View { var body: some View { ZStack { AccessoryWidgetBackground() + if status.lines.isEmpty { + focusLayout + } else { + lineLayout + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("ADE status") + .accessibilityValue(accessibilityValue) + } + + private var accessibilityValue: String { + guard !status.lines.isEmpty else { return "\(status.title). \(status.detail)" } + var parts = status.lines.map { "\($0.title), \($0.phaseLabel)" } + if status.overflowCount > 0 { parts.append("\(status.overflowCount) more") } + return parts.joined(separator: ". ") + } + + private var lineLayout: some View { + VStack(alignment: .leading, spacing: 2) { + ForEach(status.lines) { line in + HStack(spacing: 5) { + Image(systemName: line.glyph?.systemImage ?? "circle.fill") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(activityToneColor(line.tone)) + .widgetAccentable() + .accessibilityHidden(true) + Text(line.title) + .font(.footnote.weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.8) + .truncationMode(.tail) + Spacer(minLength: 4) + Text(line.phaseLabel) + .font(.system(size: 9, weight: .bold, design: .rounded)) + .foregroundStyle(activityToneColor(line.tone)) + .lineLimit(1) + .fixedSize() + .widgetAccentable() + } + } + + HStack(spacing: 6) { + if status.overflowCount > 0 { + Text("+\(status.overflowCount) more") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + } + ForEach(status.metrics.prefix(status.overflowCount > 0 ? 1 : 2)) { metric in + Label(metric.label, systemImage: metric.symbol) + .font(.caption2.weight(.semibold)) + .labelStyle(.titleAndIcon) + .lineLimit(1) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + } + .padding(.horizontal, 1) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .opacity(isLuminanceReduced ? 0.85 : 1) + } + + private var focusLayout: some View { + Group { HStack(spacing: 8) { ZStack { Circle() @@ -622,9 +722,6 @@ private struct LockScreenRectangularView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .opacity(isLuminanceReduced ? 0.85 : 1) } - .accessibilityElement(children: .combine) - .accessibilityLabel("ADE status") - .accessibilityValue("\(status.title). \(status.detail)") } } diff --git a/apps/ios/ADEWidgets/ADEWidgetBundle.swift b/apps/ios/ADEWidgets/ADEWidgetBundle.swift index cd77434a7..1ad34ab0a 100644 --- a/apps/ios/ADEWidgets/ADEWidgetBundle.swift +++ b/apps/ios/ADEWidgets/ADEWidgetBundle.swift @@ -3,7 +3,7 @@ import WidgetKit /// The single `@main` entry point for the ADE widget extension. Registers the /// lock-screen glance widget plus the "agent runs" Live Activity. ADE keeps -/// external system surfaces calm: the in-app Attention Drawer owns details, +/// external system surfaces calm: the in-app Activity drawer owns details, /// while these surfaces own glanceable status. @main struct ADEWidgetBundle: WidgetBundle { diff --git a/apps/push-relay/README.md b/apps/push-relay/README.md index e7296f85a..0a4b173c7 100644 --- a/apps/push-relay/README.md +++ b/apps/push-relay/README.md @@ -1,10 +1,14 @@ # ADE Push Relay -Cloudflare Worker that consolidates ADE attention state across a signed-in -account, then fans it out to desktop Attention Center, iPhone, APNs, and Live +Cloudflare Worker that consolidates ADE Activity state across a signed-in +account, then fans it out to desktop Activity, iPhone, APNs, and Live Activities. ADE machine runtimes publish sanitized state; signed-in clients read and acknowledge the account stream directly. +The product surface is Activity. Relay routes, schema, payloads, and stored +fields retain their established `attention` names for wire and persistence +compatibility. + This is a **separate worker** from `apps/webhook-relay` (the GitHub webhook relay): different trust model, different lifecycle, and free-plan compatible on its own (single D1 database, no Durable Objects, no queues). @@ -22,7 +26,7 @@ its own (single D1 database, no Durable Objects, no queues). 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 +- The machine secret is scoped to push publishing only. Account Activity stores bounded, sanitized presentation metadata (titles, previews, destinations, progress, and acknowledgements), never transcripts, prompts, diffs, or artifact contents. Entries expire and tombstones are pruned. @@ -38,20 +42,44 @@ 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 | `/machines/:key/attention` | Publish the machine's complete account Activity snapshot (HMAC + Clerk bearer) | +| GET | `/attention/account/snapshot?since=` | Read account Activity 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 | +| PATCH | `/attention/account/preferences/devices/:deviceId` | Merge one device's overrides without rewriting the document | +| PATCH | `/attention/account/preferences/machines/:machineKey` | Merge one machine's overrides (this is what "mute this Mac" writes) | | PATCH | `/attention/account/preferences/devices/:deviceId` | Atomically merge one device's preference override without overwriting concurrent account or other-device changes | | 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`. Omitting `pushToStartToken` preserves it; `clearPushToStartToken: true` removes it. DELETE retains the 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. +### Account Activity semantics + +- The worker merges every linked machine into a revisioned account stream, + including tombstones so desktop and iOS converge after removals. +- **Publish protocol 2.** Every publish response carries `protocol: 2` plus the + current `acks`, so a reconnecting brain learns what other devices already + dismissed without waiting for its own read. `POST /machines/:key/attention` + takes a `mode`: `reconcile` (full roster, paged, `final: true` on the last + page), `delta` (changed items only), or `presence` (no items — it holds + presence and lets a due alert retry). Each publish stamps a monotonic + `rosterEpoch`; a reconcile's `final` page seals it, and anything left on an + older epoch for that machine is dropped in one commit. A delta reuses the + epoch and so never implies a deletion. A truncating publish answers + `itemsTruncated` so the brain schedules a fresh reconcile. Publishers that + predate this keep sending full snapshots and still work. +- **Two fingerprints.** `contentFingerprint` is what the row looks like with + progress churn normalized away — an unchanged one skips the write entirely. + `alertFingerprint` is the stable identity of one phase entry and survives the + item being removed and republished, which is what stops a reconnect from + re-alerting. Legacy publishers send neither and fall back to `fingerprint`. +- **Alerting gates.** Only items whose `activityTier` is `signal` may notify, + and only if `updatedAt` is within 15 minutes — so a machine returning from + offline never fires its recovered backlog. Sends are claimed by a short-lived + delivery receipt, then recorded in `attention_alert_log` (account + alert + fingerprint + device, retained 30 days) so a delete-and-republish cannot + re-alert after the 7-day receipt is pruned. Alert payloads also set + `content-available` as an opportunistic background refresh. - 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, @@ -63,9 +91,11 @@ its own (single D1 database, no Durable Objects, no queues). start that never committed after a transient APNs failure. Once a start succeeds, durable state plus the content fingerprint suppress duplicates. - Device-registration preferences are compatibility fallbacks. Account - preferences override them; only an explicit account - `devices[deviceId]` override may supersede the account defaults for one - device. Phone settings use the scoped device PATCH, while account/project + preferences override them; only an explicit account `devices[deviceId]` + override may supersede the account defaults for one device. The document also + carries `project` and `machines` scopes — the latter keyed by machine key, so + muting one Mac silences its items everywhere rather than muting a category on + one phone. Phone settings use the scoped device PATCH, while account/project writes omit and preserve `devices`, so concurrent clients cannot erase one another's policy. - The relay owns one account-wide Live Activity per iPhone. It focuses the @@ -153,7 +183,7 @@ 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) +### Clerk verification (required for account Activity) Configure the same Clerk instance and ADE OAuth client used by desktop and iOS: @@ -164,7 +194,7 @@ 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 an actionable `503`. A rejected +account Activity routes fail closed with an actionable `503`. A rejected session token returns `401` separately, so configuration outages are not misdiagnosed as a user sign-in problem. diff --git a/apps/push-relay/migrations/0005_activity_feed.sql b/apps/push-relay/migrations/0005_activity_feed.sql new file mode 100644 index 000000000..65153c60a --- /dev/null +++ b/apps/push-relay/migrations/0005_activity_feed.sql @@ -0,0 +1,28 @@ +alter table attention_items add column content_fingerprint text; +alter table attention_items add column alert_fingerprint text; +alter table attention_items add column activity_tier text; +alter table attention_items add column roster_epoch integer not null default 0; + +update attention_items +set content_fingerprint = fingerprint, alert_fingerprint = fingerprint +where content_fingerprint is null; + +create index if not exists idx_attention_items_user_machine_epoch + on attention_items(user_id, machine_key, roster_epoch); + +create index if not exists idx_attention_items_user_alertable + on attention_items(user_id, activity_tier, seen_at, dismissed_at); + +create table if not exists attention_alert_log ( + user_id text not null, + alert_fingerprint text not null, + device_id text not null, + delivered_at text not null, + primary key(user_id, alert_fingerprint, device_id) +); + +create index if not exists idx_attention_alert_log_delivered + on attention_alert_log(delivered_at); + +create index if not exists idx_attention_delivery_receipts_delivered + on attention_delivery_receipts(delivered_at); diff --git a/apps/push-relay/src/attention.ts b/apps/push-relay/src/attention.ts index af5bab0de..d94057c93 100644 --- a/apps/push-relay/src/attention.ts +++ b/apps/push-relay/src/attention.ts @@ -74,6 +74,9 @@ type ParsedAttentionItem = Record & { id: string; revision: number; fingerprint: string; + contentFingerprint: string; + alertFingerprint: string; + activityTier?: "signal" | "ambient" | "idle"; kind: "agent" | "pull_request"; eventKind: string; phase: string; @@ -96,6 +99,8 @@ const MAX_BODY_BYTES = 256 * 1024; const MAX_ATTENTION_ITEMS = 64; const MAX_ATTENTION_TOMBSTONES = 64; const MAX_ATTENTION_DEVICES = 32; +const MAX_ATTENTION_MACHINE_PREFERENCES = 64; +const MAX_ACCOUNT_ATTENTION_ITEMS = 2_000; const ATTENTION_DEVICE_LEASE_MS = 30 * 24 * 60 * 60 * 1_000; const MAX_NOTIFICATION_ATTEMPTS_PER_PUBLISH = 64; const MAX_ID_LENGTH = 256; @@ -110,6 +115,9 @@ const TOMBSTONE_RETENTION_MS = 24 * 60 * 60 * 1_000; const MAX_OWNERSHIP_EPOCH_FUTURE_MS = 5 * 60 * 1_000; const LIVE_ACTIVITY_START_CLAIM_TTL_MS = 30_000; const NOTIFICATION_DELIVERY_CLAIM_TTL_MS = 60_000; +const MAX_ALERT_AGE_MS = 15 * 60_000; +const ATTENTION_DELIVERY_RECEIPT_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000; +const ATTENTION_ALERT_LOG_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000; const EVENT_KINDS = new Set([ "agent_running", @@ -478,14 +486,16 @@ function attentionItemUpsertStatement( userId: string, machineKey: string, item: ParsedAttentionItem, + rosterEpoch: number, ): 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, + fingerprint, content_fingerprint, alert_fingerprint, activity_tier, + roster_epoch, event_kind, phase, payload_json, seen_at, dismissed_at, expires_at, updated_at ) - select ?, ?, ?, ?, revision, ?, ?, ?, ?, null, null, ?, ? + select ?, ?, ?, ?, revision, ?, ?, ?, ?, ?, ?, ?, ?, null, null, ?, ? from attention_revisions where user_id = ? and not exists ( @@ -500,15 +510,23 @@ function attentionItemUpsertStatement( source_revision = excluded.source_revision, account_revision = excluded.account_revision, fingerprint = excluded.fingerprint, + content_fingerprint = excluded.content_fingerprint, + alert_fingerprint = excluded.alert_fingerprint, + activity_tier = excluded.activity_tier, + roster_epoch = excluded.roster_epoch, 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 + when coalesce(attention_items.alert_fingerprint, attention_items.fingerprint) + = excluded.alert_fingerprint + then attention_items.seen_at else null end, dismissed_at = case - when attention_items.fingerprint = excluded.fingerprint then attention_items.dismissed_at + when coalesce(attention_items.alert_fingerprint, attention_items.fingerprint) + = excluded.alert_fingerprint + then attention_items.dismissed_at else null end, expires_at = excluded.expires_at, @@ -519,7 +537,11 @@ function attentionItemUpsertStatement( item.id, machineKey, item.revision, - item.fingerprint, + item.contentFingerprint, + item.contentFingerprint, + item.alertFingerprint, + item.activityTier ?? null, + rosterEpoch, item.eventKind, item.phase, JSON.stringify(item), @@ -600,13 +622,20 @@ async function commitAttentionMachineChanges( items: ParsedAttentionItem[]; tombstones: IncomingAttentionTombstone[]; sealCapacityTombstones: boolean; + rosterEpoch?: number; now: string; }, ): Promise { const statements: D1PreparedStatement[] = []; for (const item of args.items) { statements.push( - attentionItemUpsertStatement(env, args.userId, args.machineKey, item), + attentionItemUpsertStatement( + env, + args.userId, + args.machineKey, + item, + args.rosterEpoch ?? 0, + ), attentionItemTombstoneDeleteStatement(env, args.userId, item), ); } @@ -636,12 +665,115 @@ async function commitAttentionMachineChanges( return commitAttentionRevision(env, args.userId, statements, args.now); } -function mergedDevicePreferences( +async function commitActivityReconcileFinal( + env: AttentionRelayEnv, + args: { + userId: string; + machineKey: string; + rosterEpoch: number; + now: string; + }, +): Promise { + return commitAttentionRevision(env, args.userId, [ + env.DB.prepare(` + insert into attention_tombstones( + user_id, item_id, source_revision, account_revision, revivable, deleted_at + ) + select user_id, item_id, source_revision, + (select revision from attention_revisions where user_id = ?), 0, ? + from attention_items + where user_id = ? and machine_key = ? and roster_epoch < ? + on conflict(user_id, item_id) do update set + source_revision = excluded.source_revision, + account_revision = excluded.account_revision, + revivable = 0, + deleted_at = excluded.deleted_at + where excluded.source_revision >= attention_tombstones.source_revision + `).bind( + args.userId, + args.now, + args.userId, + args.machineKey, + args.rosterEpoch, + ), + env.DB.prepare(` + delete from attention_items + where user_id = ? and machine_key = ? and roster_epoch < ? + `).bind(args.userId, args.machineKey, args.rosterEpoch), + ], args.now); +} + +async function enforceActivityAccountItemCap( + env: AttentionRelayEnv, + userId: string, + now: string, +): Promise<{ itemsTruncated: boolean; revision: number | null }> { + const countRow = await env.DB.prepare(` + select count(*) as count + from attention_items + where user_id = ? + `).bind(userId).first<{ count: number }>(); + const overflow = Math.max( + 0, + Number(countRow?.count ?? 0) - MAX_ACCOUNT_ATTENTION_ITEMS, + ); + if (overflow === 0) return { itemsTruncated: false, revision: null }; + + const evictionEligibleCountRow = await env.DB.prepare(` + select count(*) as count + from attention_items + where user_id = ? + and (activity_tier = 'idle' or activity_tier is null) + `).bind(userId).first<{ count: number }>(); + const rowsToRemove = Math.min( + overflow, + Number(evictionEligibleCountRow?.count ?? 0), + ); + if (rowsToRemove === 0) return { itemsTruncated: true, revision: null }; + + const revision = await commitAttentionRevision(env, userId, [ + env.DB.prepare(` + insert into attention_tombstones( + user_id, item_id, source_revision, account_revision, revivable, deleted_at + ) + select user_id, item_id, source_revision, + (select revision from attention_revisions where user_id = ?), 0, ? + from attention_items + where user_id = ? + and (activity_tier = 'idle' or activity_tier is null) + order by case when activity_tier = 'idle' then 0 else 1 end, + updated_at asc + limit ? + on conflict(user_id, item_id) do update set + source_revision = excluded.source_revision, + account_revision = excluded.account_revision, + revivable = 0, + deleted_at = excluded.deleted_at + where excluded.source_revision >= attention_tombstones.source_revision + `).bind(userId, now, userId, rowsToRemove), + env.DB.prepare(` + delete from attention_items + where user_id = ? and item_id in ( + select item_id + from attention_items + where user_id = ? + and (activity_tier = 'idle' or activity_tier is null) + order by case when activity_tier = 'idle' then 0 else 1 end, + updated_at asc + limit ? + ) + `).bind(userId, userId, rowsToRemove), + ], now); + return { itemsTruncated: true, revision }; +} + +function resolveActivityDevicePreferences( device: AttentionDeviceRow, - accountPreferences: Record, - devicePreferences: Record, + preferences: Record, ): Record { const registered = readPreferences(device.preferences_json); + const accountPreferences = isRecord(preferences.account) ? preferences.account : {}; + const devicePreferences = isRecord(preferences.devices) ? preferences.devices : {}; const accountOverride = isRecord(devicePreferences[device.device_id]) ? devicePreferences[device.device_id] as Record : {}; @@ -653,6 +785,28 @@ function mergedDevicePreferences( return { ...registered, ...accountPreferences, ...accountOverride }; } +function resolveActivityDeliveryPreferences( + device: AttentionDeviceRow, + item: ParsedAttentionItem, + preferences: Record, +): Record { + const registered = readPreferences(device.preferences_json); + const account = isRecord(preferences.account) ? preferences.account : {}; + const projects = isRecord(preferences.projects) ? preferences.projects : {}; + const project = isRecord(projects[item.project.projectId]) + ? projects[item.project.projectId] as Record + : {}; + const machines = isRecord(preferences.machines) ? preferences.machines : {}; + const machine = isRecord(machines[item.machine.machineKey]) + ? machines[item.machine.machineKey] as Record + : {}; + const devices = isRecord(preferences.devices) ? preferences.devices : {}; + const explicitDevice = isRecord(devices[device.device_id]) + ? devices[device.device_id] as Record + : {}; + return { ...registered, ...account, ...project, ...machine, ...explicitDevice }; +} + function resolvedMutedSessionIds( device: AttentionDeviceRow, preferences: Record, @@ -843,9 +997,6 @@ async function deliverAttentionNotifications( 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 desktopVisibleItemIds = await recentDesktopAttentionItemIds(env, userId, nowMs); @@ -859,15 +1010,13 @@ async function deliverAttentionNotifications( 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; + if (item.activityTier && item.activityTier !== "signal") continue; + if (nowMs - Date.parse(item.updatedAt) > MAX_ALERT_AGE_MS) continue; const current = await env.DB .prepare(` - select source_revision, fingerprint, seen_at, dismissed_at + select source_revision, fingerprint, + coalesce(alert_fingerprint, fingerprint) as alert_fingerprint, + seen_at, dismissed_at from attention_items where user_id = ? and item_id = ? limit 1 @@ -876,6 +1025,7 @@ async function deliverAttentionNotifications( .first<{ source_revision: number; fingerprint: string; + alert_fingerprint: string; seen_at: string | null; dismissed_at: string | null; }>(); @@ -886,7 +1036,8 @@ async function deliverAttentionNotifications( if ( !current || Number(current.source_revision) !== item.revision - || current.fingerprint !== item.fingerprint + || current.fingerprint !== item.contentFingerprint + || current.alert_fingerprint !== item.alertFingerprint || current.seen_at || current.dismissed_at ) { @@ -909,11 +1060,20 @@ async function deliverAttentionNotifications( for (const device of devicesResult.results) { if (notificationAttempts >= MAX_NOTIFICATION_ATTEMPTS_PER_PUBLISH) return; if (!device.apns_token) continue; - const override = mergedDevicePreferences( + const override = resolveActivityDeliveryPreferences( device, - accountPreferences, - devicePreferences, + item, + preferences, ); + const eventPolicies = isRecord(override.eventPolicies) + ? override.eventPolicies + : {}; + const policy = typeof eventPolicies[item.eventKind] === "string" + ? eventPolicies[item.eventKind] + : DEFAULT_NOTIFY_EVENTS.has(item.eventKind) + ? "notify" + : "ambient"; + if (policy !== "notify") continue; const notificationsEnabled = preferenceBoolean( override, {}, @@ -930,13 +1090,27 @@ async function deliverAttentionNotifications( ) { continue; } - const receiptState = `alert:${item.fingerprint.slice(0, 48)}`; + const receiptState = `alert:${item.alertFingerprint.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 = ? + select 1 as found from ( + select 1 + from attention_delivery_receipts + where user_id = ? and item_id = ? and device_id = ? and state = ? + union all + select 1 + from attention_alert_log + where user_id = ? and alert_fingerprint = ? and device_id = ? + ) limit 1 - `).bind(userId, item.id, device.device_id, receiptState).first<{ found: number }>(); + `).bind( + userId, + item.id, + device.device_id, + receiptState, + userId, + item.alertFingerprint, + device.device_id, + ).first<{ found: number }>(); if (existing?.found) continue; const deliveryClaim = await claimAttentionNotificationDelivery(env, { userId, @@ -968,6 +1142,9 @@ async function deliverAttentionNotifications( ...(body ? { body } : {}), }, ...(soundsEnabled ? { sound: "default" } : {}), + // Wakes the app for a background snapshot refresh alongside the + // visible alert; foreground polling remains the guaranteed path. + "content-available": 1, "thread-id": item.id, "interruption-level": item.eventKind === "agent_needs_you" ? "time-sensitive" @@ -1004,6 +1181,17 @@ async function deliverAttentionNotifications( receiptState, new Date(nowMs).toISOString(), ), + env.DB.prepare(` + insert into attention_alert_log( + user_id, alert_fingerprint, device_id, delivered_at + ) values (?, ?, ?, ?) + on conflict(user_id, alert_fingerprint, device_id) do nothing + `).bind( + userId, + item.alertFingerprint, + device.device_id, + new Date(nowMs).toISOString(), + ), env.DB.prepare(` delete from attention_delivery_receipts where user_id = ? and item_id = ? and device_id = ? @@ -1311,7 +1499,6 @@ async function deliverAccountLiveActivity( ]); 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) { @@ -1319,11 +1506,7 @@ async function deliverAccountLiveActivity( // Account-wide ActivityKit delivery fails closed unless the current // registered device row is still owned by this exact account epoch. if (!Number.isSafeInteger(ownershipEpoch) || ownershipEpoch <= 0) continue; - const override = mergedDevicePreferences( - device, - accountPreferences, - devicePreferences, - ); + const override = resolveActivityDevicePreferences(device, preferences); const state = await env.DB.prepare(` select started, fingerprint from attention_activity_state @@ -1676,6 +1859,13 @@ function parseAttentionItem(value: unknown, machineKey: string): ParsedAttention const id = requiredString(value.id); const revision = Number(value.revision); const fingerprint = requiredString(value.fingerprint); + const contentFingerprint = value.contentFingerprint == null + ? fingerprint + : requiredString(value.contentFingerprint); + const alertFingerprint = value.alertFingerprint == null + ? fingerprint + : requiredString(value.alertFingerprint); + const activityTier = value.activityTier == null ? undefined : value.activityTier; const kind = value.kind; const eventKind = requiredString(value.eventKind, 64); const phase = requiredString(value.phase, 64); @@ -1684,12 +1874,21 @@ function parseAttentionItem(value: unknown, machineKey: string): ParsedAttention const privacyPreview = boundedText(value.privacyPreview, MAX_PREVIEW_LENGTH); const updatedAt = optionalIsoDate(value.updatedAt); const occurredAt = optionalIsoDate(value.occurredAt); + const statusSince = optionalIsoDate(value.statusSince); const expiresAt = optionalIsoDate(value.expiresAt); if ( !id || !Number.isSafeInteger(revision) || revision < 0 || !fingerprint + || !contentFingerprint + || !alertFingerprint + || ( + activityTier !== undefined + && activityTier !== "signal" + && activityTier !== "ambient" + && activityTier !== "idle" + ) || (kind !== "agent" && kind !== "pull_request") || !eventKind || !EVENT_KINDS.has(eventKind) @@ -1702,6 +1901,7 @@ function parseAttentionItem(value: unknown, machineKey: string): ParsedAttention || !privacyPreview || !updatedAt || !occurredAt + || statusSince === undefined || expiresAt === undefined || !isRecord(value.machine) || requiredString(value.machine.machineKey) !== machineKey @@ -1886,7 +2086,10 @@ function parseAttentionItem(value: unknown, machineKey: string): ParsedAttention contractVersion: 1, id, revision, - fingerprint, + fingerprint: contentFingerprint, + contentFingerprint, + alertFingerprint, + ...(activityTier ? { activityTier } : {}), kind, eventKind, phase, @@ -1904,7 +2107,8 @@ function parseAttentionItem(value: unknown, machineKey: string): ParsedAttention actions: actions as Array>, updatedAt, occurredAt, - expiresAt, + statusSince, + expiresAt: activityTier === "idle" ? null : expiresAt, seenAt: null, dismissedAt: null, machine: { @@ -2308,6 +2512,127 @@ async function linkMachineToAccount( ]); } +async function refreshActivityMachinePresence( + env: AttentionRelayEnv, + args: { + userId: string; + machineKey: string; + machineName: string; + now: string; + }, +): Promise { + 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( + args.machineKey, + args.userId, + args.machineName, + args.now, + args.now, + ).run(); +} + +async function activityItemsForMachine( + env: AttentionRelayEnv, + userId: string, + machineKey: string, + now: string, +): Promise { + const rows = await env.DB.prepare(` + select payload_json + from attention_items + where user_id = ? and machine_key = ? + and seen_at is null and dismissed_at is null + and (expires_at is null or expires_at > ?) + order by updated_at desc + limit ? + `).bind( + userId, + machineKey, + now, + MAX_ACCOUNT_ATTENTION_ITEMS, + ).all<{ payload_json: string }>(); + return rows.results.flatMap((row) => { + try { + return [JSON.parse(row.payload_json) as ParsedAttentionItem]; + } catch { + return []; + } + }); +} + +type ActivityPublishAcknowledgment = { + itemId: string; + seenAt: string | null; + dismissedAt: string | null; + sourceRevision: number; +}; + +async function activityPublishAcknowledgments( + env: AttentionRelayEnv, + args: { + userId: string; + machineKey: string; + requestItems: ParsedAttentionItem[]; + }, +): Promise { + type AckRow = { + item_id: string; + seen_at: string | null; + dismissed_at: string | null; + source_revision: number; + account_revision: number; + }; + const requestRows = args.requestItems.length > 0 + ? await env.DB.prepare(` + select item_id, seen_at, dismissed_at, source_revision, account_revision + from attention_items + where user_id = ? and machine_key = ? + and item_id in (${args.requestItems.map(() => "?").join(", ")}) + `).bind( + args.userId, + args.machineKey, + ...args.requestItems.map((item) => item.id), + ).all() + : { results: [] as AckRow[] }; + const recentRows = await env.DB.prepare(` + select item_id, seen_at, dismissed_at, source_revision, account_revision + from attention_items + where user_id = ? and machine_key = ? + and (seen_at is not null or dismissed_at is not null) + order by account_revision desc + limit 64 + `).bind(args.userId, args.machineKey).all(); + const requestById = new Map(requestRows.results.map((row) => [row.item_id, row])); + const acknowledgments = args.requestItems.map((item) => { + const row = requestById.get(item.id); + return { + itemId: item.id, + seenAt: row?.seen_at ?? null, + dismissedAt: row?.dismissed_at ?? null, + sourceRevision: Number(row?.source_revision ?? item.revision), + }; + }); + const includedIds = new Set(acknowledgments.map((ack) => ack.itemId)); + for (const row of recentRows.results) { + if (includedIds.has(row.item_id)) continue; + acknowledgments.push({ + itemId: row.item_id, + seenAt: row.seen_at, + dismissedAt: row.dismissed_at, + sourceRevision: Number(row.source_revision), + }); + } + return acknowledgments; +} + export async function handleAttentionMachinePublish( request: Request, env: AttentionRelayEnv, @@ -2327,12 +2652,40 @@ export async function handleAttentionMachinePublish( } if (!isRecord(payload)) return json({ ok: false, error: "invalid payload" }, { status: 400 }); const machineName = boundedText(payload.machineName, 120) ?? "ADE machine"; + const mode = payload.mode === "delta" + || payload.mode === "reconcile" + || payload.mode === "presence" + ? payload.mode + : null; + if (payload.mode != null && !mode) { + return json({ ok: false, error: "invalid publish mode" }, { status: 400 }); + } const fullSnapshot = payload.fullSnapshot === true; + const rosterEpoch = mode ? Number(payload.rosterEpoch) : 0; + if (mode && (!Number.isSafeInteger(rosterEpoch) || rosterEpoch <= 0)) { + return json({ ok: false, error: "invalid roster epoch" }, { status: 400 }); + } + if (mode === "reconcile") { + if ( + payload.page != null + && (!Number.isSafeInteger(Number(payload.page)) || Number(payload.page) < 0) + ) { + return json({ ok: false, error: "invalid reconcile page" }, { status: 400 }); + } + if (payload.final != null && typeof payload.final !== "boolean") { + return json({ ok: false, error: "invalid reconcile final flag" }, { status: 400 }); + } + } else if (mode && (payload.page != null || payload.final != null)) { + return json({ ok: false, error: "page and final require reconcile mode" }, { status: 400 }); + } 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 }); } + if (mode === "presence" && (rawItems.length > 0 || rawTombstones.length > 0)) { + return json({ ok: false, error: "presence cannot write items" }, { 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 }); @@ -2369,7 +2722,7 @@ export async function handleAttentionMachinePublish( source_revision: number; fingerprint: string; }> = []; - if (fullSnapshot) { + if (!mode && fullSnapshot) { const existing = await env.DB.prepare(` select item_id, source_revision, fingerprint from attention_items @@ -2391,6 +2744,42 @@ export async function handleAttentionMachinePublish( } const tombstones = [...tombstonesById.values()]; const firstItem = items.find((entry): entry is ParsedAttentionItem => entry !== null); + const now = new Date().toISOString(); + if (mode === "presence") { + await refreshActivityMachinePresence(env, { + userId: account.userId, + machineKey, + machineName, + now, + }); + const storedItems = await activityItemsForMachine( + env, + account.userId, + machineKey, + now, + ); + await deliverAttentionNotifications(env, account.userId, storedItems); + const [current, acks] = await Promise.all([ + env.DB + .prepare("select revision from attention_revisions where user_id = ? limit 1") + .bind(account.userId) + .first<{ revision: number }>(), + activityPublishAcknowledgments(env, { + userId: account.userId, + machineKey, + requestItems: [], + }), + ]); + return json({ + ok: true, + protocol: 2, + revision: Number(current?.revision ?? 0), + acks, + upserted: 0, + removed: 0, + unchanged: true, + }); + } await linkMachineToAccount( env, account.userId, @@ -2398,7 +2787,8 @@ export async function handleAttentionMachinePublish( firstItem?.machine.name ?? machineName, ); if ( - fullSnapshot + !mode + && fullSnapshot && attentionFullSnapshotUnchanged( existingMachineItems, items as ParsedAttentionItem[], @@ -2414,40 +2804,67 @@ export async function handleAttentionMachinePublish( items as ParsedAttentionItem[], ); await deliverAccountLiveActivity(env, account.userId); - const current = await env.DB - .prepare("select revision from attention_revisions where user_id = ? limit 1") - .bind(account.userId) - .first<{ revision: number }>(); + const [current, acks] = await Promise.all([ + env.DB + .prepare("select revision from attention_revisions where user_id = ? limit 1") + .bind(account.userId) + .first<{ revision: number }>(), + activityPublishAcknowledgments(env, { + userId: account.userId, + machineKey, + requestItems: items as ParsedAttentionItem[], + }), + ]); return json({ ok: true, + protocol: 2, revision: Number(current?.revision ?? 0), + acks, upserted: 0, removed: 0, unchanged: true, }); } - const now = new Date().toISOString(); - const accountRevision = await commitAttentionMachineChanges(env, { + let accountRevision = await commitAttentionMachineChanges(env, { userId: account.userId, machineKey, items: items as ParsedAttentionItem[], tombstones, sealCapacityTombstones: - fullSnapshot && rawItems.length < MAX_ATTENTION_ITEMS, + !mode && fullSnapshot && rawItems.length < MAX_ATTENTION_ITEMS, + rosterEpoch, now, }); + if (mode === "reconcile" && payload.final === true) { + accountRevision = await commitActivityReconcileFinal(env, { + userId: account.userId, + machineKey, + rosterEpoch, + now, + }); + } + const cap = await enforceActivityAccountItemCap(env, account.userId, now); + if (cap.revision !== null) accountRevision = cap.revision; await deliverAttentionNotifications( env, account.userId, items as ParsedAttentionItem[], ); await deliverAccountLiveActivity(env, account.userId); + const acks = await activityPublishAcknowledgments(env, { + userId: account.userId, + machineKey, + requestItems: items as ParsedAttentionItem[], + }); return json({ ok: true, + protocol: 2, revision: accountRevision, + acks, upserted: items.length, removed: tombstones.length, + ...(cap.itemsTruncated ? { itemsTruncated: true } : {}), }); } @@ -2506,6 +2923,13 @@ async function handleSnapshot( const responseTombstoneRows = tombstoneRows.results.filter( (row) => row.account_revision <= responseRevision, ); + const accountItemCountRow = await env.DB.prepare(` + select count(*) as count + from attention_items + where user_id = ? + `).bind(userId).first<{ count: number }>(); + const itemsTruncated = Number(accountItemCountRow?.count ?? 0) + > MAX_ACCOUNT_ATTENTION_ITEMS; const links = await env.DB.prepare(` select machine_key, machine_name, last_seen_at from attention_machine_links @@ -2555,6 +2979,7 @@ async function handleSnapshot( lastSeenAt: row.last_seen_at, })), items, + itemsTruncated, tombstones: responseTombstoneRows.map((row) => ({ id: row.item_id, revision: row.source_revision, @@ -2577,6 +3002,12 @@ async function handleAcknowledgment( if (!isRecord(payload) || !Array.isArray(payload.itemIds) || payload.itemIds.length > 64) { return json({ ok: false, error: "invalid acknowledgment" }, { status: 400 }); } + if ( + Object.prototype.hasOwnProperty.call(payload, "expectedAccountOwnerId") + && payload.expectedAccountOwnerId !== userId + ) { + return json({ ok: false, error: "account owner changed" }, { status: 409 }); + } const itemIds = payload.itemIds.map((value) => requiredString(value)); if (itemIds.some((value) => value === null)) { return json({ ok: false, error: "invalid item id" }, { status: 400 }); @@ -2586,6 +3017,31 @@ async function handleAcknowledgment( if (!seenAt || dismissedAt === undefined) { return json({ ok: false, error: "invalid timestamp" }, { status: 400 }); } + const hasSourceRevisions = Object.prototype.hasOwnProperty.call( + payload, + "sourceRevisions", + ); + if (hasSourceRevisions && !isRecord(payload.sourceRevisions)) { + return json({ ok: false, error: "invalid source revisions" }, { status: 400 }); + } + const sourceRevisions = hasSourceRevisions + ? payload.sourceRevisions as Record + : {}; + if ( + hasSourceRevisions + && ( + Object.keys(sourceRevisions).length > 64 + || Object.keys(sourceRevisions).some((itemId) => !(itemIds as string[]).includes(itemId)) + || (itemIds as string[]).some((itemId) => { + const revision = sourceRevisions[itemId]; + return typeof revision !== "number" + || !Number.isSafeInteger(revision) + || revision < 0; + }) + ) + ) { + return json({ ok: false, error: "invalid source revisions" }, { status: 400 }); + } if (itemIds.length === 0) { const current = await env.DB .prepare("select revision from attention_revisions where user_id = ? limit 1") @@ -2595,6 +3051,8 @@ async function handleAcknowledgment( ok: true, revision: Number(current?.revision ?? 0), itemIds, + applied: [], + stale: [], }); } const statements = (itemIds as string[]).map((itemId) => @@ -2615,6 +3073,8 @@ async function handleAcknowledgment( where user_id = ? ) where user_id = ? and item_id = ? + ${hasSourceRevisions ? "and source_revision <= ?" : ""} + returning item_id `).bind( seenAt, seenAt, @@ -2624,11 +3084,30 @@ async function handleAcknowledgment( userId, userId, itemId, + ...(hasSourceRevisions ? [Number(sourceRevisions[itemId])] : []), ), ); - const revision = await commitAttentionRevision(env, userId, statements); + const [revisionResult, ...mutationResults] = await env.DB.batch<{ + revision?: number; + item_id?: string; + }>([ + attentionRevisionBumpStatement(env, userId, new Date().toISOString()), + ...statements, + ]); + if (!revisionResult?.success || mutationResults.some((result) => !result.success)) { + throw new Error("attention acknowledgment transaction failed"); + } + const revision = Number(revisionResult.results[0]?.revision); + if (!Number.isSafeInteger(revision) || revision < 1) { + throw new Error("attention acknowledgment transaction did not return a revision"); + } + const applied = (itemIds as string[]).filter( + (_itemId, index) => mutationResults[index]?.results.length === 1, + ); + const appliedIds = new Set(applied); + const stale = (itemIds as string[]).filter((itemId) => !appliedIds.has(itemId)); await deliverAccountLiveActivity(env, userId); - return json({ ok: true, revision, itemIds }); + return json({ ok: true, revision, itemIds, applied, stale }); } async function handlePresence( @@ -2707,11 +3186,19 @@ async function handlePreferences( } if (!isRecord(payload)) return json({ ok: false, error: "invalid preferences" }, { status: 400 }); const preservesDevices = !Object.prototype.hasOwnProperty.call(payload, "devices"); + const preservesMachines = !Object.prototype.hasOwnProperty.call(payload, "machines"); + const preservesProjects = !Object.prototype.hasOwnProperty.call(payload, "projects"); const result = await mutateAttentionPreferences(env, userId, (current) => ({ ...payload, ...(preservesDevices && isRecord(current.devices) ? { devices: current.devices } : {}), + ...(preservesMachines && isRecord(current.machines) + ? { machines: current.machines } + : {}), + ...(preservesProjects && isRecord(current.projects) + ? { projects: current.projects } + : {}), })); if ("response" in result) return result.response; await deliverAccountLiveActivity(env, userId); @@ -2745,6 +3232,20 @@ async function mutateAttentionPreferences( } } const preferences = mutate(current); + if ( + preferences.machines != null + && ( + !isRecord(preferences.machines) + || Object.keys(preferences.machines).length > MAX_ATTENTION_MACHINE_PREFERENCES + ) + ) { + return { + response: json( + { ok: false, error: "invalid machine preferences" }, + { status: 400 }, + ), + }; + } const serialized = JSON.stringify(preferences); if (serialized.length > 32_000) { return { @@ -2821,6 +3322,47 @@ async function handleDevicePreferences( }); } +async function handleActivityMachinePreferences( + request: Request, + env: AttentionRelayEnv, + userId: string, + machineKey: string, +): Promise { + if (!requiredString(machineKey, 128)) { + return json({ ok: false, error: "invalid machine key" }, { 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 machine preferences" }, { status: 400 }); + } + const result = await mutateAttentionPreferences(env, userId, (current) => { + const machines = isRecord(current.machines) ? current.machines : {}; + const machine = isRecord(machines[machineKey]) ? machines[machineKey] : {}; + return { + ...current, + machines: { + ...machines, + [machineKey]: { + ...machine, + ...payload, + }, + }, + }; + }); + if ("response" in result) return result.response; + await deliverAccountLiveActivity(env, userId); + return json({ + ok: true, + preferences: result.preferences, + updatedAt: result.updatedAt, + }); +} + async function deleteAttentionDeviceOwnership( env: AttentionRelayEnv, userId: string, @@ -3286,6 +3828,19 @@ async function handleAuthorizedAttentionAccountRequest( decodeURIComponent(route[2] ?? ""), ); } + if ( + route.length === 3 + && route[0] === "preferences" + && route[1] === "machines" + && request.method === "PATCH" + ) { + return await handleActivityMachinePreferences( + request, + env, + userId, + decodeURIComponent(route[2] ?? ""), + ); + } if ( route.length === 2 && route[0] === "devices" @@ -3336,6 +3891,12 @@ 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 receiptCutoff = new Date( + now.getTime() - ATTENTION_DELIVERY_RECEIPT_RETENTION_MS, + ).toISOString(); + const alertLogCutoff = new Date( + now.getTime() - ATTENTION_ALERT_LOG_RETENTION_MS, + ).toISOString(); const expiredDevices = await env.DB.prepare(` select user_id, device_id from attention_devices @@ -3348,17 +3909,12 @@ export async function pruneAttentionState(env: AttentionRelayEnv): Promise 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()), + where delivered_at <= ? + `).bind(receiptCutoff), + env.DB.prepare(` + delete from attention_alert_log + where delivered_at <= ? + `).bind(alertLogCutoff), env.DB.prepare("delete from attention_items where expires_at is not null and expires_at <= ?") .bind(now.toISOString()), ]), @@ -3374,11 +3930,13 @@ export async function pruneAttentionState(env: AttentionRelayEnv): Promise /** Pure contract helpers exposed only so relay tests can cover trust boundaries. */ export const attentionTestInternals = Object.freeze({ activityPullRequest, + activityPublishAcknowledgments, activityRun, attentionAlertRoutingPayload, attentionFullSnapshotUnchanged, attentionTombstoneBlocksItem, commitAttentionMachineChanges, + commitActivityReconcileFinal, deepLinkForItem, deliverAccountLiveActivity, deliverAttentionNotifications, @@ -3391,6 +3949,9 @@ export const attentionTestInternals = Object.freeze({ normalizedSnapshotCursor, parseAttentionItem, privacyPreservingActivityContentState, + refreshActivityMachinePresence, + resolveActivityDeliveryPreferences, sealCapacityTombstones, upsertAttentionTombstone, + MAX_ALERT_AGE_MS, }); diff --git a/apps/push-relay/test/attention.test.ts b/apps/push-relay/test/attention.test.ts index 9ecb2317d..11bc2beaa 100644 --- a/apps/push-relay/test/attention.test.ts +++ b/apps/push-relay/test/attention.test.ts @@ -208,6 +208,7 @@ class SqliteD1Database { "../migrations/0002_rate_and_budget.sql", "../migrations/0003_account_attention.sql", "../migrations/0004_device_registration_generation.sql", + "../migrations/0005_activity_feed.sql", ]) { this.native.exec(readFileSync(new URL(migration, import.meta.url), "utf8")); } @@ -482,12 +483,69 @@ function validAgentItem(): Record { ], occurredAt: "2026-07-28T08:00:00.000Z", updatedAt: "2026-07-28T08:00:05.000Z", + statusSince: "2026-07-28T08:00:00.000Z", // Keep the shared fixture live independent of the wall clock. Tests that // exercise expiry override this field explicitly. expiresAt: "2099-07-29T08:00:05.000Z", }; } +async function publishActivityForTest( + env: AttentionRelayEnv, + authorization: Awaited>, + payload: Record, +): Promise { + const body = new TextEncoder().encode(JSON.stringify(payload)).buffer as ArrayBuffer; + return await handleAttentionMachinePublish( + new Request("https://push.example/machines/activity/attention", { + method: "POST", + headers: { authorization: `Bearer ${authorization.token}` }, + }), + env, + MACHINE_KEY, + body, + ); +} + +function activityAgentItem( + args: { + sessionId: string; + itemId: string | null; + revision: number; + contentFingerprint: string; + alertFingerprint: string; + activityTier?: "signal" | "ambient" | "idle"; + updatedAt?: string; + expiresAt?: string | null; + preview?: string; + eventKind?: "agent_running" | "agent_needs_you"; + phase?: "running" | "needs_you" | "stale"; + statusSince?: string; + }, +): Record { + return { + ...validAgentItem(), + id: `agent:${MACHINE_KEY}:${args.sessionId}`, + revision: args.revision, + fingerprint: args.contentFingerprint, + contentFingerprint: args.contentFingerprint, + alertFingerprint: args.alertFingerprint, + eventKind: args.eventKind ?? "agent_needs_you", + phase: args.phase ?? "needs_you", + ...(args.activityTier ? { activityTier: args.activityTier } : {}), + preview: args.preview ?? "The database migration is ready for review.", + updatedAt: args.updatedAt ?? "2026-07-28T08:00:05.000Z", + statusSince: args.statusSince ?? "2026-07-28T08:00:00.000Z", + ...(args.expiresAt !== undefined ? { expiresAt: args.expiresAt } : {}), + destination: { + kind: "session", + sessionId: args.sessionId, + itemId: args.itemId, + eventId: `event-${args.itemId}`, + }, + }; +} + describe("account Attention contract", () => { it("resolves muted sessions device override then account then registration fallback", () => { const device = { @@ -541,6 +599,7 @@ describe("account Attention contract", () => { sessionId: "session-1", itemId: "approval-1", }, + statusSince: "2026-07-28T08:00:00.000Z", planProgress: { completed: 2, total: 3, @@ -560,6 +619,10 @@ describe("account Attention contract", () => { const invalidProgress = validAgentItem(); invalidProgress.planProgress = { completed: 4, total: 3, current: "Impossible" }; expect(attentionTestInternals.parseAttentionItem(invalidProgress, MACHINE_KEY)).toBeNull(); + + const invalidStatusSince = validAgentItem(); + invalidStatusSince.statusSince = "not-a-date"; + expect(attentionTestInternals.parseAttentionItem(invalidStatusSince, MACHINE_KEY)).toBeNull(); }); it("clamps desktop-first escalation preferences to a safe relay range", () => { @@ -587,113 +650,1293 @@ describe("account Attention contract", () => { ).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`, + 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("atomically preserves concurrent account and per-device preference updates", async () => { + const database = new SqliteD1Database(); + try { + database.native.prepare(` + insert into attention_preferences(user_id, payload_json, updated_at) + values ('account-a', ?, '2026-07-28T08:00:00.000Z') + `).run(JSON.stringify({ + account: { notificationsEnabled: true }, + devices: { + "phone-a": { + celebrationsEnabled: false, + }, + }, + projects: { + "project-a": { + hideDetails: true, + }, + }, + machines: { + [MACHINE_KEY]: { + notificationsEnabled: false, + }, + }, + })); + + const [phoneAResponse, phoneBResponse, accountResponse] = await Promise.all([ + accountRoute( + database, + "account-a", + "PATCH", + "/attention/account/preferences/devices/phone-a", + { + notificationsEnabled: false, + mutedSessionIds: ["session-a"], + }, + ), + accountRoute( + database, + "account-a", + "PATCH", + "/attention/account/preferences/devices/phone-b", + { + liveActivitiesEnabled: true, + }, + ), + accountRoute( + database, + "account-a", + "PUT", + "/attention/account/preferences", + { + account: { + notificationsEnabled: true, + hideDetails: true, + }, + }, + ), + ]); + + expect(phoneAResponse.status).toBe(200); + expect(phoneBResponse.status).toBe(200); + expect(accountResponse.status).toBe(200); + const stored = row<{ payload_json: string }>( + database, + "select payload_json from attention_preferences where user_id = 'account-a'", + ); + const preferences = JSON.parse(stored?.payload_json ?? "{}") as Record; + expect(preferences).toMatchObject({ + account: { + notificationsEnabled: true, + hideDetails: true, + }, + projects: { + "project-a": { + hideDetails: true, + }, + }, + machines: { + [MACHINE_KEY]: { + notificationsEnabled: false, + }, + }, + devices: { + "phone-a": { + celebrationsEnabled: false, + notificationsEnabled: false, + mutedSessionIds: ["session-a"], + }, + "phone-b": { + liveActivitiesEnabled: true, + }, + }, + }); + } finally { + database.close(); + } + }); + + it("preserves dismissal and suppresses re-alert when only content churns", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T08:01:00.000Z")); + const database = new SqliteD1Database(); + const authorization = await machinePublishAuthorization(); + let notificationSends = 0; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url === authorization.jwksUrl) return Response.json(authorization.jwks); + if (url.startsWith("https://api.sandbox.push.apple.com/")) { + notificationSends += 1; + return new Response(null, { + status: 200, + headers: { "apns-id": `content-churn-${notificationSends}` }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + })); + try { + insertAttentionDevice(database, { + userId: authorization.userId, + deviceId: "phone-content-churn", + apnsToken: "ab".repeat(32), + }); + const env = makeAttentionEnv(database, { + CLERK_JWKS_URL: authorization.jwksUrl, + CLERK_ISSUER: authorization.issuer, + CLERK_OAUTH_CLIENT_ID: "attention-test-client", + APNS_KEY: await generateTestP8(), + APNS_KEY_ID: "CHURNKEY12", + APNS_TEAM_ID: "CHURNTEAM1", + }); + const firstItem = activityAgentItem({ + sessionId: "session-churn", + itemId: "approval-stable", + revision: 7, + contentFingerprint: "content-before", + alertFingerprint: "alert-stable", + activityTier: "signal", + updatedAt: "2026-07-28T08:00:30.000Z", + }); + const first = await publishActivityForTest(env, authorization, { + machineName: "Studio", + mode: "delta", + rosterEpoch: 1, + items: [firstItem], + tombstones: [], + }); + expect(first.status).toBe(200); + expect(await first.json()).toMatchObject({ + protocol: 2, + acks: [{ + itemId: `agent:${MACHINE_KEY}:session-churn`, + seenAt: null, + dismissedAt: null, + sourceRevision: 7, + }], + }); + expect(notificationSends).toBe(1); + + const dismissedAt = "2026-07-28T08:00:40.000Z"; + const acknowledgment = await accountRoute( + database, + authorization.userId, + "POST", + "/attention/account/ack", + { + itemIds: [`agent:${MACHINE_KEY}:session-churn`], + sourceRevisions: { [`agent:${MACHINE_KEY}:session-churn`]: 7 }, + expectedAccountOwnerId: authorization.userId, + seenAt: dismissedAt, + dismissedAt, + }, + ); + expect(await acknowledgment.json()).toMatchObject({ + applied: [`agent:${MACHINE_KEY}:session-churn`], + stale: [], + }); + + const churned = activityAgentItem({ + sessionId: "session-churn", + itemId: "approval-stable", + revision: 8, + contentFingerprint: "content-after-preview-churn", + alertFingerprint: "alert-stable", + activityTier: "signal", + preview: "Elapsed 17.2s · processed 42 files.", + updatedAt: "2026-07-28T08:00:50.000Z", + }); + const second = await publishActivityForTest(env, authorization, { + machineName: "Studio", + mode: "delta", + rosterEpoch: 1, + items: [churned], + tombstones: [], + }); + expect(second.status).toBe(200); + expect(await second.json()).toMatchObject({ + protocol: 2, + acks: [{ + itemId: `agent:${MACHINE_KEY}:session-churn`, + dismissedAt, + sourceRevision: 8, + }], + }); + expect(row(database, ` + select content_fingerprint, alert_fingerprint, dismissed_at + from attention_items + where user_id = ? and item_id = ? + `, authorization.userId, `agent:${MACHINE_KEY}:session-churn`)).toEqual({ + content_fingerprint: "content-after-preview-churn", + alert_fingerprint: "alert-stable", + dismissed_at: dismissedAt, + }); + expect(notificationSends).toBe(1); + } finally { + database.close(); + } + }); + + it("resets dismissal and sends once for a new destination item identity", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T08:01:00.000Z")); + const database = new SqliteD1Database(); + const authorization = await machinePublishAuthorization(); + let notificationSends = 0; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url === authorization.jwksUrl) return Response.json(authorization.jwks); + if (url.startsWith("https://api.sandbox.push.apple.com/")) { + notificationSends += 1; + return new Response(null, { + status: 200, + headers: { "apns-id": `new-destination-${notificationSends}` }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + })); + try { + insertAttentionDevice(database, { + userId: authorization.userId, + deviceId: "phone-new-destination", + apnsToken: "cd".repeat(32), + }); + const env = makeAttentionEnv(database, { + CLERK_JWKS_URL: authorization.jwksUrl, + CLERK_ISSUER: authorization.issuer, + CLERK_OAUTH_CLIENT_ID: "attention-test-client", + APNS_KEY: await generateTestP8(), + APNS_KEY_ID: "DESTKEY123", + APNS_TEAM_ID: "DESTTEAM12", + }); + const itemId = `agent:${MACHINE_KEY}:session-new-destination`; + expect((await publishActivityForTest(env, authorization, { + machineName: "Studio", + mode: "delta", + rosterEpoch: 1, + items: [activityAgentItem({ + sessionId: "session-new-destination", + itemId: "question-1", + revision: 7, + contentFingerprint: "destination-content-1", + alertFingerprint: "destination-alert-1", + activityTier: "signal", + updatedAt: "2026-07-28T08:00:30.000Z", + })], + tombstones: [], + })).status).toBe(200); + expect(notificationSends).toBe(1); + expect((await accountRoute( + database, + authorization.userId, + "POST", + "/attention/account/ack", + { + itemIds: [itemId], + sourceRevisions: { [itemId]: 7 }, + expectedAccountOwnerId: authorization.userId, + seenAt: "2026-07-28T08:00:40.000Z", + dismissedAt: "2026-07-28T08:00:40.000Z", + }, + )).status).toBe(200); + + const response = await publishActivityForTest(env, authorization, { + machineName: "Studio", + mode: "delta", + rosterEpoch: 1, + items: [activityAgentItem({ + sessionId: "session-new-destination", + itemId: "question-2", + revision: 8, + contentFingerprint: "destination-content-2", + alertFingerprint: "destination-alert-2", + activityTier: "signal", + updatedAt: "2026-07-28T08:00:50.000Z", + })], + tombstones: [], + }); + expect(response.status).toBe(200); + expect(row(database, ` + select seen_at, dismissed_at, alert_fingerprint + from attention_items + where user_id = ? and item_id = ? + `, authorization.userId, itemId)).toEqual({ + seen_at: null, + dismissed_at: null, + alert_fingerprint: "destination-alert-2", + }); + expect(notificationSends).toBe(2); + } finally { + database.close(); + } + }); + + it("alerts twice on needs-you re-entry and round-trips statusSince", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T08:01:00.000Z")); + const database = new SqliteD1Database(); + const authorization = await machinePublishAuthorization(); + let notificationSends = 0; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url === authorization.jwksUrl) return Response.json(authorization.jwks); + if (url.startsWith("https://api.sandbox.push.apple.com/")) { + notificationSends += 1; + return new Response(null, { + status: 200, + headers: { "apns-id": `question-reentry-${notificationSends}` }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + })); + const env = makeAttentionEnv(database, { + CLERK_JWKS_URL: authorization.jwksUrl, + CLERK_ISSUER: authorization.issuer, + CLERK_OAUTH_CLIENT_ID: "attention-test-client", + APNS_KEY: await generateTestP8(), + APNS_KEY_ID: "REENTRY123", + APNS_TEAM_ID: "REENTRY12", + }); + const publish = (item: Record) => + publishActivityForTest(env, authorization, { + machineName: "Studio", + mode: "delta", + rosterEpoch: 1, + items: [item], + tombstones: [], + }); + try { + insertAttentionDevice(database, { + userId: authorization.userId, + deviceId: "phone-question-reentry", + apnsToken: "ab".repeat(32), + }); + expect((await publish(activityAgentItem({ + sessionId: "question-reentry", + itemId: null, + revision: 7, + contentFingerprint: "question-content-1", + alertFingerprint: "question-needs-you-1", + activityTier: "signal", + updatedAt: "2026-07-28T08:00:20.000Z", + statusSince: "2026-07-28T08:00:10.000Z", + }))).status).toBe(200); + expect((await publish(activityAgentItem({ + sessionId: "question-reentry", + itemId: null, + revision: 8, + contentFingerprint: "question-content-running", + alertFingerprint: "question-running", + activityTier: "ambient", + eventKind: "agent_running", + phase: "running", + updatedAt: "2026-07-28T08:00:30.000Z", + statusSince: "2026-07-28T08:00:30.000Z", + }))).status).toBe(200); + expect((await publish(activityAgentItem({ + sessionId: "question-reentry", + itemId: null, + revision: 9, + contentFingerprint: "question-content-2", + alertFingerprint: "question-needs-you-2", + activityTier: "signal", + updatedAt: "2026-07-28T08:00:40.000Z", + statusSince: "2026-07-28T08:00:40.000Z", + }))).status).toBe(200); + + const snapshot = await (await accountRoute( + database, + authorization.userId, + "GET", + "/attention/account/snapshot?since=0", + )).json() as { + items: Array<{ statusSince?: string | null }>; + }; + expect(notificationSends).toBe(2); + expect(rows(database, ` + select alert_fingerprint from attention_alert_log + where user_id = ? order by delivered_at asc + `, authorization.userId)).toEqual([ + { alert_fingerprint: "question-needs-you-1" }, + { alert_fingerprint: "question-needs-you-2" }, + ]); + expect(snapshot.items[0]?.statusSince).toBe("2026-07-28T08:00:40.000Z"); + } finally { + database.close(); + } + }); + + it("accepts a roster fallback clamped to the live source revision", async () => { + const database = new SqliteD1Database(); + const authorization = await machinePublishAuthorization(); + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url === authorization.jwksUrl) return Response.json(authorization.jwks); + throw new Error(`Unexpected fetch: ${url}`); + })); + const env = makeAttentionEnv(database, { + CLERK_JWKS_URL: authorization.jwksUrl, + CLERK_ISSUER: authorization.issuer, + CLERK_OAUTH_CLIENT_ID: "attention-test-client", + }); + const publish = (item: Record) => + publishActivityForTest(env, authorization, { + machineName: "Studio", + mode: "delta", + rosterEpoch: 1, + items: [item], + tombstones: [], + }); + try { + const liveRevision = Date.parse("2026-07-28T08:00:30.000Z"); + expect((await publish(activityAgentItem({ + sessionId: "live-to-roster", + itemId: null, + revision: liveRevision, + contentFingerprint: "live-content", + alertFingerprint: "live-alert", + activityTier: "ambient", + eventKind: "agent_running", + phase: "running", + }))).status).toBe(200); + + const rosterResponse = await publish(activityAgentItem({ + sessionId: "live-to-roster", + itemId: null, + revision: liveRevision, + contentFingerprint: "roster-content", + alertFingerprint: "roster-alert", + activityTier: "idle", + eventKind: "agent_running", + phase: "stale", + updatedAt: "2026-07-01T08:00:00.000Z", + statusSince: "2026-07-01T08:00:00.000Z", + expiresAt: null, + })); + + expect(rosterResponse.status).toBe(200); + expect(await rosterResponse.json()).toMatchObject({ protocol: 2, upserted: 1 }); + expect(row(database, ` + select source_revision, phase, content_fingerprint + from attention_items where user_id = ? and item_id = ? + `, authorization.userId, `agent:${MACHINE_KEY}:live-to-roster`)).toEqual({ + source_revision: liveRevision, + phase: "stale", + content_fingerprint: "roster-content", + }); + } finally { + database.close(); + } + }); + + it("tombstones only rows absent from a completed paged reconcile epoch", async () => { + const database = new SqliteD1Database(); + const authorization = await machinePublishAuthorization(); + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url === authorization.jwksUrl) return Response.json(authorization.jwks); + throw new Error(`Unexpected fetch: ${url}`); + })); + const env = makeAttentionEnv(database, { + CLERK_JWKS_URL: authorization.jwksUrl, + CLERK_ISSUER: authorization.issuer, + CLERK_OAUTH_CLIENT_ID: "attention-test-client", + }); + const item = (sessionId: string) => activityAgentItem({ + sessionId, + itemId: `approval-${sessionId}`, + revision: 7, + contentFingerprint: `content-${sessionId}`, + alertFingerprint: `alert-${sessionId}`, + activityTier: "idle", + expiresAt: null, + }); + try { + expect((await publishActivityForTest(env, authorization, { + machineName: "Studio", + mode: "reconcile", + rosterEpoch: 10, + page: 0, + final: false, + items: [item("roster-1"), item("roster-2")], + tombstones: [], + })).status).toBe(200); + expect((await publishActivityForTest(env, authorization, { + machineName: "Studio", + mode: "reconcile", + rosterEpoch: 10, + page: 1, + final: true, + items: [item("roster-3")], + tombstones: [], + })).status).toBe(200); + expect(rows(database, ` + select item_id, roster_epoch + from attention_items + where user_id = ? + order by item_id + `, authorization.userId)).toEqual([ + { item_id: `agent:${MACHINE_KEY}:roster-1`, roster_epoch: 10 }, + { item_id: `agent:${MACHINE_KEY}:roster-2`, roster_epoch: 10 }, + { item_id: `agent:${MACHINE_KEY}:roster-3`, roster_epoch: 10 }, + ]); + + expect((await publishActivityForTest(env, authorization, { + machineName: "Studio", + mode: "reconcile", + rosterEpoch: 11, + page: 0, + final: false, + items: [item("roster-3")], + tombstones: [], + })).status).toBe(200); + expect((await publishActivityForTest(env, authorization, { + machineName: "Studio", + mode: "reconcile", + rosterEpoch: 11, + page: 1, + final: true, + items: [item("roster-1")], + tombstones: [], + })).status).toBe(200); + + expect(rows(database, ` + select item_id, roster_epoch + from attention_items + where user_id = ? + order by item_id + `, authorization.userId)).toEqual([ + { item_id: `agent:${MACHINE_KEY}:roster-1`, roster_epoch: 11 }, + { item_id: `agent:${MACHINE_KEY}:roster-3`, roster_epoch: 11 }, + ]); + expect(rows(database, ` + select item_id, revivable + from attention_tombstones + where user_id = ? + `, authorization.userId)).toEqual([{ + item_id: `agent:${MACHINE_KEY}:roster-2`, + revivable: 0, + }]); + } finally { + database.close(); + } + }); + + it("alerts only fresh signal-tier items", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T08:20:00.000Z")); + const database = new SqliteD1Database(); + const parse = (raw: Record) => { + const parsed = attentionTestInternals.parseAttentionItem(raw, MACHINE_KEY); + expect(parsed, "activity item must parse").not.toBeNull(); + if (!parsed) throw new Error("activity item did not parse"); + return parsed; + }; + const idle = parse(activityAgentItem({ + sessionId: "idle-tier", + itemId: "idle-tier", + revision: 1, + contentFingerprint: "idle-content", + alertFingerprint: "idle-alert", + activityTier: "idle", + updatedAt: "2026-07-28T08:19:00.000Z", + })); + const ambient = parse(activityAgentItem({ + sessionId: "ambient-tier", + itemId: "ambient-tier", + revision: 1, + contentFingerprint: "ambient-content", + alertFingerprint: "ambient-alert", + activityTier: "ambient", + updatedAt: "2026-07-28T08:19:00.000Z", + })); + const staleRoster = parse(activityAgentItem({ + sessionId: "stale-roster-signal", + itemId: null, + revision: 1, + contentFingerprint: "stale-content", + alertFingerprint: "stale-alert", + activityTier: "signal", + updatedAt: "2026-07-28T08:04:59.999Z", + })); + const fresh = parse(activityAgentItem({ + sessionId: "fresh-signal", + itemId: "fresh-signal", + revision: 1, + contentFingerprint: "fresh-content", + alertFingerprint: "fresh-alert", + activityTier: "signal", + updatedAt: "2026-07-28T08:19:00.000Z", + })); + const sendPush = vi.fn(async () => ({ + ok: true, + status: 200, + apnsId: "fresh-signal-only", + reason: null, + tokenInvalid: false, + })); + try { + insertAttentionDevice(database, { + userId: "account-a", + deviceId: "phone-tier-gates", + apnsToken: "ab".repeat(32), + }); + await attentionTestInternals.commitAttentionMachineChanges( + makeAttentionEnv(database), + { + userId: "account-a", + machineKey: MACHINE_KEY, + items: [idle, ambient, staleRoster, fresh], + tombstones: [], + sealCapacityTombstones: false, + rosterEpoch: 1, + now: "2026-07-28T08:20:00.000Z", + }, + ); + await attentionTestInternals.deliverAttentionNotifications( + makeAttentionEnv(database, { + APNS_KEY: "test-key", + APNS_KEY_ID: "TESTKEY123", + APNS_TEAM_ID: "TESTTEAM12", + }), + "account-a", + [idle, ambient, staleRoster, fresh], + sendPush, + ); + expect(sendPush).toHaveBeenCalledTimes(1); + expect(rows(database, ` + select alert_fingerprint + from attention_alert_log + where user_id = 'account-a' + `)).toEqual([{ alert_fingerprint: "fresh-alert" }]); + } finally { + database.close(); + } + }); + + it("keeps machine-muted items in snapshots while device scope wins other fields", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T08:01:00.000Z")); + const database = new SqliteD1Database(); + const parsed = attentionTestInternals.parseAttentionItem(activityAgentItem({ + sessionId: "machine-muted", + itemId: "machine-muted", + revision: 1, + contentFingerprint: "machine-muted-content", + alertFingerprint: "machine-muted-alert", + activityTier: "signal", + updatedAt: "2026-07-28T08:00:30.000Z", + }), MACHINE_KEY); + expect(parsed, "machine-muted item must parse").not.toBeNull(); + if (!parsed) throw new Error("machine-muted item did not parse"); + const sendPush = vi.fn(async () => ({ + ok: true, + status: 200, + apnsId: "should-not-send", + reason: null, + tokenInvalid: false, + })); + try { + insertAttentionDevice(database, { + userId: "account-a", + deviceId: "phone-machine-muted", + apnsToken: "cd".repeat(32), + preferences: { soundsEnabled: false }, + }); + await attentionTestInternals.commitAttentionMachineChanges( + makeAttentionEnv(database), + { + userId: "account-a", + machineKey: MACHINE_KEY, + items: [parsed], + tombstones: [], + sealCapacityTombstones: false, + rosterEpoch: 1, + now: "2026-07-28T08:01:00.000Z", + }, + ); + expect((await accountRoute( + database, + "account-a", + "PATCH", + `/attention/account/preferences/machines/${MACHINE_KEY}`, + { notificationsEnabled: false, hideDetails: true }, + )).status).toBe(200); + expect((await accountRoute( + database, + "account-a", + "PATCH", + "/attention/account/preferences/devices/phone-machine-muted", + { soundsEnabled: true }, + )).status).toBe(200); + const storedPreferences = JSON.parse(row<{ payload_json: string }>(database, ` + select payload_json + from attention_preferences + where user_id = 'account-a' + `)?.payload_json ?? "{}") as Record; + expect(attentionTestInternals.resolveActivityDeliveryPreferences( + { + device_id: "phone-machine-muted", + apns_token: "cd".repeat(32), + push_to_start_token: null, + bundle_id: "com.ade.ios", + aps_environment: "sandbox", + preferences_json: JSON.stringify({ soundsEnabled: false }), + generation: "generation", + }, + parsed, + storedPreferences, + )).toMatchObject({ + notificationsEnabled: false, + hideDetails: true, + soundsEnabled: true, + }); + await attentionTestInternals.deliverAttentionNotifications( + makeAttentionEnv(database, { + APNS_KEY: "test-key", + APNS_KEY_ID: "TESTKEY123", + APNS_TEAM_ID: "TESTTEAM12", + }), + "account-a", + [parsed], + sendPush, + ); + const snapshot = await (await accountRoute( + database, + "account-a", + "GET", + "/attention/account/snapshot?since=0", + )).json() as { items: Array<{ id: string }> }; + expect(snapshot.items.map((item) => item.id)).toContain(parsed.id); + expect(sendPush).not.toHaveBeenCalled(); + + const tooManyMachines = Object.fromEntries( + Array.from({ length: 65 }, (_, index) => [ + `machine-${index}`, + { notificationsEnabled: false }, + ]), + ); + expect((await accountRoute( + database, + "account-a", + "PUT", + "/attention/account/preferences", + { machines: tooManyMachines }, + )).status).toBe(400); + } finally { + database.close(); + } + }); + + it("keeps durable alert history across prune and same-id device re-registration", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T08:01:00.000Z")); + const database = new SqliteD1Database(); + const authorization = await machinePublishAuthorization(); + let notificationSends = 0; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url === authorization.jwksUrl) return Response.json(authorization.jwks); + if (url.startsWith("https://api.sandbox.push.apple.com/")) { + notificationSends += 1; + return new Response(null, { + status: 200, + headers: { "apns-id": `durable-alert-${notificationSends}` }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + })); + const env = makeAttentionEnv(database, { + CLERK_JWKS_URL: authorization.jwksUrl, + CLERK_ISSUER: authorization.issuer, + CLERK_OAUTH_CLIENT_ID: "attention-test-client", + APNS_KEY: await generateTestP8(), + APNS_KEY_ID: "DURABLE123", + APNS_TEAM_ID: "DURABLE12", + }); + const publish = (revision: number, contentFingerprint: string) => + publishActivityForTest(env, authorization, { + machineName: "Studio", + mode: "delta", + rosterEpoch: 1, + items: [activityAgentItem({ + sessionId: "durable-alert", + itemId: "durable-alert", + revision, + contentFingerprint, + alertFingerprint: "durable-alert-identity", + activityTier: "signal", + updatedAt: "2026-07-28T08:00:30.000Z", + expiresAt: "2099-07-29T08:00:00.000Z", + })], + tombstones: [], + }); + try { + insertAttentionDevice(database, { + userId: authorization.userId, + deviceId: "phone-durable-alert", + apnsToken: "ef".repeat(32), + }); + expect((await publish(1, "durable-content-1")).status).toBe(200); + expect(notificationSends).toBe(1); + database.native.prepare(` + update attention_items + set expires_at = '2026-07-28T07:59:00.000Z' + where user_id = ? and item_id = ? + `).run(authorization.userId, `agent:${MACHINE_KEY}:durable-alert`); + database.native.prepare(` + update attention_delivery_receipts + set delivered_at = '2026-07-20T08:00:00.000Z' + where user_id = ? and device_id = 'phone-durable-alert' + `).run(authorization.userId); + database.native.prepare(` + update attention_alert_log + set delivered_at = '2026-07-08T08:00:00.000Z' + where user_id = ? and device_id = 'phone-durable-alert' + `).run(authorization.userId); + + await pruneAttentionState(env); + expect(rows(database, ` + select item_id from attention_items where user_id = ? + `, authorization.userId)).toEqual([]); + expect(rows(database, ` + select item_id from attention_delivery_receipts where user_id = ? + `, authorization.userId)).toEqual([]); + expect(rows(database, ` + select alert_fingerprint from attention_alert_log where user_id = ? + `, authorization.userId)).toEqual([{ + alert_fingerprint: "durable-alert-identity", + }]); + + expect((await publish(2, "durable-content-2")).status).toBe(200); + expect(notificationSends).toBe(1); + expect((await accountRoute( + database, + authorization.userId, + "DELETE", + "/attention/account/devices/phone-durable-alert", + { ownershipEpoch: 1, apnsToken: "ef".repeat(32) }, + )).status).toBe(200); + expect(rows(database, ` + select alert_fingerprint from attention_alert_log where user_id = ? + `, authorization.userId)).toHaveLength(1); + expect((await accountRoute( + database, + authorization.userId, + "PUT", + "/attention/account/devices/phone-durable-alert", + { + ownershipEpoch: 1, + apnsToken: "ef".repeat(32), + bundleId: "com.ade.ios", + apsEnvironment: "sandbox", + platform: "iOS", + }, + )).status).toBe(200); + expect((await publish(3, "durable-content-3")).status).toBe(200); + expect(notificationSends).toBe(1); + } finally { + database.close(); + } + }); + + it("fences stale acknowledgments and rejects account-owner mismatch", async () => { + const database = new SqliteD1Database(); + const parsed = attentionTestInternals.parseAttentionItem(activityAgentItem({ + sessionId: "ack-fence", + itemId: "ack-fence", 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); + contentFingerprint: "ack-content", + alertFingerprint: "ack-alert", + activityTier: "signal", + }), MACHINE_KEY); + expect(parsed, "ack-fence item must parse").not.toBeNull(); + if (!parsed) throw new Error("ack-fence item did not parse"); + try { + await attentionTestInternals.commitAttentionMachineChanges( + makeAttentionEnv(database), + { + userId: "account-a", + machineKey: MACHINE_KEY, + items: [parsed], + tombstones: [], + sealCapacityTombstones: false, + rosterEpoch: 1, + now: "2026-07-28T08:00:00.000Z", + }, + ); + const mismatch = await accountRoute( + database, + "account-a", + "POST", + "/attention/account/ack", + { + itemIds: [parsed.id], + sourceRevisions: { [parsed.id]: 7 }, + expectedAccountOwnerId: "account-b", + seenAt: "2026-07-28T08:01:00.000Z", + dismissedAt: null, + }, + ); + expect(mismatch.status).toBe(409); + expect(row(database, ` + select seen_at from attention_items where user_id = 'account-a' and item_id = ? + `, parsed.id)?.seen_at).toBeNull(); + + const stale = await accountRoute( + database, + "account-a", + "POST", + "/attention/account/ack", + { + itemIds: [parsed.id], + sourceRevisions: { [parsed.id]: 6 }, + expectedAccountOwnerId: "account-a", + seenAt: "2026-07-28T08:01:00.000Z", + dismissedAt: null, + }, + ); + expect(await stale.json()).toMatchObject({ + applied: [], + stale: [parsed.id], + }); + expect(row(database, ` + select seen_at from attention_items where user_id = 'account-a' and item_id = ? + `, parsed.id)?.seen_at).toBeNull(); + + const matching = await accountRoute( + database, + "account-a", + "POST", + "/attention/account/ack", + { + itemIds: [parsed.id], + sourceRevisions: { [parsed.id]: 7 }, + expectedAccountOwnerId: "account-a", + seenAt: "2026-07-28T08:02:00.000Z", + dismissedAt: null, + }, + ); + expect(await matching.json()).toMatchObject({ + applied: [parsed.id], + stale: [], + }); + expect(row(database, ` + select seen_at from attention_items where user_id = 'account-a' and item_id = ? + `, parsed.id)?.seen_at).toBe("2026-07-28T08:02:00.000Z"); + } finally { + database.close(); + } }); - it("atomically preserves concurrent account and per-device preference updates", async () => { + it("handles presence with one link write and no item writes", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T08:01:00.000Z")); const database = new SqliteD1Database(); + const authorization = await machinePublishAuthorization(); + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url === authorization.jwksUrl) return Response.json(authorization.jwks); + throw new Error(`Unexpected fetch: ${url}`); + })); try { database.native.prepare(` - insert into attention_preferences(user_id, payload_json, updated_at) - values ('account-a', ?, '2026-07-28T08:00:00.000Z') - `).run(JSON.stringify({ - account: { notificationsEnabled: true }, - devices: { - "phone-a": { - celebrationsEnabled: false, - }, + insert into attention_machine_links( + machine_key, user_id, machine_name, last_seen_at, linked_at, + legacy_devices_imported_at + ) values (?, ?, 'Studio', '2026-07-28T08:00:00.000Z', + '2026-07-28T08:00:00.000Z', null) + `).run(MACHINE_KEY, authorization.userId); + const before = row<{ count: number }>(database, ` + select total_changes() as count + `)?.count ?? 0; + const response = await publishActivityForTest( + makeAttentionEnv(database, { + CLERK_JWKS_URL: authorization.jwksUrl, + CLERK_ISSUER: authorization.issuer, + CLERK_OAUTH_CLIENT_ID: "attention-test-client", + }), + authorization, + { + machineName: "Studio refreshed", + mode: "presence", + rosterEpoch: 12, + items: [], + tombstones: [], }, - })); - - const [phoneAResponse, phoneBResponse, accountResponse] = await Promise.all([ - accountRoute( - database, - "account-a", - "PATCH", - "/attention/account/preferences/devices/phone-a", - { - notificationsEnabled: false, - mutedSessionIds: ["session-a"], - }, - ), - accountRoute( - database, - "account-a", - "PATCH", - "/attention/account/preferences/devices/phone-b", - { - liveActivitiesEnabled: true, - }, - ), - accountRoute( - database, - "account-a", - "PUT", - "/attention/account/preferences", - { - account: { - notificationsEnabled: true, - hideDetails: true, - }, - projects: { - "project-a": { - notificationsEnabled: false, - }, - }, - }, - ), - ]); + ); + const after = row<{ count: number }>(database, ` + select total_changes() as count + `)?.count ?? 0; + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + protocol: 2, + upserted: 0, + removed: 0, + acks: [], + }); + expect(after - before).toBe(1); + expect(rows(database, ` + select item_id from attention_items where user_id = ? + `, authorization.userId)).toEqual([]); + expect(row(database, ` + select machine_name, last_seen_at + from attention_machine_links + where machine_key = ? + `, MACHINE_KEY)).toEqual({ + machine_name: "Studio refreshed", + last_seen_at: "2026-07-28T08:01:00.000Z", + }); + } finally { + database.close(); + } + }); - expect(phoneAResponse.status).toBe(200); - expect(phoneBResponse.status).toBe(200); - expect(accountResponse.status).toBe(200); - const stored = row<{ payload_json: string }>( + it("caps an account, reports publish eviction, and keeps exact-cap snapshots honest", async () => { + const database = new SqliteD1Database(); + const authorization = await machinePublishAuthorization(); + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url === authorization.jwksUrl) return Response.json(authorization.jwks); + throw new Error(`Unexpected fetch: ${url}`); + })); + try { + const insertIdle = database.native.prepare(` + insert into attention_items( + user_id, item_id, machine_key, source_revision, account_revision, + fingerprint, content_fingerprint, alert_fingerprint, activity_tier, + roster_epoch, event_kind, phase, payload_json, seen_at, dismissed_at, + expires_at, updated_at + ) values (?, ?, ?, 1, 0, ?, ?, ?, 'idle', 1, 'agent_completed', + 'completed', '{}', null, null, null, ?) + `); + for (let index = 0; index < 2_000; index += 1) { + const fingerprint = `idle-fingerprint-${index}`; + insertIdle.run( + authorization.userId, + `idle-${index.toString().padStart(4, "0")}`, + MACHINE_KEY, + fingerprint, + fingerprint, + fingerprint, + new Date(Date.UTC(2026, 6, 1, 0, 0, index)).toISOString(), + ); + } + const response = await publishActivityForTest( + makeAttentionEnv(database, { + CLERK_JWKS_URL: authorization.jwksUrl, + CLERK_ISSUER: authorization.issuer, + CLERK_OAUTH_CLIENT_ID: "attention-test-client", + }), + authorization, + { + machineName: "Studio", + mode: "delta", + rosterEpoch: 2, + items: [activityAgentItem({ + sessionId: "cap-signal", + itemId: "cap-signal", + revision: 1, + contentFingerprint: "cap-signal-content", + alertFingerprint: "cap-signal-alert", + activityTier: "signal", + })], + tombstones: [], + }, + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ itemsTruncated: true }); + expect(row(database, ` + select count(*) as count from attention_items where user_id = ? + `, authorization.userId)?.count).toBe(2_000); + expect(row(database, ` + select revivable + from attention_tombstones + where user_id = ? and item_id = 'idle-0000' + `, authorization.userId)?.revivable).toBe(0); + const snapshot = await (await accountRoute( database, - "select payload_json from attention_preferences where user_id = 'account-a'", + authorization.userId, + "GET", + "/attention/account/snapshot?since=0", + )).json() as { itemsTruncated?: boolean }; + expect(snapshot.itemsTruncated).toBe(false); + } finally { + database.close(); + } + }); + + it("evicts legacy null-tier rows only after idle rows", async () => { + const database = new SqliteD1Database(); + const authorization = await machinePublishAuthorization(); + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url === authorization.jwksUrl) return Response.json(authorization.jwks); + throw new Error(`Unexpected fetch: ${url}`); + })); + const insert = database.native.prepare(` + insert into attention_items( + user_id, item_id, machine_key, source_revision, account_revision, + fingerprint, content_fingerprint, alert_fingerprint, activity_tier, + roster_epoch, event_kind, phase, payload_json, seen_at, dismissed_at, + expires_at, updated_at + ) values (?, ?, ?, 1, 0, ?, ?, ?, ?, 1, 'agent_running', + 'running', '{}', null, null, null, ?) + `); + try { + for (let index = 0; index < 1_998; index += 1) { + const fingerprint = `signal-${index}`; + insert.run( + authorization.userId, + `signal-${index}`, + MACHINE_KEY, + fingerprint, + fingerprint, + fingerprint, + "signal", + "2026-07-01T00:00:02.000Z", + ); + } + insert.run( + authorization.userId, + "legacy-null", + MACHINE_KEY, + "legacy-null", + "legacy-null", + "legacy-null", + null, + "2026-07-01T00:00:00.000Z", ); - const preferences = JSON.parse(stored?.payload_json ?? "{}") as Record; - expect(preferences).toMatchObject({ - account: { - notificationsEnabled: true, - hideDetails: true, - }, - projects: { - "project-a": { - notificationsEnabled: false, - }, - }, - devices: { - "phone-a": { - celebrationsEnabled: false, - notificationsEnabled: false, - mutedSessionIds: ["session-a"], - }, - "phone-b": { - liveActivitiesEnabled: true, - }, - }, + insert.run( + authorization.userId, + "idle-row", + MACHINE_KEY, + "idle-row", + "idle-row", + "idle-row", + "idle", + "2026-07-01T00:00:01.000Z", + ); + const env = makeAttentionEnv(database, { + CLERK_JWKS_URL: authorization.jwksUrl, + CLERK_ISSUER: authorization.issuer, + CLERK_OAUTH_CLIENT_ID: "attention-test-client", }); + const publishSignal = (sessionId: string, revision: number) => + publishActivityForTest(env, authorization, { + machineName: "Studio", + mode: "delta", + rosterEpoch: 2, + items: [activityAgentItem({ + sessionId, + itemId: null, + revision, + contentFingerprint: `${sessionId}-content`, + alertFingerprint: `${sessionId}-alert`, + activityTier: "signal", + })], + tombstones: [], + }); + + expect(await (await publishSignal("cap-first", 2)).json()) + .toMatchObject({ itemsTruncated: true }); + expect(row(database, ` + select item_id from attention_items where user_id = ? and item_id = 'idle-row' + `, authorization.userId)).toBeUndefined(); + expect(row(database, ` + select item_id from attention_items where user_id = ? and item_id = 'legacy-null' + `, authorization.userId)).toEqual({ item_id: "legacy-null" }); + + expect(await (await publishSignal("cap-second", 3)).json()) + .toMatchObject({ itemsTruncated: true }); + expect(row(database, ` + select item_id from attention_items where user_id = ? and item_id = 'legacy-null' + `, authorization.userId)).toBeUndefined(); + expect(rows(database, ` + select item_id, revivable from attention_tombstones + where user_id = ? and item_id in ('idle-row', 'legacy-null') + order by item_id + `, authorization.userId)).toEqual([ + { item_id: "idle-row", revivable: 0 }, + { item_id: "legacy-null", revivable: 0 }, + ]); + } finally { + database.close(); + } + }); + + it("reports truncation backpressure when an over-cap account has no evictable rows", async () => { + const database = new SqliteD1Database(); + const authorization = await machinePublishAuthorization(); + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url === authorization.jwksUrl) return Response.json(authorization.jwks); + throw new Error(`Unexpected fetch: ${url}`); + })); + const insertSignal = database.native.prepare(` + insert into attention_items( + user_id, item_id, machine_key, source_revision, account_revision, + fingerprint, content_fingerprint, alert_fingerprint, activity_tier, + roster_epoch, event_kind, phase, payload_json, seen_at, dismissed_at, + expires_at, updated_at + ) values (?, ?, ?, 1, 0, ?, ?, ?, 'signal', 1, 'agent_running', + 'running', '{}', null, null, null, '2026-07-01T00:00:00.000Z') + `); + try { + for (let index = 0; index < 2_000; index += 1) { + const fingerprint = `signal-only-${index}`; + insertSignal.run( + authorization.userId, + `signal-only-${index}`, + MACHINE_KEY, + fingerprint, + fingerprint, + fingerprint, + ); + } + const response = await publishActivityForTest( + makeAttentionEnv(database, { + CLERK_JWKS_URL: authorization.jwksUrl, + CLERK_ISSUER: authorization.issuer, + CLERK_OAUTH_CLIENT_ID: "attention-test-client", + }), + authorization, + { + machineName: "Studio", + mode: "delta", + rosterEpoch: 2, + items: [activityAgentItem({ + sessionId: "signal-overflow", + itemId: null, + revision: 2, + contentFingerprint: "signal-overflow-content", + alertFingerprint: "signal-overflow-alert", + activityTier: "signal", + })], + tombstones: [], + }, + ); + const body = await response.json() as { itemsTruncated?: boolean }; + expect(response.status).toBe(200); + expect(body.itemsTruncated).toBe(true); + expect(row(database, ` + select count(*) as count from attention_items where user_id = ? + `, authorization.userId)?.count).toBe(2_001); } finally { database.close(); } @@ -1572,6 +2815,8 @@ describe("account Attention contract", () => { ok: true, revision: 4, itemIds: [], + applied: [], + stale: [], }); expect(row(database, ` select revision @@ -1875,6 +3120,10 @@ describe("account Attention contract", () => { delete from attention_delivery_receipts where user_id = 'account-a' and item_id = ? `).run(parsed.id); + database.native.prepare(` + delete from attention_alert_log + where user_id = 'account-a' and alert_fingerprint = ? + `).run(parsed.alertFingerprint); database.native.prepare(` update attention_presence set payload_json = ? @@ -3909,7 +5158,7 @@ describe("account Attention contract", () => { } }); - it("prunes delivery receipts without live Attention state for renewed devices", async () => { + it("prunes delivery receipts by age independently of Attention item state", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-07-28T12:00:00.000Z")); const database = new SqliteD1Database(); @@ -3942,7 +5191,8 @@ describe("account Attention contract", () => { ) 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') + ('account-a', 'removed-item', 'active-phone', 'alert:removed', '2026-07-28T11:00:00.000Z'), + ('account-a', 'old-removed-item', 'active-phone', 'alert:old', '2026-07-20T11:00:00.000Z') `).run(); await pruneAttentionState(makeAttentionEnv(database)); @@ -3952,7 +5202,11 @@ describe("account Attention contract", () => { from attention_delivery_receipts where user_id = 'account-a' and device_id = 'active-phone' order by item_id - `)).toEqual([{ item_id: "active-item" }]); + `)).toEqual([ + { item_id: "active-item" }, + { item_id: "expired-item" }, + { item_id: "removed-item" }, + ]); expect(rows(database, ` select item_id from attention_items diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f27815e0c..4792f38ee 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -226,7 +226,7 @@ The desktop app is a **client of the runtime**. It owns a trusted main process, **Multi-window shell.** `main.ts` hosts multiple `BrowserWindow` instances; opening another project opens it in a dedicated window. Each window has its own runtime binding (local pool or a specific remote target). The global `/chats` route surfaces through a real machine-level **Chats** top tab (its existence tracked in session-only `personalChatsTabOpen` app state, active-ness derived from the route) that coexists with project tabs and survives project open/switch/close, or runs inside the current project tab without clearing that binding. Personal-chat IPC therefore targets the local brain from local/no-project windows and the remote brain from an SSH-bound project window. External controllers — for example a `ade code` TUI — can drive desktop window navigation via the `app/navigate` JSON-RPC method against the runtime; the desktop's IPC tracing carries window ID so logs distinguish which renderer surface invoked a channel. -**Account Attention is outside the project binding.** +**Account Activity is outside the project binding.** `attentionAccountCoordinator.ts` is the desktop main-process boundary for snapshot, acknowledgment, presence, and preference calls. Signed-in reads go directly to the account push relay and therefore remain available when the @@ -235,8 +235,19 @@ brain. A relay failure may degrade only to this Mac's local `getMachineSnapshot`; signed-out mode uses that same explicitly labeled local scope. The loaded account owner and, for machine fallback, every item's exact source revision fence later mutations. The renderer keeps this source warm in -`AppShell`, presents it first through the global-header Attention drawer, and -uses `/attention` only as the secondary full-history route. +`AppShell`, presents it first through the global-header Activity popover, and +uses `/activity` for the larger two-column Activity pane. The popover and pane +share two buckets: Sessions (Needs you, Working, and Done) and Inbox (PR/CI and +other outcomes); the pane adds filters and item detail, while ADE Notch shows +the same stream as a compact native toast/surface. + +The product and UI name is **Activity**, but the cross-process, network, +persistence, telemetry, and native-helper contracts intentionally retain their +existing `attention` naming. That includes `AttentionItem` wire fields, +`attention.call`, `ade.attention.*` IPC channels, `/attention/account/*` relay +routes, `attention_*` SQLite columns, analytics/log identifiers, and the +`ade-attention-notch` product name. Do not rename those compatibility +boundaries. `/attention` remains a legacy navigation alias for `/activity`. **Runtime binding pools.** @@ -261,13 +272,13 @@ Terminal-native **Work** chat client (Ink 7 + React 19) for agents and power use Shared DTOs and cross-client policies are imported from `apps/desktop/src/shared/*` (never the renderer barrel) so `npm run typecheck` in `apps/ade-cli` covers both typed commands and the TUI. This includes `externalSessionAffordances.ts`, which keeps ADE Code's provider-native Continue/Copy choices aligned with desktop while `externalSessionBrowser.ts` owns TUI-only navigation and the Open-existing action. Entry: `apps/ade-cli/src/tuiClient/cli.tsx` → `apps/ade-cli/dist/tuiClient/cli.mjs`, loaded by `ade code`. The built TUI bundle is intended to run in isolation: tsup bundles its Ink/xterm/highlight dependencies and injects ESM shims for `__dirname` / `__filename`; both `apps/ade-cli/scripts/verify-built-cli.mjs` and the desktop artifact validators smoke-import it and run `runAdeCodeCli(["--help"])`. Provider/model/interface setup is kept in pure helpers (`modelState.ts`, `providerMetadata.ts`, `modelPickerController.ts`) so Chat-vs-CLI availability, Cursor SDK-vs-CLI model filtering, permission presets, Fast Mode, and setup rows stay testable outside the Ink root. Chat Info uses shared derivations for subagents, tasks, and scheduled work, so Claude wakeups/cron/background activity rendered in desktop also appears in ADE Code; `AgentChatSessionSummary.nextWakeAt` adds the runtime scheduler's earliest armed fire as an alarm countdown in the Schedule block. The TUI can hand off to a desktop window via the `app/navigate` JSON-RPC method when a desktop client is attached to the same runtime. -`/attention` is also machine-global rather than project-scoped. It reads the +`/activity` is also machine-global rather than project-scoped. It reads the consolidated account stream through `attention.call`, groups the shared -Attention item contract in the right pane, and opens an item's exact ADE +`AttentionItem` wire contract in the Activity right pane, and opens an item's exact ADE destination before acknowledging it. Signed-out or degraded operation may show the connected host's real machine snapshot. A host without the capability stays connected and reports host-specific update/restart guidance instead of a blank -pane. +pane. `/attention` remains accepted as an unadvertised compatibility alias. Model setup shares the desktop registry: GPT-5.6 Sol/Terra/Luna lead the Codex list, Sol is the default, and host-advertised reasoning defaults and effort @@ -285,8 +296,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` (default-on, 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 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. The same non-PII epoch is stamped into account-wide Live Activity attributes/content: the app ends an owner-mismatched activity, while the widget extension renders only a neutral Updating ADE state before cleanup. 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. +- Shipped Activity surfaces: a global account-wide Activity drawer with Sessions and Inbox buckets, 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 Activity snapshots/ACKs/presence/preferences and register APNs/Live-Activity tokens directly with the Cloudflare push relay (§2.7). The wire DTOs retain their `Attention*` names. 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. The same non-PII epoch is stamped into account-wide Live Activity attributes/content: the app ends an owner-mismatched activity, while the widget extension renders only a neutral Updating ADE state before cleanup. 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 with a LAN → Tailscale → Relay preference; the phone races every eligible candidate in one happy-eyeballs wave rather than exhausting direct routes before trying Relay. 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. @@ -328,8 +339,8 @@ machine-scoped `/chats` uses runtime-scoped `personalChats.*` commands without selecting a project. The chat adapter implements the shared `agentChat.promptStashes` contract with an always-array list fallback and required create/delete mutations, so malformed or unsupported host results do -not reach composer code as `null`. Account Attention is a separate direct push-relay read: -the global header/full-center snapshot does not follow the selected sync +not reach composer code as `null`. Account Activity is a separate direct push-relay read: +the global header/pane snapshot does not follow the selected sync machine or project. Signed-out compatibility environments can instead ask only their explicitly paired host for a real machine snapshot; older hosts surface an update state. Product analytics requires an affirmative browser-local @@ -349,7 +360,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/`** — merges the bounded ADE Attention snapshots published by every signed-in brain, exposes an incremental account snapshot/ACK/presence/preferences/device API to desktop, hosted web, ADE Code, 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. Primary and secondary identity domains are complete, distinct issuer/JWKS/OAuth-client triples selected by exact `iss`; OAuth audience metadata must match through `aud` or `azp`, and the D1 user key is namespaced by verified issuer. `npm run deploy` separates schema/trigger health from account-auth health: it requires both binding triples and short-lived issuer-specific smoke tokens, then checks `/health` and calls a real account snapshot with each token after deployment. 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. Physical-notch Macs merge the surface with the hardware cutout; other Macs keep the real ADE icon in the menu bar and open an anchored transient panel instead of a permanent imitation notch. See [features/sync-and-multi-device/push-notifications.md](./features/sync-and-multi-device/push-notifications.md). +- **`apps/push-relay/`** — merges the bounded ADE Activity snapshots published by every signed-in brain, exposes an incremental account snapshot/ACK/presence/preferences/device API to desktop, hosted web, ADE Code, 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). Its routes, schema, payloads, and stored fields intentionally retain `attention` compatibility names. A machine publish requires both its existing HMAC signature and a verified Clerk account token; account routes require a verified Clerk bearer token. Primary and secondary identity domains are complete, distinct issuer/JWKS/OAuth-client triples selected by exact `iss`; OAuth audience metadata must match through `aud` or `azp`, and the D1 user key is namespaced by verified issuer. `npm run deploy` separates schema/trigger health from account-auth health: it requires both binding triples and short-lived issuer-specific smoke tokens, then checks `/health` and calls a real account snapshot with each token after deployment. 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. Physical-notch Macs merge the surface with the hardware cutout; other Macs keep the real ADE icon in the menu bar and open an anchored transient panel instead of a permanent imitation notch. 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). Because the DO keeps exactly one host control socket per `machineKey` and evicts the previous holder with close code `4505`, dialing it is gated on holding the machine-wide sync host lease (`relayTunnelAuthorityGate` + `syncHostSingleton`, §3.4) — not on merely having a listener. A `4505` close is treated as a machine-local ownership conflict rather than a network fault: the client retries on a 60 s floor at most three times, then stops and reports `routeHealth.relay.relayControlSuppressed` with an actionable reason, re-arming once after 10 minutes or immediately when it (re)acquires the lease. Ordinary reconnect backoff uses decorrelated jitter with a 1 s floor and 60 s cap, so two clients that collide once do not keep colliding. 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. Its additive `custom_name` column is owned by the account user rather than the publisher: authenticated `PATCH /account/machines/:machineKey` sets or clears the bounded display override, while registration continues updating the reported hostname without clobbering it. 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. @@ -778,7 +789,7 @@ Most services described here live under `apps/desktop/src/main/services/ | `ai/` | `aiIntegrationService.ts`, `authDetector.ts`, `providerConnectionStatus.ts`, `claudeRuntimeProbe.ts`, `modelsDevService.ts`, `compactionEngine.ts`, `tools/*` | Provider routing, detection, tool definitions, compaction. | | `agentTools/` | `agentToolsService.ts` | Agent tool registry metadata surfaced to the renderer. | | `analytics/` | `productAnalyticsService.ts`, `productAnalyticsPolicy.ts`, `usageProductAnalyticsExporter.ts`, `dailyUsageAnalytics.ts`, `agentTurnProductAnalytics.ts` | Machine-scoped privacy-bounded product analytics: direct PostHog capture transport, consent/kill switches, closed sanitizer, salted identifier hashing, once-only install/activation milestones, pseudonymous account identification, durable quotas/deduplication, usage-ledger export, and coarse aggregate/work-session producers. See [logging.md](./logging.md). | -| `attention/` | `attentionAccountCoordinator.ts`, `attentionNotchHelper.ts`, `attentionNotchRouter.ts` | Account-first desktop Attention boundary plus the native macOS helper lifecycle. The coordinator reads the relay independently of the selected project/remote binding, permits only an explicit local-machine fallback, and fences mutations by loaded account owner/source revision. The helper consumes the renderer snapshot, reports physical-notch vs menu-bar surface state, preserves empty/error availability, and routes native open/refresh/settings/acknowledgment requests back through typed IPC. Its refresh cadence is keyed off what is actually on screen: 15 s while it has a reported surface and the display is awake, 60 s while it has none or the screen is locked/suspended. `main.ts` feeds that through `setScreenAwake`, tracking `powerMonitor` lock and suspend as independent facts so a resume after a sleep that did not lock cannot declare a locked screen awake. Changing the interval rebuilds the timer, and a respawned child starts with no surface rather than inheriting the previous one's. | +| `attention/` | `attentionAccountCoordinator.ts`, `attentionNotchHelper.ts`, `attentionNotchRouter.ts` | Account-first desktop Activity boundary plus the native macOS helper lifecycle. The internal directory and service names remain part of the established compatibility vocabulary. The coordinator reads the relay independently of the selected project/remote binding, permits only an explicit local-machine fallback, and fences mutations by loaded account owner/source revision. The helper consumes the renderer snapshot, reports physical-notch vs menu-bar surface state, preserves empty/error availability, and routes native open/refresh/settings/acknowledgment requests back through typed IPC. Its refresh cadence is keyed off what is actually on screen: 15 s while it has a reported surface and the display is awake, 60 s while it has none or the screen is locked/suspended. `main.ts` feeds that through `setScreenAwake`, tracking `powerMonitor` lock and suspend as independent facts so a resume after a sleep that did not lock cannot declare a locked screen awake. Changing the interval rebuilds the timer, and a respawned child starts with no surface rather than inheriting the previous one's. | | `appControl/` | `appControlService.ts`, `appControlLaunchCommand.ts` | Chrome DevTools Protocol bridge for developer-owned Electron apps. Launches a chat-owned PTY running the user's dev command (or connects to an existing `--remote-debugging-port`), polls `/json` for ready CDP targets, attaches a long-lived `CdpClient` WebSocket, and exposes screenshot / DOM snapshot / hit-test / click / type / scroll / key dispatch / screencast frames. `appControlLaunchCommand.ts` owns the shell-command detection and debug-flag injection helpers for direct Electron and package-script launches. `inspectPoint` and `selectPoint` produce `AppControlContextItem`s for the chat composer (DOM packet + screenshot + source-file candidates resolved by `findSourceMatches` over an indexed tree of project source files). See [features/computer-use/app-control.md](./features/computer-use/app-control.md). | | `builtInBrowser/` | `builtInBrowserService.ts`, `builtInBrowserAgentAccess.ts`, `builtInBrowserActorCapabilities.ts`, `builtInBrowserAuthentication.ts`, `builtInBrowserProfileMigration.ts`, `builtInBrowserStateStore.ts`, `builtInBrowserNavigation.ts`, `builtInBrowserPermissions.ts`, `builtInBrowserWebAuthn.ts`, `desktopBridgeServer.ts` | In-app web browser owned by the main process. Every remote-content `WebContentsView` uses the single persistent `persist:ade-browser` storage profile (`storageProfileKey: "global"`), while service keys combine the ADE window id with a project/window/personal tab-collection key so visible tabs stay independent. Project roots route project commands and scratch observations; validated personal commands retain the personal tab collection and use the channel-specific machine-local browser-observation scratch root. Neither route partitions cookies or site storage. On first use, a bounded, idempotent migration copies unexpired persistent cookies from this channel's legacy project-derived partitions into the global profile without overwriting global cookies or copying session cookies; it preserves the old partition directories because Chromium DOM storage, IndexedDB, service-worker state, and WebAuthn credentials cannot be safely merged across partitions. The bounded machine-local state store restores HTTP(S)/blank tab URLs and the active tab for each collection, but never restores agent leases, lightweight browser sessions, or synthetic session cookies. The service caps each collection at 10 tabs, routes global-session network events back to their owning collection, drives OAuth popups and downloads, and emits targeted events. HTTP/proxy authentication uses a sandboxed, local credential prompt and passes values directly to Chromium without persisting or logging them; client-certificate requests use an explicit native chooser and only accept a certificate Electron offered. Permission requests are deny-by-default, limited to managed browser web contents and secure origins, and use persisted per-origin/embedding-origin decisions with a native human prompt; only Google's `storage-access` and `top-level-storage-access` requests retain a narrow accounts-domain compatibility exception. The Browser toolbar's trusted-renderer Profile panel exposes non-secret cookie/cache/flush diagnostics and list/remove/clear controls for remembered permission decisions; these operations are not bridged to agents or unbound CLI callers. A separate non-persistent agent-access controller requires a per-chat/lane native human grant for every non-local origin and for local origins with allowed privileged permissions; cross-origin navigations and redirects are intercepted, and sensitive popups are blocked until explicitly approved. The grant follows the agent-owned tab without a timer and clears only when an explicit trusted-renderer navigation reclaims the tab. Tabs carry owner/lease metadata. ADE-launched chats receive opaque in-memory browser actor capabilities bound to their trusted chat/lane/project or personal collection. The runtime requires the token and strips caller routing; Electron validates it in the issuing process, restores only the bound scope, forces `force: false`, and separately authenticates the bridge with the desktop launch's rotating token. Agents cannot force or impersonate a takeover, read another agent's tab status, inspect global cookie-domain diagnostics, or administer permissions. Browser sessions bind one workflow to one tab. Project observations live under `.ade/cache/browser-observations/`; personal observations live under the channel user-data `browser-observations/personal/` root, which is narrowly allowlisted for proof promotion. The issuer-restored scope selects the matching independent tab collection. Navigation/protocol policy lives in `builtInBrowserNavigation.ts`; WebAuthn account selection lives in `builtInBrowserWebAuthn.ts`. | | `automations/` | `automationService.ts`, `automationPlannerService.ts`, `automationIngressService.ts`, `automationSecretService.ts` | Rule lifecycle, NL → rule planner, inbound triggers, per-rule secrets. | diff --git a/docs/PRD.md b/docs/PRD.md index f97616544..31bfaf15d 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -89,8 +89,8 @@ ADE is the control plane. It owns ADE Browser automation for its built-in projec ### Brain, runtime, and clients - [**Remote Runtime**](./features/remote-runtime/README.md) — Remote access to an ADE runtime. Multi-project registry, machine endpoint, login-service install, SSH bootstrap of the cross-platform `ade-` runtime binaries shipped under `apps/desktop/resources/runtime/`. A remote machine's brain is authoritative for its projects. -- [**ADE Code**](./features/ade-code/README.md) — Terminal-native Work chat (Ink + React) inside `apps/ade-cli`. Default attaches to the machine brain and starts it if missing. Same JSON-RPC surface as the desktop app and the iOS controller, including session ask/note/settle lifecycle controls and the account-wide `/attention` pane. -- [**Web Client**](./features/web-client/README.md) — Owner-only hosted browser controller. Static Cloudflare Pages SPA, ADE account sign-in, account-directory machine selection, DPoP-bound sync WebSocket transport, no local DB, and account Attention that remains independent of the selected project. +- [**ADE Code**](./features/ade-code/README.md) — Terminal-native Work chat (Ink + React) inside `apps/ade-cli`. Default attaches to the machine brain and starts it if missing. Same JSON-RPC surface as the desktop app and the iOS controller, including session ask/note/settle lifecycle controls and the account-wide Activity pane. +- [**Web Client**](./features/web-client/README.md) — Owner-only hosted browser controller. Static Cloudflare Pages SPA, ADE account sign-in, account-directory machine selection, DPoP-bound sync WebSocket transport, no local DB, and account Activity that remains independent of the selected project. ### Work execution @@ -123,7 +123,7 @@ ADE is the control plane. It owns ADE Browser automation for its built-in projec - [**Linear Integration**](./features/linear-integration/README.md) — Issue read/search, lane/commit/PR attachment flow, batch launch, session-scoped attachment, and an optional live-status round-trip. - [**Computer Use**](./features/computer-use/README.md) — Direct signed Codex Computer Use, intentional proof capture, and active App Control. Canonical artifact model, ownership-linked storage. - [**iOS Simulator**](./features/ios-simulator/README.md) — Chat-side macOS-only drawer that builds, launches, mirrors, inspects, and controls a booted iOS Simulator. ADEInspector publishes per-frame SwiftUI element metadata so taps become source-anchored chat context. -- [**Sync and Multi-Device**](./features/sync-and-multi-device/README.md) — cr-sqlite CRDT (desktop native ext, iOS pure-SQL emulation), host/controller model, WebSocket envelope, remote commands, the [cross-machine session handoff contract](./features/sync-and-multi-device/cross-machine-session-handoff.md), and [ADE Attention](./features/sync-and-multi-device/push-notifications.md): one account-wide source of truth across desktop, web, ADE Code, iOS, notifications, widgets, Live Activities, and the native Mac presentation. +- [**Sync and Multi-Device**](./features/sync-and-multi-device/README.md) — cr-sqlite CRDT (desktop native ext, iOS pure-SQL emulation), host/controller model, WebSocket envelope, remote commands, the [cross-machine session handoff contract](./features/sync-and-multi-device/cross-machine-session-handoff.md), and [Activity](./features/sync-and-multi-device/push-notifications.md): one account-wide source of truth across desktop, web, ADE Code, iOS, notifications, widgets, Live Activities, and the native Mac presentation. --- diff --git a/docs/README.md b/docs/README.md index 21f40c56d..7a08c9144 100644 --- a/docs/README.md +++ b/docs/README.md @@ -42,7 +42,7 @@ docs/ ├── remote-runtime/ # local runtime + SSH remote machines ├── search/ # universal FTS5 index + ⌘K/TUI/CLI search ├── storage-and-recovery/ # disk pressure, durable state, diagnosis, repair - ├── sync-and-multi-device/ # CRDT sync, account Attention, iOS, remote commands, session handoff + ├── sync-and-multi-device/ # CRDT sync, account Activity, iOS, remote commands, session handoff ├── terminals-and-sessions/ # PTY, sessions, and UI surfaces ├── web-client/ # owner-only hosted browser client over sync WebSocket └── workspace-graph/ # React Flow canvas + data sources diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index 6f6db93ff..645906738 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -25,8 +25,8 @@ Point Cursor’s browser inspector at the served page for layout debugging. The | `apps/ade-cli/src/adeRpcServer.ts` | Runtime JSON-RPC and ADE action dispatcher used by the TUI/CLI. After a successful user-issued meaningful mutation it records one local usage event, attributing `ade-code` / `ade-cli` clients to `tui`; agent-owned run/step/chat calls and read-only actions are excluded. | | `apps/ade-cli/src/tuiClient/cli.tsx` | TUI entry: argv parsing, project discovery, connection bootstrap, Ink mount. Built to `apps/ade-cli/dist/tuiClient/cli.mjs`. | | `apps/ade-cli/src/tuiClient/app.tsx` | Primary Ink/React surface: navigation, composer, drawers, right pane, session lifecycle, slash command dispatch. It joins chat/terminal lists with `session.list` lifecycle fields and dispatches `/chat ask`, `/chat note`, `/chat settle`, and `/chat unsettle` through the session action domain (settle/unsettle land on the cto-gated `session.settleSession` / `session.unsettleSession` — the agent-facing `*SelfSession` pair was removed in 2026-07); settling a row that is awaiting input or explicitly requesting attention asks the backend to dismiss that pending input in the same settlement transaction. The target-addressable `/session snooze` / `wake` / `settle` / `unsettle` / `keep-active` commands are parsed in `sessionLifecycle.ts` and dispatched from here, with `components/Drawer.tsx` and `components/RightPane.tsx` rendering the resulting snooze/woke row markers. Owns startup reconnect/retry UI, the debounced/cached `@` mention loader, cursor-relative `/command` + `@file` trigger detection via the shared `apps/desktop/src/shared/composerTriggers.ts` module (mid-sentence slash completion on Tab/Enter, colored `@file`/`/command` chip tokens painted into the prompt rows through `segmentPromptLineText` + `findConfirmedComposerTokens`), smart-link prompt styling/summary strips, terminal mode restoration on exit/heartbeat shutdown, and the `Ctrl+Y` "copy ADE deeplink" handler which resolves the focused lane / PR row through `buildDeeplinkForRow` and copies the canonical `ade://...` URL to the system clipboard. It also owns cache-first chat revisits and the two-stage older-history path: drain the already-hydrated local snapshot buffer, then request byte-cursor pages with bounded retry while preserving the cursor on transient failure. Also backs `/skills` by listing Agent Skill roots from project, user, inherited, and bundled ADE locations, independent of the active provider. | -| `apps/ade-cli/src/tuiClient/attentionPane.ts` | Account-first `/attention` model: loads through machine-global `attention.call`, groups the shared Attention contract, derives exact ADE links, labels machine fallback honestly, and sends account-owner/source-revision-fenced seen mutations only after navigation succeeds. | -| `apps/ade-cli/src/tuiClient/components/AttentionPaneView.tsx` | Calm right-pane rendering for Needs you, Failing or blocked, Done unreviewed, Live now, and Recent groups, including machine/project context, offline last-known labels, recovery copy, and keyboard hints. | +| `apps/ade-cli/src/tuiClient/activityPane.ts` | Account-first `/activity` model: loads through machine-global `attention.call`, groups the shared `AttentionItem` wire contract, derives exact ADE links, labels machine fallback honestly, and sends account-owner/source-revision-fenced seen mutations only after navigation succeeds. `/attention` remains a hidden compatibility alias. | +| `apps/ade-cli/src/tuiClient/components/ActivityPaneView.tsx` | Calm Activity right-pane rendering for needs-you, failures, done-but-unreviewed, live, and recent groups, including machine/project context, offline last-known labels, recovery copy, and keyboard hints. | | `apps/ade-cli/src/tuiClient/promptSmartLinks.ts` | ADE Code's capability-adapted smart-link helpers. Formats a one-row violet provider/label strip from the shared `smartLinks.ts` catalog and makes character Backspace/Delete remove the whole URL when the cursor intersects it; the prompt still contains and sends the canonical raw URL. | | `apps/ade-cli/src/tuiClient/productAnalytics.ts` | Pure TUI screen normalization plus runtime `analytics.capture` calls. `app.tsx` records a deduplicated open and normalized screen changes; it never owns a PostHog client, reads terminal/chat content, or emits per-render/poll events. Accepted events share the machine runtime's consent and 200-event daily budget. See [logging and product analytics](../../logging.md). | | `apps/ade-cli/src/tuiClient/externalSessionBrowser.ts` | Pure state/actions for the provider-native session browser. Filters and clamps rows, consumes the shared Continue/Copy policy, puts `Open existing ADE session` first for imported rows, and exposes only Copy actions after it so Enter never re-imports the original session. | @@ -103,9 +103,9 @@ Point Cursor’s browser inspector at the served page for layout debugging. The `forceEmbedded` and `requireSocket` are mutually exclusive — `connectToAde` rejects the combination. -### Account-wide Attention +### Account-wide Activity -`/attention` is a machine-global right-pane utility, not a view of the selected +`/activity` is a machine-global right-pane utility, not a view of the selected lane or project. A signed-in TUI asks `attention.call/getSnapshot` for the consolidated account stream. The runtime's account coordinator can read the relay independently of the current project, while each item retains its owning @@ -251,7 +251,7 @@ Right pane (open contextual content): | `/session settle [session-id] [outcome]` | Mark a session settled, declaring the settle at the override tier. | | `/session unsettle [session-id]` | Clear a session's declared settle plus any `settled` pin. | | `/session keep-active [session-id]` | Write the `active` settle-override pin, holding the row in the active list even if something later declares a settle on it (e.g. the PR-merge policy). Note that nothing *derives* a settle: a clean CLI exit leaves the row `ended`, never `settled`. | -| `/attention` | Open account-wide Attention in the right pane. Signed-out or degraded mode is labeled as connected-machine-only; `Enter` opens the exact destination and `R` refreshes. | +| `/activity` | Open account-wide Activity in the right pane. `/attention` remains a compatibility alias. Signed-out or degraded mode is labeled as connected-machine-only; `Enter` opens the exact destination and `R` refreshes. | | `/tag ` | Tag the active Claude chat (Claude only). | | `/output-style [style]` | List or select the active Claude output style (Claude only). | | `/plugin [reload\|native args]` | List, reload, or manage Claude plugins (Claude only). | diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index f0a43f2a6..8b49c89e2 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -1310,7 +1310,7 @@ Provider connection management lives on the `ade.ai.*` surface (handled in `regi `approval_request` / `structured_question`. The same id is mirrored into `TerminalSessionSummary.pendingInputItemId` for sync clients that key off the terminal session row. iOS uses it to back - Approve/Deny/Reply intents in the Attention Drawer without opening + Approve/Deny/Reply intents in the Activity drawer without opening the chat. - **Steer delivery vs. turn completion.** `deliverNextQueuedSteer()` is invoked on every turn-end code path (success, failure, interrupt, diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 8cb6bdeea..a3751edb9 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -214,8 +214,8 @@ Renderer — settings: container. It renders; it does not decide. Tabs, ordering, deep-link resolution, and search all resolve through `settings/settingsManifest.ts`, which is also what generates the Cmd-K - entries. The nine tabs are General, Appearance, Agents & Models, - Lanes & Git, Integrations, Notifications & Sound, Secrets, + entries. The ten tabs are General, Appearance, Agents & Models, + Lanes & Git, Integrations, Notifications & Sound, Activity, Secrets, Storage & Diagnostics, and Stats. Every tab id ADE has ever shipped in a URL still resolves via `LEGACY_TAB_ALIASES` (`settingsManifest.test.ts` asserts this); the one exception is @@ -827,7 +827,8 @@ changing rather than which service backs it: | Agents & Models | `ProvidersSection.tsx`, `OAuthConnectModal.tsx`, `AiFeaturesSection.tsx`, `BudgetCapEditor.tsx`, `DictationSection.tsx` | Provider connections, model routing, background helpers, spend cap, and voice input — merged because provider auth and per-task model routing are one mental model. **Coding Agents** cards (Claude Code, Codex CLI, Cursor, Droid) and **OpenCode — Universal Model Access**. Background helpers cover summaries, PR descriptions, commit messages, auto-naming, and scheduled-work recovery. Legacy `?tab=ai`, `?tab=providers`, `?tab=background-jobs`, and `?tab=automations` land here. | | Lanes & Git | `LaneBehaviorSection.tsx`, `LaneTemplatesSection.tsx`, `PrChatTranscriptsSection.tsx` | How lanes start (`new lane base`), stay current (`auto-rebase`), and tell you they fell behind (`rebase suggestions` off/badge/banner + min-behind threshold), plus lane init recipes and PR transcript gists. Legacy `?tab=lane-templates` lands here. | | Integrations | `GitHubIntegrationSection.tsx`, `LinearIntegrationSection.tsx`, `AdeCliSection.tsx` | GitHub, Linear, and the `ade` command line — reinstated as its own tab. Legacy `?tab=integrations`, `?tab=github`, and `?tab=linear` land here, as does `?integration=github|linear|cli`. | -| Notifications & Sound | `NotificationsSection.tsx`, `AgentCompletionSoundSection.tsx` | The canonical home for `AttentionPreferences`. Per-event delivery policy (off / ambient / notify) for agent and PR events, quiet hours, focus suppression, phone delivery and escalation, previews, sounds, celebrations, the attention notch, and the Lanes banner budget. The per-event matrix and quiet hours were fully modelled with balanced defaults but had **no UI at all** before this tab. The header `AttentionSettingsPopover` is now three quick toggles that point here. | +| Notifications & Sound | `NotificationsSection.tsx`, `AgentCompletionSoundSection.tsx` | Delivery for `AttentionPreferences`: per-event policy (off / ambient / notify) for agent and PR events, quiet hours, focus suppression, phone delivery and escalation, the agent completion sound, and the Lanes banner budget. The per-event matrix and quiet hours were fully modelled with balanced defaults but had **no UI at all** before this tab. | +| Activity | `ActivitySection.tsx`, `ActivitySettingsControls.tsx` | The surfaces Activity itself paints: the ADE notch (enabled, reveal mode, expanded panel), celebrations, Activity sounds, hide-previews, and the per-machine notification mute. `ActivitySettingsControls` is mounted here **and** by the gear inside the Activity popover and pane, so the two entry points cannot drift. Legacy `?tab=attention` plus the `#attention-notch`, `#celebrations`, `#attention-sounds`, and `#hide-previews` hashes land here. | | Secrets | `SecretsSection.tsx` | Encrypted key/value pairs for agents, desktop, and the CLI, with `.env` import. Legacy `?tab=secret` lands here. | | Storage & Diagnostics | `StorageSection.tsx`, `storage/*`, `SessionLifecycleSection.tsx` | Disk-usage and lane-storage dashboard, lane storage rules, session lifecycle, and diagnostics. Rule fields now show the value actually in force with an explicit "Inherited" marker instead of an empty box whose real value hid in the placeholder. Legacy `?tab=disk` and `?tab=diagnostics` land here. See [Storage and recovery](../storage-and-recovery/README.md). | | Stats | `AdeUsageSection.tsx`, `ActivityModule.tsx`, `providerColors.ts` | Usage page with live Limits plus a sectioned Activity dashboard. Legacy `?tab=usage` and `?tab=ade-usage` land here. | diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 97db52833..e740da158 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -26,7 +26,7 @@ does and does not travel, and the layers that implement it. Deep-dives: - `cross-machine-session-handoff.md` — the clean/published Git contract, bounded context capsule, destination setup, route confirmation, and idempotent recovery used by **Send to machine**. -- `push-notifications.md` — ADE Attention's account-wide source of truth and +- `push-notifications.md` — Activity's account-wide source of truth and its APNs + Live Activity pipeline: machine publishers, the Cloudflare consolidation relay, desktop/web/ADE Code/iOS reads, native Mac presentation, per-device policy, exact routing, acknowledgments, and ownership fences. @@ -40,7 +40,7 @@ commands, file requests, and chat/terminal streams. Browser environments paired before this release can still reconnect over their saved local/direct routes, but the hosted client no longer creates non-account pairings. -Account Attention deliberately does **not** follow that selected machine or +Account Activity deliberately does **not** follow that selected machine or project binding. Every signed-in brain publishes all of its active projects to the account relay, while signed-in desktop, hosted web, ADE Code, and iOS read the consolidated account stream through an account-scoped path. Navigation and @@ -1172,7 +1172,7 @@ Canonical files (`apps/ade-cli/src/services/sync/`): handoff is idempotent. Legacy peers that do not advertise renewal close at token expiry; capable peers have only the advertised short grace window. -Account Attention and push: +Account Activity and push: - `apps/ade-cli/src/services/push/pushPublisherService.ts` — derives one bounded machine contribution across every project hosted by the brain, @@ -1189,18 +1189,70 @@ Account Attention and push: desktop account-first read/ack/presence/preferences coordinator. It bypasses the selected project or remote-machine binding and uses the local machine runtime only as an explicitly labeled fallback. +- `apps/ade-cli/src/services/push/activityFingerprint.ts` — the two identities + every item carries. The *content* fingerprint is what the row looks like with + elapsed durations and token/file counters normalized away, so progress churn + does not rewrite account state; the *alert* fingerprint is the stable identity + of one phase entry, so a re-published item cannot re-alert a phone that + already heard about it. - `apps/desktop/src/shared/types/attention.ts` — cross-client item, snapshot, destination, availability, preference, and native-presentation contract. -- `apps/desktop/src/renderer/components/attention/` and - `apps/desktop/src/renderer/state/attentionStore.ts` — global header control, - compact drawer, full history center, account-switch/revision-safe mutations, - and renderer-to-native snapshot feed. + `ATTENTION_CONTRACT_VERSION` is the *item* contract; the publish protocol + version is separate (see `push-notifications.md`). +- `apps/desktop/src/shared/activityCatalog.ts` — one table naming every + Activity event: its group (agents / pull requests), its icon key, and its + default delivery policy. Desktop settings, the Activity columns, and the + delivery defaults read this instead of each keeping a private switch. +- `apps/desktop/src/renderer/state/activityStore.ts` — the renderer's account + snapshot, with account-switch and source-revision fences on every mutation. +- `apps/desktop/src/renderer/components/activity/useActivitySync.ts` — the + single account poller, mounted in `AppShell` so the header control and ADE + Notch stay truthful while `/activity` is closed. It also derives the notch + toast stream. +- `apps/desktop/src/renderer/components/activity/HeaderActivityControl.tsx` — + the global-header count and its popover preview of both buckets. +- `apps/desktop/src/renderer/components/activity/ActivityPane.tsx` — the + `/activity` two-column pane, with `ActivitySessionsColumn.tsx` (Needs you / + Working / Done, split per machine and divided where an offline machine's rows + become last-known state), `ActivityInboxColumn.tsx` (PR/CI and other + outcomes), `ActivityFilters.tsx` (machine / chat type / model, every option + derived from the snapshot on screen), and `ActivityDetailSheet.tsx`. +- `apps/desktop/src/renderer/components/activity/ActivityCard.tsx` and + `ActivityCardSkeleton.tsx` — the row and its fixed-height placeholder. The + card deliberately does **not** reuse `terminals/SessionCard`: an Activity row + frequently belongs to another machine, and `SessionCard`'s settle/snooze + controls call this Mac's local session service, where a non-unique session id + could land the mutation on a same-id local session. The status vocabulary is + shared instead through the pure `terminals/SessionStatusLabel.tsx`, extracted + from `SessionStatusSlot` for exactly this reason. Read the comment at the top + of `ActivityCard.tsx` before "simplifying" it. +- `apps/desktop/src/renderer/components/activity/activityPriority.ts` and + `activityPresentation.ts` — section assignment (Needs you / Working / Done) + and the per-item label/tone/glyph derivation. +- `apps/desktop/src/renderer/components/activity/useProgressiveRows.ts` — the + bounded row budget (60, stepped by 60) that keeps long columns cheap. +- `apps/desktop/src/renderer/components/activity/activityNotchLocalSettings.ts` + — this Mac's offline cache of the notch presentation. Account preferences win + when loaded. The three original `ade:attention:notch-*` localStorage keys are + frozen wire for anyone who already made a choice; new settings got new keys. +- `apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.tsx` — + the gear in both the popover and the pane. It mounts + `settings/ActivitySettingsControls.tsx` in its `popover` variant, which + `settings/ActivitySection.tsx` also mounts, so the Settings tab and the + in-surface gear cannot drift. Every row saves on change; there is no Save + button, which the popover it replaced did have. +- `apps/desktop/src/renderer/lib/legacyRoutes.ts` — `LEGACY_ROUTE_ALIASES` + maps `/attention` to `/activity`. ADE's shell matches top-level surfaces with + pathname predicates rather than `` elements, so there is no + router-level redirect to hang a rename on; this is the route-level twin of + `settingsManifest.ts`'s `LEGACY_TAB_ALIASES`. - `apps/desktop/src/renderer/webclient/adapter/attention.ts` — direct browser account-relay reader plus signed-out paired-host fallback through `attention.getMachineSnapshot` / `attention.acknowledgeMachine`. -- `apps/ade-cli/src/tuiClient/attentionPane.ts` and - `components/AttentionPaneView.tsx` — ADE Code's machine-global `/attention` - pane and exact-destination acknowledgment flow. +- `apps/ade-cli/src/tuiClient/activityPane.ts` and + `components/ActivityPaneView.tsx` — ADE Code's machine-global `/activity` + pane and exact-destination acknowledgment flow. The hidden `/attention` + alias and `attention.call` RPC remain for compatibility. - `apps/push-relay/src/attention.ts` and `attentionAuth.ts` — account merge, Clerk verification, acknowledgments, presence/preferences, APNs fan-out, and one account-wide Live Activity per phone. diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index e8348fe4d..2d92fc611 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -269,8 +269,22 @@ apps/ios/ │ │ ├── ADEAgentActivityAttributes.swift # account-wide ActivityKit │ │ │ # content-state + exact machine links + │ │ │ # non-PII ownership-epoch fence +│ │ ├── ActivityRowPresentation.swift # pure item → label/tone/glyph/elapsed +│ │ │ # mapper; the iOS mirror of desktop +│ │ │ # sessionStatusPresentation.ts + +│ │ │ # activityPresentation.ts. No SwiftUI — +│ │ │ # tones are tokens. Compiles into the +│ │ │ # widget extension, so iOS 17 only. +│ │ ├── ActivityWidgetPresentation.swift # tone → colour binding and the +│ │ │ # lock-screen ranking, shared by the app and +│ │ │ # the widget so the two cannot describe the +│ │ │ # same session differently. iOS 17 only. │ │ └── AttentionActionIntents.swift # widget actions for approve/deny/restart/retry │ ├── Views/ +│ │ ├── Activity/ # ActivityDrawerSheet (global account-wide +│ │ │ # Sessions/Inbox drawer), ActivityDrawerModel +│ │ │ # (snapshot + local dismissals + acks), +│ │ │ # ActivityRow, ActivityBellButton │ │ ├── Account/ # account choice/sign-in plus the mobile │ │ │ # access gate and connections section │ │ ├── Components/ # ADEDesignSystem (incl. ADEConnectionDot, @@ -294,7 +308,10 @@ apps/ios/ │ │ │ # (HubInlineComposer — inline keyboard │ │ │ # composer, not a modal drawer), │ │ │ # HubScreen+ChatNavigation (chat open + -│ │ │ # cross-project quick look) +│ │ │ # cross-project quick look), +│ │ │ # HubLiveStrip ("Live now" — agents working +│ │ │ # across every account machine, read from +│ │ │ # ActivityDrawerModel; hidden when empty) │ │ ├── PersonalChats/ # Hub-only projectless chat list, │ │ │ # new-chat model composer, and reused │ │ │ # Work transcript destination adapter @@ -380,7 +397,16 @@ apps/ios/ │ │ │ # WorkSessionDestination*, │ │ │ # WorkRootScreen+Selection (multi-select state + │ │ │ # bulk close/archive/restore/delete/export), -│ │ │ # WorkSelectionActionBar, etc. +│ │ │ # WorkSelectionActionBar, +│ │ │ # WorkLaneOrder (pure lane ordering + the +│ │ │ # singleton/headerless rule; the port of +│ │ │ # desktop workLaneOrder.ts and the +│ │ │ # headerlessLaneIds memo. Models manual +│ │ │ # drag and handoff jobs even though iOS +│ │ │ # has neither yet — they are the two rules +│ │ │ # that decide whether a lane keeps its +│ │ │ # header, and dropping them is how a port +│ │ │ # silently loses a rule later), etc. │ │ ├── Linear/ # LinearPaneSheet, issue list/detail screens, │ │ │ # launch config, brand/logo paths, pane store │ │ │ # and toolbar button. Uses existing cto.* read @@ -1264,7 +1290,7 @@ contract) is documented in signed-in or paired phone registers ActivityKit's push-to-start token even when ordinary notification permission is denied and no alert APNs token exists. Signed-in phones send APNs and push-to-start routing directly to the - account Attention relay. `AccountService` serializes + account Activity 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 @@ -1295,7 +1321,7 @@ contract) is documented in tap while the app is dead queues in the registry and drains on the next 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 +- **App-icon badge.** Account Activity 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 @@ -1352,20 +1378,22 @@ contract) is documented in - `UIImpactFeedbackGenerator` and `UINotificationFeedbackGenerator` on message send, intervention approval, worker launch, PR merge. -### Attention Drawer +### Activity drawer -Source: `apps/ios/ADE/Views/AttentionDrawer/`. +Source: `apps/ios/ADE/Views/Activity/`. -The navigation-bar bell opens one global account-wide Attention Center -(`AttentionDrawerSheet`). The signed-in app reads the Clerk-authenticated +The navigation-bar bell opens one global account-wide Activity drawer +(`ActivityDrawerSheet`). 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. +The sheet has two buckets: **Sessions**, ordered as Needs you → Working → Done, +and **Inbox** for PR/CI work and unseen outcomes. It includes project filtering +and machine/project context on every item, and remains useful when the phone is +account-signed-in but not directly paired to a machine. Rows can be dismissed +with a swipe; account fallback and offline states remain explicit. Each row uses the shared item destination and actions: @@ -1375,8 +1403,19 @@ Each row uses the shared item destination and actions: - **CI failing / review requested / merge ready** — exact PR tab navigation. - **Completed / merged** — retained in Recent until seen or dismissed. +Row vocabulary is derived once, in `Shared/ActivityRowPresentation.swift` — a +pure item-to-label/tone/glyph/elapsed mapper with no SwiftUI in it — and the +tone-to-colour binding plus the lock-screen ranking live beside it in +`Shared/ActivityWidgetPresentation.swift`. Both compile into the widget +extension as well as the app, which is what keeps the lock screen from +describing a session in words and colours the app does not use; it also means +both files are pinned to the extension's iOS 17 deployment target. The Hub's +"Live now" strip (`Views/Hub/HubLiveStrip.swift`) is a third reader of the same +model, showing agents working on any account machine and hiding itself entirely +when none are. + The drawer uses the same one-hue-one-meaning contract as the widget and desktop -sidebar. `blocked` is a neutral Live item with an Open action, distinct from the +Activity pane. `blocked` is a neutral Working item with an Open action, distinct from the amber `awaitingInput` kind; running uses the shared dotted-circle glyph, and a stale run says `Stale` with a clock rather than claiming the host is offline. @@ -1634,7 +1673,7 @@ rows use the hollow status ring, lower opacity, stay openable, and render `statusNote` as `done: …`; an explicit attention request instead puts its question in the preview line. Ready/idle rows remain in Your move without a capsule, while `Needs you` is the loud tier used by awaiting counts, the -attention drawer, push, and attention-first roster behavior. A settled chat + Activity drawer, push, and attention-first roster behavior. A settled chat woken by unattended scheduled work shows Running during the turn and returns to Settled at idle because only user activity clears its declaration. @@ -2609,7 +2648,7 @@ different machine's cached limits. reachable host no longer lists as pending. It never drops while the host is unreachable or the refresh came back empty, so a transient gap cannot erase a genuinely queued message. -- **`AttentionDrawerModel.clearVisibleItems()` persists dismissals +- **`ActivityDrawerModel.dismissVisible(in:)` persists dismissals scoped to the active id set.** Ids are stored under `ade.attention.dismissedItemIDs` and pruned on every rebuild against the live active set, so a chat that re-enters diff --git a/docs/features/sync-and-multi-device/push-notifications.md b/docs/features/sync-and-multi-device/push-notifications.md index 069a868b3..8e91292f9 100644 --- a/docs/features/sync-and-multi-device/push-notifications.md +++ b/docs/features/sync-and-multi-device/push-notifications.md @@ -1,16 +1,18 @@ -# Attention, notifications, and Live Activities +# Activity, notifications, and Live Activities -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 +ADE uses one account-wide Activity stream for agent work and pull requests +across every signed-in machine and project. Desktop Activity, ADE Notch, the +iOS Activity drawer, 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**. +The product name for the shared system is **Activity**. The compact native +macOS presentation is **ADE Notch**. Compatibility contracts still use +`attention` names, including `AttentionItem`, relay routes, IPC channels, +persistence fields, analytics/log identifiers, and the native helper product. ## Product rules -- Running work is ambient. It belongs in Attention, ADE Notch, widgets, and +- Running work is ambient. It belongs in Activity, 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. @@ -53,7 +55,7 @@ account/device preferences. Exact destinations still identify the owning machine, project, session, event, or PR tab. The legacy paired-machine push routes remain available for older clients. Once -an account Attention publish succeeds, the brain suppresses duplicate legacy +an account Activity publish succeeds, the brain suppresses duplicate legacy alerts and the legacy per-machine Live Activity. ## Shared contract @@ -63,7 +65,8 @@ The TypeScript source of truth is An `AttentionItem` includes: -- stable `id`, source `revision`, `fingerprint`, occurrence/update/expiry time; +- stable `id`, source `revision`, occurrence/update/expiry time; +- two fingerprints and an activity tier (see below); - kind, event, and phase; - machine and project identity; - optional lane, provider, model, plan progress, and recent activity; @@ -73,8 +76,32 @@ An `AttentionItem` includes: 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: +### Two fingerprints and the activity tier + +An item carries a **content** fingerprint and an **alert** fingerprint, derived +in `apps/ade-cli/src/services/push/activityFingerprint.ts`. They answer two +different questions and are deliberately not the same value: + +- The content fingerprint is *what the row looks like* — identity, phase, lane, + provider, model, title, destination, action ids, plan progress, and the + preview with elapsed durations and token/file counters normalized away. A + running agent whose preview ticks from "12s" to "13s" therefore produces an + unchanged snapshot, and the relay writes nothing. +- The alert fingerprint is *the stable identity of one phase entry* — for a PR, + the item, event, phase, `statusSince`, and PR number. It survives the item + being removed and republished, which is what stops a reconnecting machine + from re-alerting a phone about work it already announced. + +`activityTier` (`signal` / `ambient` / `idle`) is the item's own claim about +whether it is worth interrupting for. Only `signal` items are eligible to +notify. Legacy publishers omit both fingerprints and the tier; the relay falls +back to the single `fingerprint` for each and treats a missing tier as +alertable. + +Contract version 1 (`ATTENTION_CONTRACT_VERSION`) limits text, actions, +progress counts, snapshots, and tombstones before data is stored or delivered. +It versions the *item shape*; the publish protocol is versioned separately (see +"Publish protocol 2" below). 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 @@ -140,6 +167,8 @@ POST /attention/account/ack POST /attention/account/presence GET /attention/account/preferences PUT /attention/account/preferences +PATCH /attention/account/preferences/devices/:deviceId +PATCH /attention/account/preferences/machines/:machineKey PUT /attention/account/devices/:deviceId DELETE /attention/account/devices/:deviceId PUT /attention/account/devices/:deviceId/activities/:activityId @@ -181,6 +210,36 @@ The publisher: - skips duplicate legacy notifications and Live Activities after a successful account publish. +### Publish protocol 2 + +Every publish response carries a `protocol` number, and the publisher records +the highest one the relay has reported. Protocol 2 replaces "always send the +whole machine" with three modes on `POST /machines/:machineKey/attention`: + +| Mode | When | What it sends | +| --- | --- | --- | +| `reconcile` | first publish after start, after an account change, and after any cap shrink | the full roster, paged, with `final: true` on the last page | +| `delta` | ordinary changes | only the items that changed, paged if they exceed one wire page | +| `presence` | the 30 s heartbeat with nothing to say | no items — it exists to hold presence and to let a due alert retry | + +Each publish stamps a monotonic `rosterEpoch`. A `reconcile` run bumps the +epoch, and its `final` page seals it: anything still carrying an older epoch for +that machine is state the machine no longer claims, so it is removed in one +commit rather than by inference from an absent id. A `delta` reuses the current +epoch and therefore never implies a deletion, which is what makes it safe to +send a partial list at all. + +The relay echoes current acknowledgment state (`acks`) on every publish, +including the no-op paths, so a brain that came back from a disconnect learns +what other devices already dismissed without waiting for its own read. If the +account item cap truncates the publish, the response says `itemsTruncated` and +the publisher schedules a fresh reconcile rather than leaving the relay holding +a silently trimmed roster. + +A relay that reports `protocol` below 2 does not understand any of this. The +publisher notices, falls back to the legacy full-snapshot publish, and keeps a +reconcile pending so the first protocol-2 response resynchronizes cleanly. + The paired-machine compatibility publisher tracks Live Activity delivery per phone. A failed start, update, or end retries only that phone while healthy phones continue receiving new content, and relay suppression is keyed per @@ -198,7 +257,10 @@ Balanced defaults: | Review requested / merge ready | Notify | | Completed / merged / opened / closed | Ambient | -Preferences support account defaults plus device and project overrides: +Preferences support account defaults plus device, project, and machine +overrides. The `machines` scope is keyed by machine key and is what "mute this +Mac" writes: it silences one machine's items everywhere rather than muting a +category on one phone. Its size is capped like the other scopes: - event delivery policies; - notifications; @@ -225,12 +287,29 @@ 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. +Two gates run before any preference is consulted, because they are about +whether the item deserves an interruption at all: + +- **Tier.** An item whose `activityTier` is not `signal` never alerts. +- **Staleness.** An item whose `updatedAt` is more than 15 minutes old never + alerts. This is what makes a reconnect safe: a machine that was offline + republishes its roster, and none of that recovered backlog fires a push. + +Notification delivery is then deduped twice. A short-lived per +item/device/state delivery receipt claims the send, so two concurrent publishes +cannot both notify. Behind it, a durable **alert log** keyed by account + alert +fingerprint + device records what each phone was actually told, and is retained +for 30 days — well past the item's own lifetime. Deleting and republishing an +item therefore cannot re-alert, which the receipt alone could not prevent +because receipts are keyed by item id and pruned at 7 days. + +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. Alert pushes also carry +`content-available`, so the visible alert doubles as a background wake for a +snapshot refresh — foreground polling remains the guaranteed path, not this. -## Desktop Attention +## Desktop Activity `AttentionAccountCoordinator` in Electron main owns desktop reads and mutations. For a signed-in user it talks directly to the account relay; it does @@ -246,24 +325,25 @@ the same local-only path and offers sign-in. If neither source is safe, the surface reports which component failed and how to recover rather than inventing an empty account. -`useAttentionSync` remains mounted in `AppShell`, so the global-header control -and ADE Notch stay truthful across project switches and while `/attention` is +`useActivitySync` remains mounted in `AppShell`, so the global-header control +and ADE Notch stay truthful across project switches and while `/activity` is closed. The header count represents work waiting on the user (`needs_you`, failed/blocked, and done-but-unreviewed); live work is an ambient pulse rather -than an inflated inbox count. Its keyboard-accessible popover groups Needs you, -Failing or blocked, Done unreviewed, and Live now across machines/projects. The -full Attention Center is the secondary Open all/history destination. +than an inflated inbox count. Its keyboard-accessible popover previews the two +Activity buckets: Sessions, prioritized as Needs you → Working → Done, and +Inbox for PR/CI and other outcomes. Open all leads to the larger two-column +Activity pane with filters, settings, and an exact item-detail sheet. The full route provides: -- Needs-you/inbox, live, and recent views; +- Sessions and Inbox columns, with Needs you, Working, and Done session groups; - 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 +Presence reports include foreground state, whether an ambient Activity surface is visible, and the currently visible item ids. They are posted every 30 s while the ADE window is visible and every 120 s while it is hidden, plus immediately on focus, on blur, and on becoming visible again: a hidden window still has to @@ -282,12 +362,12 @@ stream it is standing in for. Above it, the renderer races a 75 s backstop, sized to clear a 15 s relay request, one forced 401 retry, and that 30 s fallback in sequence — a shorter race would discard a slow-but-successful snapshot and replace a real host error with a generic timeout. When the backstop -wins, Attention reports that it took too long and offers a retry instead of +wins, Activity reports that it took too long and offers a retry instead of leaving the header pinned on syncing. -## Hosted web Attention +## Hosted web Activity -The hosted browser adapter reads account Attention directly from the relay with +The hosted browser adapter reads account Activity directly from the relay with its in-memory Clerk access token, independently of the paired machine and selected project used for Work, Files, and PR commands. It validates the entire snapshot/preferences contract at the network boundary and performs at most one @@ -300,15 +380,16 @@ from their explicitly paired host through the viewer-allowed revision. An older host that lacks those actions produces an Update host state; the adapter never converts an unsupported call into an empty list. If the browser account changes after a snapshot loads, opening or acknowledging that -snapshot is rejected until Attention refreshes under the new owner. +snapshot is rejected until Activity refreshes under the new owner. -## ADE Code Attention +## ADE Code Activity -`/attention` opens an account-wide right pane grouped by needs-you, failures, +`/activity` opens an account-wide right pane grouped by needs-you, failures, done-but-unreviewed, live, and recent work. The TUI calls machine-global `attention.call`, not the selected project's action scope, so changing lanes or projects does not change the account source. Enter opens the exact ADE destination first and only then sends the revision/owner-fenced seen mutation. +`/attention` remains an unadvertised compatibility alias. When signed out, ADE Code asks the connected host for its real machine snapshot and labels the subset. Account failure may degrade to that same connected-host @@ -368,7 +449,7 @@ Interaction rules: - Reveal on hover is dormant until the pointer enters a bounded top-edge or status-item hot zone; Click only never grows on hover; - right-click anywhere on the physical surface or the menu-bar item opens the - same native menu: Open Attention Center, Refresh, presentation mode, expanded + same native menu: Open Activity, Refresh, presentation mode, expanded panel policy, and a confirmed Hide action with restore guidance; - ordinary running work and needs-you changes update status without overriding the user's reveal policy; @@ -381,12 +462,27 @@ Interaction rules: The helper sends open and acknowledgment requests back through typed IPC. Exact ADE destinations are validated before the desktop navigates. -## iOS Attention Center +## iOS Activity drawer 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 +A signed-in app polls the account snapshot every 20 s while it is foreground, +and stops on background or sign-out. Each start bumps a generation counter that +the loop rechecks after every sleep, so repeated starts cannot leave two pollers +running and a stopped poller cannot resume after its account changed. This poll +is the guaranteed freshness path; the `content-available` flag on alert pushes +is an opportunistic wake on top of it, not a substitute. + +Acknowledgments made while the relay is unreachable go to an App Group-backed +**pending-ack queue** partitioned by account owner, and drain on the next +successful refresh. Reads normalize duplicate item ids, so a crash between +enqueue and cleanup cannot multiply relay writes. The queue is bounded three +ways — 200 entries per owner, 24 hours of age, and 5 failed attempts per entry — +so an acknowledgment the relay will never accept expires instead of retrying +forever. + +The global Activity drawer 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. diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index e221ca2c8..b5544fb79 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -247,7 +247,7 @@ Shared types and IPC: null (iOS) send the string. - `apps/desktop/src/shared/sessionStatusPresentation.ts` — the shared phase-to-label/glyph/tone/prominence vocabulary consumed by the Work sidebar - and Attention Center and mirrored by iOS widgets/Attention Drawer. Blue means + and Activity surfaces and mirrored by iOS widgets/Activity drawer. Blue means work in flight, amber is reserved exclusively for `Needs you`, emerald is a clean unseen outcome, red is failure, and neutral is true but non-actionable. It also owns the short working-duration formatter; renderer icon components @@ -296,9 +296,16 @@ Shared types and IPC: clamped to the viewport like `SessionContextMenu`, with no document-level listener. Already-snoozed rows offer **Wake now** instead of the duration list. +- `apps/desktop/src/renderer/components/terminals/SessionStatusLabel.tsx` — + the pure label half of the slot below: shared glyph id to Phosphor icon, tone + class, and the elapsed/countdown text. It was extracted so the account-wide + Activity card can speak the same status vocabulary **without** inheriting the + slot's mutation controls, which act on this Mac's local session service and + would be wrong — sometimes destructively so — on a row that belongs to + another machine. Anything both surfaces must agree on belongs here. - `apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx` — the row's single status surface and no-layout-shift hover/focus action swap. - It maps shared presentation glyph ids to Phosphor icons, ticks active + It renders `SessionStatusLabel` and adds the mutations: it ticks active chat Working/Planning elapsed time from immutable `currentTurnStartedAt`, falling back to last activity for legacy rows, keeps CLI/Stale elapsed time on last activity, ticks idle scheduled-work countdowns, and diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md index 2b473096a..99b04fb9f 100644 --- a/docs/features/terminals-and-sessions/ui-surfaces.md +++ b/docs/features/terminals-and-sessions/ui-surfaces.md @@ -320,6 +320,12 @@ Failed keep full weight. Only canonical `needs_you` contributes to the Work-tab highlight, notifications, and Dock badge. `useAppWideSessionAttention` owns that count at `AppShell`, so it remains live outside Work. +The same canonical session phase feeds the account-wide Activity UI: its +header popover and two-column pane group sessions as Needs you, Working, and +Done, while ADE Notch can toast the highest-priority transition. The hook and +wire-level `attention` vocabulary remain compatibility names; user-facing +surfaces call the feature Activity. + ## Work view: `WorkViewArea.tsx` Owns the render target for open sessions. Supports three modes tied to diff --git a/docs/features/web-client/README.md b/docs/features/web-client/README.md index 0dcdc6b59..813af6e8f 100644 --- a/docs/features/web-client/README.md +++ b/docs/features/web-client/README.md @@ -365,11 +365,15 @@ Reused desktop renderer (web-mode adaptation): `WelcomeVideoGate.tsx`) reads this flag to hide native window controls, the updater, the onboarding tour, and tabs with no sync-protocol backing instead of rendering broken affordances. -- `apps/desktop/src/renderer/components/attention/HeaderAttentionControl.tsx` - and `AttentionCenter.tsx` - the project-independent header drawer and its - secondary Open all/history route. Attention is a global utility route, not +- `apps/desktop/src/renderer/components/activity/HeaderActivityControl.tsx` + and `ActivityPane.tsx` - the project-independent header popover and the + expanded pane its "Open all" raises. Activity is a global utility surface, not another selected-machine tab, so it is intentionally separate from - `WEB_CLIENT_TAB_PATHS`. + `WEB_CLIENT_TAB_PATHS`. Its `/activity` pathname (and the `/attention` name it + replaced) is a deep link that opens the pane over the current tab; both are in + `APP_ROUTE_ROOTS` so a hard reload keeps it. The notch has no web counterpart, + so `attentionNotch` is listed in `WEB_HIDDEN_CAPABILITIES` and its settings + rows are hidden rather than rendered inert. - `apps/desktop/src/renderer/components/app/TopBar.tsx` and `ConnectionsPanel.tsx` - the single desktop Connections control and its Machines, Phone, and Web tabs. The Web tab reports connected browser peers @@ -786,7 +790,7 @@ refresh hints rather than replicated state. Because there is no local replica, project/runtime reads are live transport round-trips to the active project binding's machine — where the desktop renderer would hit its -in-process cr-sqlite. Account Attention is the deliberate exception: a +in-process cr-sqlite. Account Activity is the deliberate exception: a signed-in browser reads the consolidated push-relay stream directly, so changing the active machine/project cannot narrow or block the account inbox. Two adapter-side measures keep ordinary machine reads from turning routine UI into diff --git a/docs/logging.md b/docs/logging.md index 25cf76b5a..634faea76 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -134,8 +134,9 @@ blocked reasons, retries, or scheduled review scans. The existing `ade_feature_used` limits cap it at 30 accepted events per minute and 140 per UTC day without raising the shared 200-event ceiling. -Opening the account-wide Attention control records the existing -`ade_feature_used` event with `feature: "attention"`, +Opening the account-wide Activity control (renamed from "Attention" in the UI; +the analytics taxonomy deliberately keeps the frozen `attention` keys) records +the existing `ade_feature_used` event with `feature: "attention"`, `action: "header_opened"`, `outcome: "opened"`, and `source: "renderer_route"`. The renderer emits no item, machine, project, session, notification, or error data. A persisted one-hour deduplication key