Compare commits

..
Author SHA1 Message Date
buzzert ff48a25186 ios: simplify Agent transcript positioning and entrances
TestFlight / Build and upload (push) Successful in 1m59s
2026-10-16 13:58:04 -07:00
buzzert e681e9e05c ios: let native scrolling handle Agent keyboard resizing
TestFlight / Build and upload (push) Successful in 1m49s
2026-10-16 12:37:04 -07:00
buzzert d5427926d5 ios: render Agent history lazily
TestFlight / Build and upload (push) Successful in 1m49s
2026-10-16 12:10:29 -07:00
buzzert bbaa5e8859 ios: isolate Agent input and read observation 2026-10-16 11:51:07 -07:00
34 changed files with 602 additions and 1358 deletions
+1 -24
View File
@@ -605,25 +605,6 @@ durable entries and can advance without visible messages. Use returned cursors;
do not derive them from message IDs. `limit` bounds source entries, not displayed
messages.
Every room message, including protected tool rows and `activity.queuedMessages`,
has a string `id`, `role`, `content`, and ISO-8601 `createdAt`. Privacy filtering
retains this envelope and tool lifecycle metadata (`kind`, `toolCallId`,
`toolName`, `entryId`, `status`, and available `startedAt`/`completedAt`). Protected
tools use their public tool name as `content`; private arguments, previews, and
errors are withheld. Result-only pages may omit initiation metadata.
Historical entries without a known time use `1970-01-01T00:00:00.000Z` as an
unknown-time sentinel. For compatibility with older partial responses, clients
may recover missing/invalid `createdAt` from `metadata.startedAt`, then
`metadata.completedAt`, or use that sentinel; never substitute the current time.
Older redacted tool rows containing only `id`, `role`, and `name` can retain their
identity with a tool-name label until a complete upsert arrives. The web client
uses the same normalization for REST, WebSocket, and queued activity, retains
known times across partial upserts, and orders by time, numeric `entryId` (when
present, before absent), then stable `id`. Unknown times display as “Earlier”
without a clock time. Invalid identities or non-tool message bodies reject the
page before advancing its replay cursor; timestamps never replace cursors.
### `POST /v1/agent/messages`
Body: `{ "content": "Check the oven", "requestId": "client-generated-uuid" }`.
@@ -722,10 +703,6 @@ All push/config/presence routes require a configured `ADMIN_TOKEN` and bearer au
`vapidPublicKey` is present only when Web Push is enabled and its credentials are valid. No private keys, tokens, paths or configured account identifiers are returned. Source discovery and browser presence run independently of transport enablement/installation consent. Clients bind local consent to a known source UUID as well as the authenticated backend; null/unknown source cannot opt in. A source change retires prior local consent/installation and requires a new UUID plus explicit user opt-in, rechecking the source after asynchronous permission/subscription/registration work. `sourceId` is the persisted bound runtime UUID or null before discovery. A changed runtime source or regressed durable cursor quarantines delivery and cancels pending alerts without resetting the canonical read cursor. There is deliberately no HTTP read-reset/rebaseline endpoint; an operator must reconcile replacement runtime data with source-aware clients before enabling a new baseline.
`webPushAvailable` describes the **server transport**, not the browser or device's push service. Browser permission `granted` and exposed Push APIs also do not prove that a subscription can be created. The web client distinguishes insecure/unsupported contexts, iOS Home Screen requirements, permission dismissal/denial, worker startup, push subscription errors/timeouts, local storage failures, and authenticated server failures. HTTP 401/403 require sign-in recovery; 400/413 mean a rejected subscription; 409 requires a new installation identity; 429 means retry later; 503 indicates server availability/configuration. These are client diagnostics for existing statuses, with no endpoint or response-shape change. Raw provider messages, response bodies, subscription endpoints and credentials must never be echoed to the UI. Background presence/config refreshes preserve a failed opt-in's diagnosis until an explicit retry or refresh.
For Android/GrapheneOS setup and device verification limits, see [mobile web QA and notification guidance](../mobile-web-qa.md). Vanadium's Chromium Android push transport depends on Google Play services; a website cannot supply an alternative browser push transport or reliably detect installed Android packages. Keep this distinct from server configuration errors.
### `PUT /v1/agent/push/installations/:installationId`
Use a stable random UUID per local installation, authenticated with the current shared token. Registration is explicit user opt-in and requires enabled/configured transport. Native body:
@@ -786,7 +763,7 @@ The web service worker accepts bounded nonempty preview strings, preserving the
Shipping is disabled by default: `AGENT_PUSH_ENABLED=false`. Native transport requires `AGENT_PUSH_APNS_KEY_FILE` (APNs-enabled ES256 `.p8`, regular secret file 0400/0600), `AGENT_PUSH_APNS_KEY_ID`, `AGENT_PUSH_APNS_TEAM_ID`, optional `AGENT_PUSH_APNS_TOPIC` (default `net.buzzert.sybil2`) and `AGENT_PUSH_APNS_ENVIRONMENT` (`production` by default, or `sandbox`; exactly one credential environment per deployment). Web Push requires `AGENT_PUSH_VAPID_FILE`, a private regular JSON file 0400/0600 containing `publicKey`, `privateKey` (base64url P-256) and `subject` (mailto/HTTPS contact). Invalid/missing/unsafe files leave only that adapter unavailable; files and parse errors are never exposed. This implementation neither creates production keys nor changes Apple portal capabilities. Correct native team/topic is `DQQH5H6GBD` / `net.buzzert.sybil2`; parent portal work, push entitlements/provisioning and real device registration are separate prerequisites. An environment-scoped production key cannot send sandbox notifications: sandbox registrations are rejected when production is configured. Concurrent sandbox/production credentials require a future explicit per-environment credential map; do not reuse one scoped key across environments.
Backend subscribes to the token-authenticated private runtime WebSocket `GET /api/notification-events`. Its initial and coalesced `{type:"notification_changed",version:1}` frames trigger cursor-based reconciliation through `GET /api/notification-context?after=<cursor>&limit=200`; reconnects recover missed changes from the saved durable cursor. The socket uses bearer header authentication, rejects query strings, carries no content, and uses ping/pong without database access. There is no fixed two-second source poll. Local presence/read/prompt changes also wake reconciliation. One-shot timers cover debounce, grace and lease expiry, provider retry and abandoned send intents; idle maintenance runs hourly. Source failures retry with bounded backoff (1–30 seconds). The coordinator still fetches fresh context and checks every eligibility condition before each provider boundary. The backend sends `X-Sybil-Client: backend-proxy-v1` on private proxy REST/WS traffic and `backend-observer-v1` on the notification observer. The marker classifies the proxy for the native attention census and never replaces runtime authentication. The runtime source is version 1 with `{sourceId,conversationId,cursor,hasMore,latestUserMessageAt,nativeReadCursor,uncertain,nextChangeAt?,webPresence:{active,unknown,observedAt},candidates:[{messageId,cursor,createdAt,trigger}]}`; trigger is `user|external_event|timer|worker|unknown`. `nextChangeAt` is an optional nullable ISO deadline for the next runtime attention/uncertainty expiry; pending work schedules a refresh then and earlier events reschedule it. No transcript text is included. `uncertain` and `webPresence.unknown` fail closed. Candidate pages preserve all candidates belonging to each consumed cursor (max 200 candidates/page), advancing across non-candidates; omitted `after` is high-water bootstrap with no old candidates. The source identity survives normal runtime restarts. If an incremental context request returns 409 because its cursor exceeds a replaced/restored runtime high-water mark, the backend makes exactly one authenticated no-after probe under the same 10-second deadline. Only a candidate-free, nonpaginated snapshot proving a different source or lower cursor is accepted, solely to quarantine and cancel old pending alerts. It never resets reads/checkpoints, rebaselines, or enumerates historical candidates. Failed/invalid probes and same-source nonregressions remain unavailable and suppress delivery. Runtime native presence/read is an acknowledgment input to the existing canonical backend read state, not a second independently authoritative read store.
Backend independently polls token-authenticated runtime `GET /api/notification-context?after=<cursor>&limit=200` and sends `X-Sybil-Client: backend-proxy-v1` on private proxy REST/WS traffic. The marker classifies the proxy for the native attention census and never replaces runtime authentication. The runtime source is version 1 with `{sourceId,conversationId,cursor,hasMore,latestUserMessageAt,nativeReadCursor,uncertain,webPresence:{active,unknown,observedAt},candidates:[{messageId,cursor,createdAt,trigger}]}`; trigger is `user|external_event|timer|worker|unknown`. No transcript text is included. `uncertain` and `webPresence.unknown` fail closed. Candidate pages preserve all candidates belonging to each consumed cursor (max 200 candidates/page), advancing across non-candidates; omitted `after` is high-water bootstrap with no old candidates. The source identity survives normal runtime restarts. If an incremental context request returns 409 because its cursor exceeds a replaced/restored runtime high-water mark, the backend makes exactly one authenticated no-after probe under the same 10-second deadline. Only a candidate-free, nonpaginated snapshot proving a different source or lower cursor is accepted, solely to quarantine and cancel old pending alerts. It never resets reads/checkpoints, rebaselines, or enumerates historical candidates. Failed/invalid probes and same-source nonregressions remain unavailable and suppress delivery. Runtime native presence/read is an acknowledgment input to the existing canonical backend read state, not a second independently authoritative read store.
For an otherwise dispatchable message only, the backend resolves its text through the existing authenticated root public endpoint: `GET /api/conversations/root/messages?after=<candidate.cursor-1>&limit=1&includeTools=false`. The URL/path is fixed to the configured runtime, redirects are rejected, the response is limited to 512 KiB and the request to 10 seconds. The page must match the observed root conversation and exact entry cursor; exactly one message must match the candidate ID, assistant role, `metadata.kind:"message"`, `status:"complete"` and entry ID. Only that message's string `content` is used. The runtime's existing root-candidate/provenance rules remain authoritative; a `worker` trigger qualifies only a later public root reply, never the worker's own output. Source/read/presence and all delivery conditions are rechecked after lookup and before committing send intent. Preview text is not persisted in backend outbox/receipt rows. No runtime change or database migration is required.
-11
View File
@@ -326,14 +326,6 @@ with `metadata.kind: "tool_call"`, matching Sybil's tool renderer. REST pages an
socket messages are upserts keyed by stable `id`. Save each returned cursor after
applying its entire frame, even when it contains no visible messages.
Both message frames and queued activity use the required message envelope and
historical timestamp compatibility rules in [REST room history](./rest.md#get-v1agentmessages).
Protected tool redaction preserves `createdAt`, a safe string `content`, and
available lifecycle metadata; it does not remove the row's identity or replay
metadata. Partial tool results may omit `args`/`startedAt`; merge them without
losing a known creation time or downgrading a completed tool on replay. Validate
and normalize all rows before applying a frame or advancing its cursor.
Clients may send `{ "type": "ping" }` (receiving `{ "type": "pong" }`) or
`{ "type": "subscribe", "after": "44" }` to change the replay cursor. Transport
ping/pong heartbeats detect dead connections. Other writes belong on REST.
@@ -402,6 +394,3 @@ Distinguish an unbound room from one already bound to a legacy absent/null sourc
Older clients may ignore unknown server frame types. `read_state` and presence are not client subscribe messages and are never forwarded to runtime as generic frames. Push consent, revoke, presence, retry/expiry and notification payload contracts live in [rest.md](./rest.md#agent-push-notifications-v1). The runtime built-in browser has its own authenticated presence/read inputs and bounded anonymous grace per runtime source. The separate 60-second restart safety intervals and source/clock uncertainty checks remain in effect. No Web Push is used for invisible read synchronization.
Enabled notifications include a bounded plain-text preview of the eligible completed root public assistant message by default, including on the lock screen when OS settings allow it. The backend resolves only that candidate's exact public `content`, never activity/reasoning/tool/worker fields or linked resources. APNs uses a native string `aps.alert.body`; Web Push uses `body`. This does not change message IDs, stream frames, read acknowledgment or clearing semantics. See the REST contract for Markdown flattening, payload budgets and web worker update ordering.
The backend notification coordinator has its own private, token-authenticated runtime WebSocket at `/api/notification-events`. It receives content-free `{type:"notification_changed",version:1}` invalidations, including an initial frame on every connection, and reconciles durable notification context from its saved cursor. This replaces the fixed two-second backend poll; it does not change public Agent message/read-state frames or require a client release. Native presence/read changes and local backend presence/read/prompt changes wake the coordinator. Pending notification deadlines use one-shot timers (including the runtime's additive nullable `nextChangeAt` field); idle maintenance runs hourly. Reconnection, source failure backoff, and fresh eligibility checks before delivery preserve cursor recovery and suppression behavior. WebSocket ping/pong performs no database operations.
-67
View File
@@ -1,67 +0,0 @@
# Mobile web QA — 2026-11-02
This change is limited to the web client and its tests/documentation, with no backend contract or native app release changes. This report records the focused mobile QA and remaining device verification limits.
## Reproduction and fixes
The production bundle was exercised with local API/socket fixtures in touch-enabled Chromium at 390×844. Before/after measurements use identical messages and viewport dimensions.
| Area | Before | After |
| --- | --- | --- |
| Installed display | Manifest preferred `fullscreen`, including `display_override` | `standalone`, no fullscreen override; normal Apple status-bar style |
| Horizontal table pan | `touch-action: pan-y` on the workspace; table stayed at `scrollLeft=0`, swipe created a new chat | Native table scrolled to `scrollLeft=160`, current chat retained |
| Regular assistant width | 304.30px of a 358px transcript at 390px viewport | 358px; desktop width rule retained |
| Agent message width | 287.52px | 287.52px; bubble/avatar styling retained |
| Compact model | Hidden | Provider and active model beneath title; updates after changing model in settings |
| Context menu | Assistant context-menu event canceled for a Fork-only menu | Native event allowed for text, links and composer; explicit message-actions button opens Fork |
| Zoom | Viewport disabled user scaling | Fixed-scale viewport retained (`maximum-scale=1.0`, `user-scalable=no`) |
| Notification failures | Browser exception names and server statuses replaced by a generic error; background refresh erased errors | Stage-specific, redacted recovery guidance, retained until explicit retry/refresh |
Text owns gestures across regular chat, search markdown and Agent. Dynamic horizontal scrollers and code/table/quote elements are protected even at both ends. Selections, multi-touch, long-press starts and touch cancellation abandon navigation tracking. Blank workspace chrome still supports sidebar/new-chat swipes. Notification help is bounded and scrollable; sidebar content remains reachable on shorter screens.
## Notification diagnosis and official sources
A read-only authenticated request to the running server's `GET /v1/agent/push/config` returned HTTP 200 with `enabled=true`, `webPushAvailable=true`, a known source, `quarantined=false`, and a public key present. Credentials/key values were not printed. This rules out disabled transport configuration at the time of inspection; it does not prove delivery or identify the actual phone's failed step.
Chromium's Android [GCM driver](https://chromium.googlesource.com/chromium/src/+/main/components/gcm_driver/android/java/src/org/chromium/components/gcm_driver/GCMDriver.java) uses [GoogleCloudMessagingV2](https://chromium.googlesource.com/chromium/src/+/main/components/gcm_driver/android/java/src/org/chromium/components/gcm_driver/GoogleCloudMessagingV2.java), which checks for `com.google.android.gms` and rejects registration when Google Play services is absent. Vanadium is GrapheneOS's Chromium browser, as documented in the [official browsing guide](https://grapheneos.org/usage#web-browsing). Missing/unreachable Play services is therefore a plausible cause on GrapheneOS; the old generic message cannot establish that cause.
The [official sandboxed Google Play guide](https://grapheneos.org/usage#sandboxed-google-play) requires dependent apps and Play in the same profile and recommends a battery optimization exception for background push. Check network access, Sybil's site notification permission and Vanadium's Android notification permission, then reload and explicitly enable again. Users choosing not to install Play need a browser with an independent push service. Sybil cannot replace that browser service or inspect installed Android packages, so it does not disable Vanadium based on user-agent detection.
Permission and subscription are separate: [`PushManager.subscribe()`](https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe) can fail after permission was granted. Diagnostics identify the failed layer and known exception name without echoing arbitrary browser/provider messages. No permissions are requested in the background; opt-in still requests permission synchronously from the user's click. Registration failures retain rollback and revocation behavior, including storage failure after browser subscription.
`standalone` can retain the device status bar, unlike fullscreen ([manifest display reference](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/display)). Existing installations may retain older manifest metadata until their browser updates it. Chrome documents [manifest update scheduling](https://web.dev/articles/manifest-updates); Vanadium installation/update behavior still needs device verification. The manifest URL, app ID, start URL and service-worker scope remain stable.
## Automated verification
- `npm run build` and `npm test` in `web`.
- `mobile-workspace.browser.mjs`: trusted CDP touch pans on tables, nested quotation code and code blocks; both scroll edges; selection/clipboard copy and text paste; uncanceled context-menu events; explicit Fork menu; synthetic multi-touch/long-press/cancel scenarios; both navigation directions; model change; widths 320, 390, 430 and 767; 1280px desktop check; 450px-height composer checks; unchanged Agent width; manifest and zoom settings. Screenshots were inspected.
- `agent-push-errors.browser.mjs`: mobile permission dismissal/denial, worker failure, `AbortError`, `NotAllowedError`, `NotSupportedError`, `InvalidStateError`, subscription timeout, HTTP 400/401/403/409/429/503, rollback, preserved error after background refresh, successful retry, missing APIs/insecure context, iOS Home Screen guidance, and reachable notification controls at 320×568 and 390×450. API/push-provider outcomes are mocked; worker registration is real except the deliberate failure case.
- Existing `agent-push.browser.mjs`: real worker activation/notification display/IndexedDB and read-close, explicit consent, source changes, multiple sessions, credential rotation, revocation and offline logout with mocked push provider/backend.
- Existing `pwa.browser.mjs`: production service-worker activation/reload and authentication flows.
Run browser checks after building, using an existing Playwright/Chromium installation. These fixtures intentionally reject non-loopback targets and mock backend/provider traffic.
```sh
cd web
npm run build
npm test
npm run preview -- --host 127.0.0.1 --port 5197 --strictPort
```
In another shell, with `PLAYWRIGHT_MODULE=/absolute/path/to/playwright-core/index.mjs` and `CHROME_EXECUTABLE=/absolute/path/to/chrome` exported:
```sh
cd web
node tests/mobile-workspace.browser.mjs
node tests/agent-push-errors.browser.mjs
node tests/agent-push.browser.mjs
node tests/pwa.browser.mjs
```
`MOBILE_ARTIFACT_DIR` saves layout screenshots. `PUSH_ERROR_SCREENSHOT` saves notification settings. `MOBILE_BASELINE=1` records the original layout/gesture failures against a separately served original build.
## Device verification limits
No physical GrapheneOS/Vanadium, Android Chrome, or iPhone/Safari device was available. Desktop Chromium touch emulation cannot verify Android system status-bar visibility, native long-press menu rendering/selection handles, actual pinch/keyboard behavior, installed-PWA metadata updates or background FCM delivery. Clipboard tests use the browser engine's keyboard copy/paste and synthetic context-menu dispatch; cancellation/selection assertions are not a claim that Android's system menu was displayed. A shorter viewport approximates keyboard space, without emulating the keyboard itself.
On the user's device after an authorized deployment: check the installed status bar and fixed-scale viewport, pan both ways through wide tables/quotes/code without navigation, long-press/copy/paste, change model, then explicitly enable notifications and inspect the new error if registration fails. Verify actual background delivery with all Sybil web sessions inactive. The underlying phone-specific notification failure remains unconfirmed until that check.
@@ -1,30 +1,37 @@
import SwiftUI
import Observation
struct SybilAgentView: View {
@Bindable var room: SybilAgentRoom
var room: SybilAgentRoom
var topInset: CGFloat = 0
@Environment(\.scenePhase) private var scenePhase
@State private var latestRequest = 0
var body: some View {
VStack(spacing: 0) {
AgentTranscriptView(room: room, topInset: topInset, latestRequest: latestRequest)
AgentStatusBar(room: room)
AgentComposer(room: room) { latestRequest += 1 }
}
.onAppear { room.isSelected = true }
.onDisappear { room.isSelected = false }
}
}
// Draft edits and keyboard focus belong to the composer. The transcript observes
// only room/render/scroll state; SwiftUI handles keyboard avoidance.
private struct AgentTranscriptView: View {
var room: SybilAgentRoom
var topInset: CGFloat
var latestRequest: Int
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@FocusState private var composerFocused: Bool
@State private var atLatest = true
@State private var isScrolling = false
@State private var scrollPhase: ScrollPhase = .idle
@State private var historyLimit = 100
@State private var historyStartID: String?
@State private var scrollPosition = ScrollPosition()
private static let bottomID = "agent-bottom"
@State private var scrollTracking = AgentScrollTracking()
@State private var atBottom = false
@State private var restoreAnchor: AgentReadingAnchor?
@State private var appeared = false
private let bottomID = "agent-bottom"
private var readKey: String {
"\(room.snapshot.room.cursor):\(atLatest):\(atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(room.isAttachmentModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
}
private var visibleReadingAnchor: AgentReadingAnchor? {
scrollTracking.rowFrames.filter { $0.value.maxY > topInset && $0.value.minY < scrollTracking.metrics.height }
.min { $0.value.minY < $1.value.minY }
.map { AgentReadingAnchor(id: $0.key, offset: $0.value.minY) }
}
private var isScrolling: Bool { scrollPhase.isScrolling }
var body: some View {
#if DEBUG
@@ -35,149 +42,151 @@ struct SybilAgentView: View {
let allMessages = transcript.messages
let startIndex = transcript.startIndex
let displayed = transcript.displayed
VStack(spacing: 0) {
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 15) {
if allMessages.count > displayed.count {
Button("Load earlier messages") {
if var anchor = visibleReadingAnchor {
anchor.expectedCount = min(allMessages.count, displayed.count + 100)
restoreAnchor = anchor
}
atLatest = false
historyLimit += 100
historyStartID = allMessages[max(0, startIndex - 100)].id
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 15) {
if allMessages.count > displayed.count {
Button("Load earlier messages") {
// Loading older history may move the viewport as lazy
// row estimates settle; do not chase the old position.
atLatest = false
historyLimit += 100
historyStartID = allMessages[max(0, startIndex - 100)].id
}
if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) }
ForEach(Array(displayed.enumerated()), id: \.element.id) { index, message in
AgentTranscriptRow(message: message, room: room,
startsDay: index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt))
.equatable()
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
.id(message.id)
}
if rendered.isTyping {
HStack(spacing: 9) {
AgentAvatar()
ProgressView().controlSize(.small)
Text("Sybil is thinking…").font(.sybil(.caption))
}
.foregroundStyle(SybilTheme.textMuted)
.accessibilityIdentifier("agent-typing")
}
Color.clear.frame(height: 1).id(bottomID)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
.scrollTargetLayout()
.padding(.horizontal, 16)
.padding(.bottom, 16)
// Match normal chat: content clears the header, while the
// viewport extends beneath the shared navigation fade.
.padding(.top, 16 + topInset)
.frame(maxWidth: SybilLayout.webContentMaxWidth)
.frame(maxWidth: .infinity)
}
.coordinateSpace(name: "agent-scroll")
.scrollPosition($scrollPosition)
.defaultScrollAnchor(.bottom, for: .initialOffset)
.defaultScrollAnchor(.bottom, for: .alignment)
.scrollDismissesKeyboard(.interactively)
.onScrollPhaseChange { _, phase in
isScrolling = phase.isScrolling
if phase == .interacting || phase == .tracking { restoreAnchor = nil }
}
.onScrollGeometryChange(for: AgentScrollMetrics.self) { geometry in
AgentScrollMetrics(offset: geometry.contentOffset.y,
height: geometry.containerSize.height,
contentHeight: geometry.contentSize.height,
bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top,
visibleBottom: geometry.visibleRect.maxY)
} action: { previous, next in
#if DEBUG
AgentRenderDiagnostics.record("scrollGeometry")
#endif
scrollTracking.metrics = next
if atBottom != next.atBottom { atBottom = next.atBottom }
if isScrolling || next.contentHeight <= next.height { atLatest = next.atBottom }
if atLatest, !isScrolling, restoreAnchor == nil,
previous.height != next.height || previous.contentHeight != next.contentHeight || previous.bottomInset != next.bottomInset {
proxy.scrollTo(bottomID, anchor: .bottom)
if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) }
ForEach(Array(displayed.enumerated()), id: \.element.id) { index, message in
AgentTranscriptRow(message: message, room: room,
startsDay: index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt))
.equatable()
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
.id(message.id)
}
}
.onPreferenceChange(AgentRowFrames.self) { frames in
#if DEBUG
AgentRenderDiagnostics.record("rowPreferences")
#endif
let anchor = restoreAnchor ?? (!atLatest && !isScrolling ? visibleReadingAnchor : nil)
if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 {
// Restore the exact pixel offset after insertion or text
// reflow. Normalize the saved frames to
// the requested offset so the next layout cannot undo it.
let requested = scrollTracking.metrics.offset + frame.minY - anchor.offset
let target = min(max(requested, -scrollTracking.metrics.topInset), scrollTracking.metrics.maximumOffset)
let appliedShift = target - scrollTracking.metrics.offset
scrollTracking.rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) }
if abs(appliedShift) > 1 {
// Geometry reports the raw offset; ScrollPosition uses
// zero at the top inset, including the safe area.
scrollPosition.scrollTo(y: target + scrollTracking.metrics.topInset)
if rendered.isTyping {
HStack(spacing: 9) {
AgentAvatar()
ProgressView().controlSize(.small)
Text("Sybil is thinking…").font(.sybil(.caption))
}
restoreAnchor = nil
} else {
scrollTracking.rowFrames = frames
if let expected = restoreAnchor?.expectedCount, frames.count >= expected { restoreAnchor = nil }
.foregroundStyle(SybilTheme.textMuted)
.accessibilityIdentifier("agent-typing")
}
// Include bottom padding in the target so Latest reaches
// the actual content end before native keyboard avoidance.
Color.clear.frame(height: 17).id(Self.bottomID)
}
.overlay(alignment: .bottomTrailing) {
if !atLatest {
Button {
atLatest = true
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { proxy.scrollTo(bottomID, anchor: .bottom) }
} label: {
Label(room.unreadCount > 0 ? "\(room.unreadCount) unread" : "Latest", systemImage: "arrow.down")
.font(.sybil(.caption, weight: .semibold))
.padding(12)
.background(SybilTheme.surfaceStrong, in: Capsule())
.overlay(Capsule().stroke(SybilTheme.border))
}
.padding(14)
.accessibilityIdentifier("agent-jump-latest")
}
}
.onAppear {
appeared = true
if !room.isLoading { historyStartID = displayed.first?.id }
}
.onChange(of: room.isLoading) { _, loading in
if !loading && historyStartID == nil { historyStartID = displayed.first?.id }
}
.onChange(of: allMessages.count) { _, _ in
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) }
}
.onChange(of: composerFocused) { _, _ in
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) }
// Keep native lazy-row targets without binding scroll position.
.scrollTargetLayout()
.padding(.horizontal, 16)
// Match normal chat: content clears the header, while the
// viewport extends beneath the shared navigation fade.
.padding(.top, 16 + topInset)
.frame(maxWidth: SybilLayout.webContentMaxWidth)
.frame(maxWidth: .infinity)
}
.accessibilityIdentifier("agent-transcript")
.defaultScrollAnchor(.bottom, for: .initialOffset)
// Let SwiftUI follow size changes, including keyboard avoidance,
// without competing scrollTo calls from geometry updates.
.defaultScrollAnchor(atLatest ? .bottom : nil, for: .sizeChanges)
.defaultScrollAnchor(.bottom, for: .alignment)
.scrollDismissesKeyboard(.interactively)
.onScrollPhaseChange { _, phase in
scrollPhase = phase
}
.onScrollGeometryChange(for: Bool.self) { geometry in
geometry.containerSize.height > 0 &&
geometry.contentSize.height + geometry.contentInsets.bottom - geometry.visibleRect.maxY <= 24
} action: { _, atBottom in
#if DEBUG
AgentRenderDiagnostics.record("scrollGeometry")
#endif
scrollTracking.atBottom = atBottom
// Only user scrolling changes follow intent. Animated Latest
// navigation and native keyboard resizing must not cancel it.
if isScrolling && scrollPhase != .animating {
atLatest = atBottom
}
}
statusBar
composer
.overlay(alignment: .bottomTrailing) {
if !atLatest {
Button {
atLatest = true
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { proxy.scrollTo(Self.bottomID, anchor: .bottom) }
} label: {
Label(room.unreadCount > 0 ? "\(room.unreadCount) unread" : "Latest", systemImage: "arrow.down")
.font(.sybil(.caption, weight: .semibold))
.padding(12)
.background(SybilTheme.surfaceStrong, in: Capsule())
.overlay(Capsule().stroke(SybilTheme.border))
}
.padding(14)
.accessibilityIdentifier("agent-jump-latest")
}
}
.onAppear {
appeared = true
if !room.isLoading {
historyStartID = displayed.first?.id
proxy.scrollTo(Self.bottomID, anchor: .bottom)
}
}
.onChange(of: room.isLoading) { _, loading in
if !loading && historyStartID == nil {
historyStartID = displayed.first?.id
proxy.scrollTo(Self.bottomID, anchor: .bottom)
}
}
.onChange(of: allMessages.count) { _, _ in
if atLatest { proxy.scrollTo(Self.bottomID, anchor: .bottom) }
}
.onChange(of: latestRequest) { _, _ in
if !atLatest {
atLatest = true
proxy.scrollTo(Self.bottomID, anchor: .bottom)
}
}
.modifier(AgentReadAcknowledgement(room: room, tracking: scrollTracking,
atLatest: atLatest, isScrolling: isScrolling))
}
.onAppear { room.isSelected = true }
.onDisappear { room.isSelected = false }
}
}
// Bottom proximity can change twice per entrance/keyboard frame while the
// scroll view follows the growing content. Only the read-settling task needs
// those transitions; they must not project and rebuild the transcript hierarchy.
private struct AgentReadAcknowledgement: ViewModifier {
var room: SybilAgentRoom
var tracking: AgentScrollTracking
var atLatest: Bool
var isScrolling: Bool
@Environment(\.scenePhase) private var scenePhase
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var readKey: String {
"\(room.snapshot.room.cursor):\(atLatest):\(tracking.atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(room.isAttachmentModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
}
func body(content: Content) -> some View {
let cursor = room.snapshot.room.cursor
content
.task(id: readKey) {
// This cursor belongs to the rendered snapshot. New network input cancels
// the task, and cannot be acknowledged while still entering the layout.
let cursor = rendered.room.cursor
guard atLatest, scrollTracking.metrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active, !room.isLoading else { return }
// Capture the rendered cursor before scheduling. New network input
// cancels this task and must settle in layout before acknowledgement.
guard atLatest, tracking.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active, !room.isLoading else { return }
try? await Task.sleep(for: .milliseconds(reduceMotion ? 80 : 360))
guard !Task.isCancelled, atLatest, scrollTracking.metrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
guard !Task.isCancelled, atLatest, tracking.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
room.markRead(renderedCursor: cursor)
}
}
}
@ViewBuilder private var statusBar: some View {
private struct AgentStatusBar: View {
var room: SybilAgentRoom
@ViewBuilder var body: some View {
let activity = room.snapshot.room.activity
if let error = room.error ?? activity.error {
HStack(alignment: .top) {
@@ -199,12 +208,18 @@ struct SybilAgentView: View {
.padding(.horizontal, 16).padding(.vertical, 6)
}
}
}
private struct AgentComposer: View {
@Bindable var room: SybilAgentRoom
var onSubmit: () -> Void
@FocusState private var composerFocused: Bool
private var canSendComposer: Bool {
room.attachmentDraft.canSend
}
private var composer: some View {
var body: some View {
AgentAttachmentComposer(room: room) {
SybilPromptField(
text: $room.composer,
@@ -223,7 +238,7 @@ struct SybilAgentView: View {
private func submitComposer() {
guard canSendComposer, room.sendComposer() else { return }
atLatest = true
onSubmit()
}
}
@@ -236,9 +251,8 @@ struct AgentAvatar: View {
}
}
// Activity/read publications still update the surrounding view. Completed rows
// depend only on their message values and date heading, so progress changes
// must not rebuild the eager history (whose exact heights preserve anchors).
// Activity/read publications still update the transcript. Completed rows depend
// only on message values and heading; scrolling needs no per-row geometry.
private struct AgentTranscriptRow: View, Equatable {
var message: Message
var room: SybilAgentRoom
@@ -261,9 +275,6 @@ private struct AgentTranscriptRow: View, Equatable {
.padding(.vertical, 6)
}
AgentMessageRow(message: message, room: room)
.background(GeometryReader { geometry in
Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))])
})
}
}
}
@@ -300,63 +311,24 @@ private struct AgentMessageRow: View {
}
}
// Animate both occupied height and content, so incoming rows push older history up.
private struct AgentGrowingLayout: Layout {
var progress: CGFloat
var animatableData: CGFloat { get { progress } set { progress = newValue } }
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let size = subviews.first?.sizeThatFits(ProposedViewSize(width: proposal.width, height: nil)) ?? .zero
return CGSize(width: proposal.width ?? size.width, height: size.height * progress)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
subviews.first?.place(at: bounds.origin, proposal: ProposedViewSize(width: bounds.width, height: nil))
}
}
// Keep the row's measured height stable for lazy layout. Animate its appearance
// without collapsing the scroll target while the composer or keyboard resizes.
private struct AgentEntrance: ViewModifier {
var animate: Bool
var reduceMotion: Bool
@State private var entered = false
func body(content: Content) -> some View {
let preparing = animate && !entered && !reduceMotion
AgentGrowingLayout(progress: preparing ? 0 : 1) {
content.opacity(preparing ? 0 : 1).offset(y: preparing ? 16 : 0)
}
.clipped()
content
.opacity(preparing ? 0 : 1)
.offset(y: preparing ? 16 : 0)
.onAppear {
withAnimation(animate && !reduceMotion ? .easeOut(duration: 0.24) : nil) { entered = true }
}
}
}
// Pixel geometry is imperative anchor bookkeeping, not rendered content. Keeping
// it outside observation avoids rebuilding the entire transcript on every scroll
// frame. Only the at-bottom boundary above invalidates the read acknowledgement.
private final class AgentScrollTracking {
var metrics = AgentScrollMetrics()
var rowFrames: [String: CGRect] = [:]
}
private struct AgentScrollMetrics: Equatable {
var offset: CGFloat = 0
var height: CGFloat = 0
var contentHeight: CGFloat = 0
var bottomInset: CGFloat = 0
var topInset: CGFloat = 0
var visibleBottom: CGFloat = 0
// visibleRect includes the viewport extended into the safe area, as well as
// the alignment inset used when a short transcript is pinned to the bottom.
var distanceFromBottom: CGFloat { contentHeight + bottomInset - visibleBottom }
var atBottom: Bool { height > 0 && distanceFromBottom <= 24 }
var maximumOffset: CGFloat { max(-topInset, offset + distanceFromBottom) }
}
private struct AgentReadingAnchor {
var id: String
var offset: CGFloat
var expectedCount: Int? = nil
}
private struct AgentRowFrames: PreferenceKey {
static let defaultValue: [String: CGRect] = [:]
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
value.merge(nextValue(), uniquingKeysWith: { _, next in next })
}
// Only the bottom boundary invalidates read acknowledgement, not the transcript.
@Observable private final class AgentScrollTracking {
var atBottom = false
}
+88 -49
View File
@@ -1,4 +1,4 @@
# Agent scrolling benchmark
# Agent interaction benchmarks
Run the production Agent view in an app-hosted XCTest on the iPhone 16e simulator:
@@ -7,62 +7,101 @@ just generate
xcodebuild test -scheme SybilPresentation \
-destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' \
-derivedDataPath build/DerivedData -parallel-testing-enabled NO \
-only-testing:SybilPresentationTests/AgentPerformanceTests \
-resultBundlePath build/agent-performance.xcresult
-only-testing:SybilPresentationTests/AgentPerformanceTests/testInputAndEntranceResponsiveness \
-resultBundlePath build/agent-input-performance.xcresult
```
`AgentPerformanceTests` seeds a room through its normal event reducer with 160
local messages (100 initially displayed). User text is literal; assistant text
contains paragraphs, lists, links, emphasis and code. The real Agent view,
composer, avatar, entrance layout and thinking spinner remain enabled. No server,
production credentials, notification delivery or network images are needed.
`testInputAndEntranceResponsiveness` seeds 160 local mixed literal/Markdown
messages through the normal room reducer (100 initially displayed), then:
Three approximately 12-second phases run in order: idle scrolling, thinking at
the bottom, and thinking while scrolling. The test explicitly resets the offset
to the bottom before the stationary thinking phase (including in the baseline).
Thinking activity changes queue depth, worker count and tool progress every
500 ms through the normal 100 ms publication
batch. The display link sweeps a 2400-point range at 600 points/second, reversing
every four seconds. Use the **same test source, simulator, build configuration
and host load** for before/after runs. Don't run other builds or profilers during
a measured run. Warm builds and repeat runs help distinguish stable effects from
host noise.
- Receives 12 more messages at 550 ms intervals through the production 100 ms
publication batch and 240 ms entrance animation, checking bottom following.
- Presents and dismisses the actual software keyboard six times, 600 ms per
transition. It asserts that the transcript viewport shrinks by over 100 points
and remains at the bottom; a hardware-keyboard-only run is invalid.
- Calls UIKit `insertText` on the actual focused composer every 30 ms, including
enough text to wrap and grow to its six-line limit. It verifies the draft,
bottom following, and that keyboard/draft changes don't reproject the history.
The geometry assertions use iPhone 16e, iOS 26.5, and the default text size. They
retain the unoptimized transcript heights (39181.333 points idle, 39223.333 while
thinking), so an isolation wrapper cannot silently change row spacing. Rebaseline
these explicitly when changing simulator/font configuration or the fixture.
The real composer, Markdown rows, avatars and entrance animations remain enabled.
No server, production credentials, notification delivery or network images are
needed. The log prints `AGENT_INPUT_PERFORMANCE_JSON`; the result bundle retains
JSON and screenshots of the actual hosted view before and after typing.
Screenshots capture the hosting view, not the separate system keyboard window.
The log prints `AGENT_PERFORMANCE_JSON`; the result bundle retains the JSON as an
attachment. Delivery-gap p50/p95/p99/max and counts over 25/50 ms measure actual
main-run-loop responsiveness. `missedBudgetIntervals` estimates gaps relative to
the reported display-link budget. These are **not compositor FPS, GPU hitches,
touch latency or physical-device results**. Scrolling is programmatic; native
swipe/anchor/read behavior needs the local-fixture UI regression tests as well.
The hosted view excludes the surrounding app shell. Physical iPhone scrolling,
streaming and thinking animation remain a required follow-up.
Use the same test source, simulator, build configuration and host load for
before/after measurements. Don't run other builds or profilers during sampling.
Functional assertions about observation boundaries should fail against the old
implementation; use the same sampling procedure when collecting a baseline.
Opt-in `AgentRenderDiagnostics` counters are compiled only in Debug and are off
outside this test. They explain work (Agent body evaluations, message projection,
message text construction, isolated row evaluations, geometry callbacks and
snapshot publications). A drop in these counts alone does not establish improved
frame responsiveness.
## Measurement limits
## Reference result
Delivery-gap p50/p95/p99/max and counts over 25/50 ms measure **CADisplayLink
main-run-loop callback delivery**, not compositor FPS, GPU hitches, touch-to-key
latency or physical-device performance. The hosted view excludes the surrounding
app shell. UIKit input exercises editing/layout without synthesizing key taps.
The first keyboard presentation includes system warmup; residual system and
simulator scheduling gaps remain. Native gestures, read settling, header fade,
attachments and reading anchors are covered separately by the loopback UI tests.
On monsoon, Xcode 26.5, Debug, iPhone 16e / iOS 26.5, clean repeat runs against
`f534f76` plus counters and against the geometry/row isolation fix gave:
`AgentRenderDiagnostics` counters are Debug-only and opt-in. They explain work;
a drop in counts alone does not establish improved responsiveness.
| Phase | Before p95 / p99 (ms) | After p95 / p99 (ms) | Gaps >50 ms, before / after |
The transcript now uses `LazyVStack` with the observation boundaries introduced
in `bbaa5e8`. Loading older history may move the viewport; this is the only
waived position guarantee. Native regressions still assert reading anchors for
incoming messages, bottom following, keyboard behavior, read settling, fade,
and attachments. There is no saved prepend anchor or convergence loop.
Lazy content height estimates change as offscreen rows materialize. The earlier
43869→35870 point difference was not shortened content: after-arrival screenshots
were byte-identical. Compare visible geometry and native behavior, not estimated
whole-history height, when assessing lazy performance.
## Scrolling harness
`testThinkingScrollResponsiveness` retains the earlier three approximately
12-second phases: idle scroll, stationary thinking, and scrolling with changing
queue/worker/tool activity every 500 ms. Its display link sweeps 2400 points at
600 points/second. This programmatic sweep does not reproduce native drag phases;
use the UI tests for reading-anchor geometry and interactive keyboard dismissal.
The eager-stack reference in `b9a6f5d` has exact heights of 39181.333
points idle and 39223.333 while thinking. That reference harness retains its
eager-only total-height assertions; run it at `bbaa5e8` for the eager comparison.
Those totals are not valid assertions about lazy estimated content size. Use the
input harness and native gesture tests above for the current lazy implementation.
That earlier scrolling fix reduced busy-scroll p95 from 88.8 to 19.4 ms; it did
not address the input/entrance paths measured here. Physical phone validation
remains necessary for hardware performance claims.
## 2026-10-16 comparison before the prepend waiver
Single Debug simulator runs on the same iPhone 16e / iOS 26.5, starting at
`7510603` (which includes `b9a6f5d`). Values are callback-gap p95 milliseconds:
| Path | Before | Eager isolation (`bbaa5e8`) | Earlier lazy experiment |
| --- | ---: | ---: | ---: |
| Idle scroll | 50.01 / 50.02 | 16.68 / 16.71 | 128 / 0 |
| Thinking stationary | 33.33 / 81.92 | 28.91 / 38.35 | 22 / 2 |
| Thinking scroll | 88.78 / 90.89 | 19.41 / 30.42 | 123 / 1 |
| 12 message entrances | 40.8 | 33.2 | 16.8 |
| Six keyboard cycles | 33.2 | 33.1 | 29.2 |
| Multiline composer typing | 24.8 | 25.2 | 21.3 |
Each phase lasted approximately 12 seconds. Content heights matched exactly.
Busy-scroll Agent body/projection evaluations fell from 261 to 27; unchanged
text constructions were zero in both versions. Geometry isolation alone removed
the idle-scroll stalls; individual row isolation reduced activity-related gaps.
These results support those two boundaries, without a Markdown cache or lazy
layout change. Occasional simulator gaps remain; this does not replace device
validation.
Eager transcript projections were 12/0/0 versus 220/24/10, with no row-frame
preference callbacks in these pinned-bottom phases. Eager content height stayed
43869.333 points. Entrance p99 improved 83.4→57.1 ms; keyboard p99 55.9→44.5;
typing p99 33.2→26.7. Residual gaps remain: entrance/keyboard had 10/3 gaps over
50 ms; typing had 30 over 25 ms (baseline 19). Keyboard p95 was essentially flat
and typing p95 did not improve. These single runs establish removed transcript
work and a safer entrance improvement, not universally smooth input or device FPS.
The lazy option was initially rejected because strict prepend anchors failed.
The subsequent release accepts that prepend tradeoff; all other guarantees remain.
## Lazy release after the prepend waiver
The same input harness passes unchanged: p95 entrance/keyboard/typing gaps are
16.9/28.1/20.9 ms, versus the eager run's 33.2/33.1/25.2 ms. Projections stay
12/0/0; pinned row-frame preference callbacks stay zero. The after-arrival PNG
is byte-identical to the eager baseline. Residual gaps remain (one keyboard gap
over 50 ms and one typing gap over 25 ms); these are single simulator samples. Five
focused native regressions pass with only the two prepend-offset assertions
replaced by checks that the remaining history loaded.
@@ -72,6 +72,116 @@ final class AgentPerformanceTests: XCTestCase {
attachment.name = "agent-performance"; attachment.lifetime = .keepAlways; add(attachment)
}
/// Exercises UIKit editing/focus and the real message reducer; no synthetic
/// view rebuild loop or replacement composer/animation is used.
func testInputAndEntranceResponsiveness() async throws {
let room = SybilAgentRoom()
try room.receive(.messages(AgentPage(conversationId: "performance",
messages: (1...160).map(Self.message), cursor: "160", beforeCursor: nil, hasMore: false)), replay: true)
room.flush()
let host = UIHostingController(rootView: SybilAgentView(room: room, topInset: 64)
.background(SybilTheme.background).environment(\.colorScheme, .dark))
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let window = UIWindow(windowScene: scene)
window.rootViewController = host
window.makeKeyAndVisible()
defer {
host.view.endEditing(true)
AgentRenderDiagnostics.enabled = false
window.isHidden = true; window.rootViewController = nil
room.stop()
}
host.view.frame = window.bounds
host.view.layoutIfNeeded()
try await Task.sleep(for: .seconds(2))
let scroll = try XCTUnwrap(findScroll(host.view))
let input = try XCTUnwrap(findInput(host.view))
AgentRenderDiagnostics.enabled = true
var reports: [[String: Any]] = []
func report(_ sampler: AgentFrameSampler, phase: String) {
var value = sampler.stop()
value["phase"] = phase
value["procedure"] = "mixed160-input-entrance-v1"
value["operations"] = AgentRenderDiagnostics.counts
value["contentHeight"] = scroll.contentSize.height
value["viewportHeight"] = scroll.bounds.height
reports.append(value)
}
AgentRenderDiagnostics.counts = [:]
let entrance = AgentFrameSampler(scroll: nil)
entrance.start()
for index in 161...172 {
try room.receive(.messages(AgentPage(conversationId: "performance",
messages: [Self.message(index)], cursor: String(index), beforeCursor: nil, hasMore: false)))
// Use the production 100 ms publication batch and 240 ms entrance.
try await Task.sleep(for: .milliseconds(550))
assertAtBottom(scroll)
}
XCTAssertLessThanOrEqual(AgentRenderDiagnostics.counts["messageProjection", default: 0], 24,
"Entrance frames must not repeatedly project the history")
report(entrance, phase: "message-entrance")
XCTAssertEqual(room.snapshot.room.messages.count, 172)
assertAtBottom(scroll)
capture(host, "input-entrance-latest")
let unfocusedHeight = scroll.bounds.height
AgentRenderDiagnostics.counts = [:]
let keyboard = AgentFrameSampler(scroll: nil)
keyboard.start()
for _ in 0..<6 {
XCTAssertTrue(input.becomeFirstResponder())
try await Task.sleep(for: .milliseconds(600))
XCTAssertLessThan(scroll.bounds.height, unfocusedHeight - 100, "Software keyboard must actually resize the transcript")
assertAtBottom(scroll)
XCTAssertTrue(input.resignFirstResponder())
try await Task.sleep(for: .milliseconds(600))
}
XCTAssertEqual(AgentRenderDiagnostics.counts["messageProjection", default: 0], 0,
"Keyboard focus and geometry must not project the transcript")
report(keyboard, phase: "keyboard-presentation")
XCTAssertEqual(scroll.bounds.height, unfocusedHeight, accuracy: 1)
XCTAssertTrue(input.becomeFirstResponder())
try await Task.sleep(for: .milliseconds(700))
let keyInput = try XCTUnwrap(input as? any UIKeyInput)
AgentRenderDiagnostics.counts = [:]
let typing = AgentFrameSampler(scroll: nil)
typing.start()
let text = "Typing into the actual composer should keep the long transcript responsive as this sentence wraps across several lines. "
for character in text + text {
keyInput.insertText(String(character))
try await Task.sleep(for: .milliseconds(30))
}
XCTAssertEqual(AgentRenderDiagnostics.counts["messageProjection", default: 0], 0,
"Draft edits and line wrapping must not project the transcript")
assertAtBottom(scroll)
report(typing, phase: "composer-typing")
XCTAssertEqual(room.composer, text + text)
capture(host, "input-multiline-keyboard")
let data = try JSONSerialization.data(withJSONObject: reports, options: [.sortedKeys])
print("AGENT_INPUT_PERFORMANCE_JSON " + String(decoding: data, as: UTF8.self))
let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json")
attachment.name = "agent-input-performance"; attachment.lifetime = .keepAlways; add(attachment)
}
private func assertAtBottom(_ scroll: UIScrollView, file: StaticString = #filePath, line: UInt = #line) {
XCTAssertEqual(scroll.contentOffset.y + scroll.bounds.height - scroll.adjustedContentInset.bottom,
scroll.contentSize.height, accuracy: 25, file: file, line: line)
}
private func findInput(_ view: UIView) -> UIView? {
if view is UITextView || view is UITextField { return view }
return view.subviews.lazy.compactMap { self.findInput($0) }.first
}
private func capture(_ host: UIViewController, _ name: String) {
let image = UIGraphicsImageRenderer(bounds: host.view.bounds).image { _ in
host.view.drawHierarchy(in: host.view.bounds, afterScreenUpdates: true)
}
let attachment = XCTAttachment(image: image)
attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
}
private static func message(_ index: Int) -> Message {
let own = index % 3 == 0
let content = own
+5 -5
View File
@@ -321,15 +321,15 @@ final class AgentUITests: XCTestCase {
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
capture("agent-reading-anchor")
for _ in 0..<25 {
if app.buttons["Load earlier messages"].isHittable { break }
if app.buttons["Load earlier messages"].exists && app.buttons["Load earlier messages"].isHittable { break }
scroll.swipeDown(velocity: .fast)
}
XCTAssertTrue(app.buttons["Load earlier messages"].isHittable)
let first = app.staticTexts["History message 61: local simulator fixture."]
let beforeLoad = first.frame.minY
app.buttons["Load earlier messages"].tap()
XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3)
capture("agent-older-history-anchor")
// Loading older lazy history may move the viewport. It must still load
// the remaining history and preserve Latest/keyboard behavior below.
XCTAssertFalse(app.buttons["Load earlier messages"].exists)
capture("agent-older-history-loaded")
app.buttons["agent-jump-latest"].tap()
XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable)
composer.tap(); composer.typeText("A multiline draft\nwith a second line\nand a third line")
@@ -37,8 +37,8 @@ final class TranscriptFadeUITests: XCTestCase {
}
private var transcript: XCUIElement {
// The tablet sidebar is also a scroll view; select the detail by its content.
app.scrollViews.containing(.staticText, identifier: "History message 160: local simulator fixture.").firstMatch
// Identify the transcript independently of any particular message row.
app.scrollViews["agent-transcript"]
}
private func capture(_ name: String) {
@@ -55,6 +55,79 @@ final class TranscriptFadeUITests: XCTestCase {
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
}
private func reloadMixedHistory() async throws {
app.terminate()
let rows = (1...160).map { index in
let short = "History message \(index): local simulator fixture."
return ["role": index % 3 == 0 ? "user" : "assistant",
"content": index == 160 || index % 3 == 0 ? short :
"### Investigation \(index)\n\n" + String(repeating: "A longer transcript paragraph with **emphasis** and `inline code`.\n\n", count: 4) +
"- Preserve visible content.\n- Keep the composer available.\n\n```swift\nrender(messages[\(index)])\n```"]
}
try await control(["action": "presentation-samples", "messages": rows])
app.launch()
}
func testInitialOpenAndTabReturnShowLatestWithoutScrolling() async throws {
try await reloadMixedHistory()
openAgent()
let latest = app.staticTexts["History message 160: local simulator fixture."]
XCTAssertTrue(latest.isHittable)
XCTAssertLessThan(latest.frame.maxY, composer.frame.minY)
capture("agent-initial-open")
showSidebar()
app.buttons["Settings"].tap()
XCTAssertFalse(composer.exists)
openAgent()
XCTAssertTrue(latest.isHittable, "Returning to Agent must not need a scroll to materialize rows")
XCTAssertLessThan(latest.frame.maxY, composer.frame.minY)
capture("agent-tab-return")
}
func testSendClearsMultilineComposerAndKeepsLatestAboveKeyboard() async throws {
try await reloadMixedHistory()
openAgent()
composer.tap()
if let existing = composer.value as? String, !existing.isEmpty, existing != "Enter Prompt" {
composer.press(forDuration: 1)
let selectAll = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Select All")).firstMatch
if selectAll.waitForExistence(timeout: 2) { selectAll.tap() }
else { composer.typeText(String(repeating: XCUIKeyboardKey.rightArrow.rawValue, count: existing.count)) }
composer.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: existing.count))
}
let draft = "Keyboard send\nsecond line\nthird line"
composer.typeText(draft)
XCTAssertTrue(app.keyboards.firstMatch.exists)
let multilineHeight = composer.frame.height
app.buttons["agent-send"].tap()
let cleared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == %@ OR value == %@", "", "Enter Prompt"), object: composer)
await fulfillment(of: [cleared], timeout: 5)
XCTAssertLessThan(composer.frame.height, multilineHeight)
XCTAssertTrue(app.keyboards.firstMatch.exists)
let sent = app.staticTexts[draft]
XCTAssertTrue(sent.waitForExistence(timeout: 5))
XCTAssertTrue(sent.isHittable)
let reply = app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: Keyboard send")).firstMatch
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: reply)
await fulfillment(of: [followed], timeout: 10)
capture("agent-send-cleared-composer-with-keyboard")
XCTAssertGreaterThan(reply.frame.minY, transcript.frame.minY + 64)
XCTAssertLessThan(reply.frame.maxY, composer.frame.minY)
// Finished: is only the first Markdown paragraph. Measure the final
// reply content, leaving the same allowance for metadata and spacing.
let replyEnd = app.staticTexts["The local fixture returned three matches."]
XCTAssertTrue(replyEnd.isHittable)
XCTAssertLessThan(replyEnd.frame.maxY, composer.frame.minY)
let bottomGap = composer.frame.minY - replyEnd.frame.maxY
print("Agent send settled final-content gap: \(bottomGap)pt")
XCTAssertLessThan(bottomGap, 100, "Composer clearing must not leave the transcript shifted above the bottom")
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
try await Task.sleep(for: .milliseconds(500))
let read = try await control(["action": "state"])
XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int)
}
func testSharedHeaderFadeGeometry() async throws {
showSidebar()
let chat = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "Transcript fade comparison")).firstMatch
@@ -107,23 +180,23 @@ final class TranscriptFadeUITests: XCTestCase {
capture("agent-fade-reading-position-and-unread")
for _ in 0..<30 {
if app.buttons["Load earlier messages"].isHittable { break }
if app.buttons["Load earlier messages"].exists && app.buttons["Load earlier messages"].isHittable { break }
scroll.swipeDown(velocity: .fast)
}
let load = app.buttons["Load earlier messages"]
XCTAssertTrue(load.isHittable)
let first = app.staticTexts["History message 61: local simulator fixture."]
let beforeLoad = first.frame.minY
load.tap()
capture("agent-fade-prepend-reading-position")
XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3,
"Prepending must preserve the readable row's position beneath the header")
// Prepend position is intentionally not preserved with lazy history.
// New-message reading anchors above and follow/read settling below remain strict.
XCTAssertFalse(load.exists)
capture("agent-fade-older-history-loaded")
app.buttons["agent-jump-latest"].tap()
let latest = app.staticTexts["Fade regression reply while reading"]
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: latest)
await fulfillment(of: [followed], timeout: 5)
composer.tap()
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
XCTAssertTrue(latest.isHittable)
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
capture("agent-fade-latest-with-keyboard")
@@ -134,5 +207,27 @@ final class TranscriptFadeUITests: XCTestCase {
try await Task.sleep(for: .milliseconds(500))
let read = try await control(["action": "state"])
XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int)
// A reply still follows and settles as read while the keyboard is up.
try await control(["action": "reply", "content": "Reply with keyboard visible"])
let keyboardReply = app.staticTexts["Reply with keyboard visible"]
let keyboardFollowed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: keyboardReply)
await fulfillment(of: [keyboardFollowed], timeout: 5)
try await Task.sleep(for: .milliseconds(500))
let keyboardRead = try await control(["action": "state"])
XCTAssertEqual(keyboardRead["readCursor"] as? Int, keyboardRead["cursor"] as? Int)
// Interactive dismissal is user navigation; Latest must still return to
// the bottom, and presenting the keyboard again must keep it visible.
let dismissStart = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9))
let dismissEnd = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98))
dismissStart.press(forDuration: 0.1, thenDragTo: dismissEnd)
let dismissed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: app.keyboards.firstMatch)
await fulfillment(of: [dismissed], timeout: 5)
if app.buttons["agent-jump-latest"].exists { app.buttons["agent-jump-latest"].tap() }
XCTAssertTrue(keyboardReply.isHittable)
composer.tap()
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
XCTAssertTrue(keyboardReply.isHittable)
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ export const InstallationBody = z.discriminatedUnion('transport', [
]);
export type Registration = z.infer<typeof InstallationBody>;
export type Subscription = z.infer<typeof WebSubscription>;
export const NotificationContext = z.object({ version: z.literal(1), nextChangeAt: z.string().datetime().nullable().optional(), sourceId: z.string().uuid(), conversationId: z.string().min(1).max(128),
export const NotificationContext = z.object({ version: z.literal(1), sourceId: z.string().uuid(), conversationId: z.string().min(1).max(128),
cursor: Cursor, hasMore: z.boolean(), latestUserMessageAt: z.string().datetime().nullable(), nativeReadCursor: Cursor,
uncertain: z.boolean(), webPresence: z.object({ active: z.boolean(), unknown: z.boolean(), observedAt: z.string().datetime() }),
candidates: z.array(z.object({ messageId: z.string().min(1).max(200), cursor: Cursor,
-35
View File
@@ -1,35 +0,0 @@
import WebSocket from 'ws';
/** Events are hints; every connection starts durable cursor reconciliation. */
export function subscribeNotificationEvents(upstream: { baseUrl: string; apiKey?: string }, changed: () => void,
options: { retryMs?: number; heartbeatMs?: number } = {}) {
let stopped = false, attempts = 0, socket: WebSocket | undefined;
let retry: ReturnType<typeof setTimeout> | undefined, heartbeat: ReturnType<typeof setTimeout> | undefined;
const connect = () => {
if (stopped || !upstream.apiKey) return;
const url = new URL('/api/notification-events', upstream.baseUrl);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(url, { headers: { Authorization: `Bearer ${upstream.apiKey}`, 'X-Sybil-Client': 'backend-observer-v1' },
handshakeTimeout: 10_000, maxPayload: 1024, followRedirects: false });
const current = socket;
const alive = () => { clearTimeout(heartbeat); heartbeat = setTimeout(() => current.terminate(), options.heartbeatMs ?? 60_000); heartbeat.unref(); };
current.on('open', alive);
current.on('ping', alive);
current.on('message', raw => {
try {
const event = JSON.parse(raw.toString());
if (event.type !== 'notification_changed' || event.version !== 1) { current.close(1002); return; }
attempts = 0; alive(); changed();
} catch { current.close(1002); }
});
current.on('error', () => { /* Reconnect without logging headers, URLs, or response bodies. */ });
current.on('close', () => {
clearTimeout(heartbeat);
if (stopped) return;
retry = setTimeout(connect, Math.min(30_000, (options.retryMs ?? 1_000) * 2 ** Math.min(attempts++, 5)));
retry.unref();
});
};
connect();
return () => { stopped = true; clearTimeout(retry); clearTimeout(heartbeat); socket?.terminate(); };
}
+30 -97
View File
@@ -9,15 +9,14 @@ import { Cursor, InstallationBody, InstallationId, MAX_AGE_MS, MAX_ATTEMPTS, Not
digest, fail, ownerEpoch, parseSafe, validateSubscription, type Context, type Identity, type Presence, type Registration } from './agent-push-contract.js';
import { createPushSender, type PushSend } from './agent-push-transports.js';
import { createMessageText, type MessageText } from './agent-push-preview.js';
import { subscribeNotificationEvents } from './agent-push-events.js';
type Tx = Prisma.TransactionClient;
export type ReadState = { cursor: string; updatedAt: string | null; sourceId: string | null };
type Options = { db: PrismaClient; config: PushConfig; context: (after?: string, sourceId?: string) => Promise<Context>; messageText: MessageText; send: PushSend; now?: () => number;
broadcast?: (state: ReadState) => void; warmupMs?: number; subscribe?: (changed: () => void) => (() => void) };
broadcast?: (state: ReadState) => void; warmupMs?: number };
const date = (ms: number) => new Date(ms);
const activeStatuses = ['pending', 'intent'];
type Suppression = { until?: number; reason: string; clientKind: 'web' | 'unknown' | 'runtime_web' | 'runtime_unknown' | 'system' };
type Suppression = { reason: string; clientKind: 'web' | 'unknown' | 'runtime_web' | 'runtime_unknown' | 'system' };
/** Sole coordinator: persisted source checkpoint, installations, presence, claims and outcomes. */
export class AgentPushCoordinator {
@@ -28,15 +27,7 @@ export class AgentPushCoordinator {
private started = false;
private untrackedConnections = new Map<string, { at: number; kind: 'web' | 'unknown' }>();
private running = false;
private timer?: ReturnType<typeof setTimeout>;
private timerAt = Infinity;
private enabled = false;
private dirty = false;
private reconsider = true;
private unsubscribe?: () => void;
private nextMaintenance = 0;
private nextRuntimeChange?: number;
private failures = 0;
private timer?: ReturnType<typeof setInterval>;
private stopped = false;
private observedRoot?: { sourceId: string; conversationId: string };
constructor(readonly options: Options) {
@@ -60,37 +51,11 @@ export class AgentPushCoordinator {
this.started = true;
}
start() {
if (this.enabled || !this.options.config.adminToken) return;
this.enabled = true;
this.unsubscribe = this.options.subscribe?.(() => this.wake());
this.wake();
}
/** Coalesce events without dropping one that arrives during reconciliation. */
wake() {
this.reconsider = true;
if (!this.enabled || this.stopped) return;
this.dirty = true;
if (!this.running) this.schedule(this.now() + 100);
}
private schedule(at: number) {
if (!this.enabled || this.stopped || at >= this.timerAt) return;
clearTimeout(this.timer);
this.timerAt = at;
this.timer = setTimeout(() => { this.timer = undefined; this.timerAt = Infinity; void this.tick(); }, Math.max(0, at - this.now()));
this.timer.unref();
}
private async schedulePending() {
const [pending, intent] = await Promise.all([
this.options.db.agentPushDelivery.findFirst({ where: { status: 'pending' }, orderBy: { dueAt: 'asc' }, select: { dueAt: true } }),
this.options.db.agentPushDelivery.findFirst({ where: { status: 'intent' }, orderBy: { updatedAt: 'asc' }, select: { updatedAt: true } }),
]);
this.schedule(Math.max(this.now() + 100, Math.min(this.nextMaintenance,
pending?.dueAt.getTime() ?? Infinity, intent ? intent.updatedAt.getTime() + 30_001 : Infinity)));
}
async stop() {
this.stopped = true; this.unsubscribe?.(); clearTimeout(this.timer);
while (this.running) await new Promise(r => setTimeout(r, 20));
if (this.timer || !this.options.config.adminToken) return;
this.timer = setInterval(() => { void this.tick(); }, 2_000); this.timer.unref();
void this.tick();
}
async stop() { this.stopped = true; if (this.timer) clearInterval(this.timer); while (this.running) await new Promise(r => setTimeout(r, 20)); }
async config() {
await this.initialize();
const state = await this.options.db.agentPushCoordinator.findUnique({ where: { id: 'root' } });
@@ -108,7 +73,7 @@ export class AgentPushCoordinator {
value = { ...value, token: value.token.toLowerCase() };
} else { if (!config.web) throw fail(503, 'Web Push is unavailable'); validateSubscription(value.subscription, this.now()); }
const payload = JSON.stringify(value), fingerprint = digest(value.transport === 'apns' ? `${value.environment}:${value.topic}:${value.token}` : value.subscription.endpoint);
const result = await this.options.db.$transaction(async tx => {
return this.options.db.$transaction(async tx => {
const old = await tx.agentPushInstallation.findUnique({ where: { id } });
if (old?.revokedAt) throw fail(409, 'Installation was retired; explicit opt-in requires a new installation ID');
if (old?.enabled && old.ownerEpoch === this.epoch && old.payload === payload) return { installationId: id, transport: old.transport, enabled: true, version: old.version };
@@ -124,8 +89,6 @@ export class AgentPushCoordinator {
update: { ownerEpoch: this.epoch, transport: value.transport, payload, fingerprint, enabled: true, version: { increment: 1 } } });
return { installationId: id, transport: record.transport, enabled: record.enabled, version: record.version };
});
this.wake();
return result;
}
async revoke(id: string) {
await this.options.db.$transaction(async tx => {
@@ -135,11 +98,10 @@ export class AgentPushCoordinator {
update: { enabled: false, revokedAt: date(this.now()), version: { increment: 1 }, payload: '', fingerprint: '' } });
await tx.agentPushDelivery.updateMany({ where: { installationId: id, status: { in: activeStatuses } }, data: { status: 'cancelled', outcome: 'revoked' } });
});
this.wake();
}
async presence(id: string, value: Presence) {
const now = this.now();
const result = await this.options.db.$transaction(async tx => {
return this.options.db.$transaction(async tx => {
const old = await tx.agentPushPresence.findUnique({ where: { id } });
if (old?.ownerEpoch === this.epoch && value.sequence <= old.sequence) return { expiresAt: old.expiresAt.toISOString() };
if (!old && await tx.agentPushPresence.count({ where: { ownerEpoch: this.epoch, expiresAt: { gt: date(now) } } }) >= 512) throw fail(429, 'Too many presence sessions');
@@ -153,8 +115,6 @@ export class AgentPushCoordinator {
const row = await tx.agentPushPresence.upsert({ where: { id }, create: { id, ...data }, update: data });
return { expiresAt: row.expiresAt.toISOString() };
});
this.wake();
return result;
}
async retirePresence(id: string) {
await this.options.db.$transaction(async tx => {
@@ -163,7 +123,6 @@ export class AgentPushCoordinator {
await tx.agentPushPresence.update({ where: { id }, data: { visible: false, focused: false,
inactiveUntil: old.visible && old.focused && old.expiresAt.getTime() > this.now() ? date(this.now() + PUSH_POLICY.inactiveGraceMs) : old.inactiveUntil } });
});
this.wake();
}
async connect(id: string, kind?: string, sessionId?: string) {
const now = this.now();
@@ -181,19 +140,17 @@ export class AgentPushCoordinator {
}
});
this.untrackedConnections.delete(id);
this.wake();
}
async heartbeat(id: string) {
// Transport liveness never creates a client, changes its attribution or proves attention.
await this.options.db.agentPushConnection.updateMany({ where: { id, ownerEpoch: this.epoch }, data: { expiresAt: date(this.now() + PUSH_POLICY.presenceLeaseMs) } });
}
async disconnect(id: string) { this.untrackedConnections.delete(id); await this.options.db.agentPushConnection.deleteMany({ where: { id, ownerEpoch: this.epoch } }); this.wake(); }
async disconnect(id: string) { this.untrackedConnections.delete(id); await this.options.db.agentPushConnection.deleteMany({ where: { id, ownerEpoch: this.epoch } }); }
async notePrompt() {
if (!this.options.config.adminToken) return;
await this.initialize();
// Covers submissions arriving after the last runtime snapshot but before the send claim.
await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', ownerEpoch: this.epoch }, data: { localPromptAt: date(this.now()) } });
this.wake();
}
async readState(tx: Tx | PrismaClient = this.options.db): Promise<ReadState> {
const row = await tx.agentReadState.findUnique({ where: { id: 'root' } });
@@ -236,7 +193,7 @@ export class AgentPushCoordinator {
const changed = await this.mergeRead(tx, cursor, coordinator?.sourceId || null);
return { state: changed || await this.readState(tx), changed: Boolean(changed) };
});
if (result.changed) { this.options.broadcast?.(result.state); this.wake(); }
if (result.changed) this.options.broadcast?.(result.state);
return result.state;
}
private async acquire(): Promise<boolean> {
@@ -306,26 +263,22 @@ export class AgentPushCoordinator {
return changedRead;
});
if (read) this.options.broadcast?.(read);
this.nextRuntimeChange = context.nextChangeAt ? Date.parse(context.nextChangeAt) : undefined;
this.observedRoot = { sourceId: context.sourceId, conversationId: context.conversationId };
return context.hasMore;
}
private async browserSuppression(tx: Tx, now: number): Promise<Suppression | undefined> {
const observations = await tx.agentPushPresence.findMany({ where: { ownerEpoch: this.epoch } });
const presences = observations.filter(v => v.expiresAt.getTime() > now);
const focused = presences.filter(v => v.visible && v.focused);
if (focused.length) return { reason: 'foreground_focused', clientKind: 'web', until: Math.min(...focused.map(v => v.expiresAt.getTime())) };
const interactions = presences.map(v => v.lastInteractionAt ? Math.min(v.expiresAt.getTime(), v.lastInteractionAt.getTime() + PUSH_POLICY.interactionGraceMs) : 0).filter(at => at > now);
if (interactions.length) return { reason: 'recent_interaction', clientKind: 'web', until: Math.min(...interactions) };
const inactive = presences.map(v => Math.min(v.expiresAt.getTime(), v.inactiveUntil?.getTime() || 0)).filter(at => at > now);
if (inactive.length) return { reason: 'inactive_grace', clientKind: 'web', until: Math.min(...inactive) };
if (presences.some(v => v.visible && v.focused)) return { reason: 'foreground_focused', clientKind: 'web' };
if (presences.some(v => v.lastInteractionAt && v.lastInteractionAt.getTime() + PUSH_POLICY.interactionGraceMs > now)) return { reason: 'recent_interaction', clientKind: 'web' };
if (presences.some(v => v.inactiveUntil && v.inactiveUntil.getTime() > now)) return { reason: 'inactive_grace', clientKind: 'web' };
const connections = await tx.agentPushConnection.findMany({ where: { ownerEpoch: this.epoch, expiresAt: { gt: date(now) } } });
const unobserved = connections.filter(v => v.kind !== 'native' && (v.kind !== 'web' || !observations.some(p => p.id === v.sessionId)));
if (!unobserved.length && !this.untrackedConnections.size) return;
const firstUntracked = [...this.untrackedConnections.values()].sort((a, b) => a.at - b.at)[0];
await tx.agentPushCoordinator.updateMany({ where: { id: 'root', ownerEpoch: this.epoch, unknownSince: null }, data: { unknownSince: date(firstUntracked?.at ?? now) } });
const state = await tx.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
if (state.unknownSince!.getTime() + PUSH_POLICY.unknownGraceMs > now) return { reason: 'unknown_client_grace', until: state.unknownSince!.getTime() + PUSH_POLICY.unknownGraceMs,
if (state.unknownSince!.getTime() + PUSH_POLICY.unknownGraceMs > now) return { reason: 'unknown_client_grace',
clientKind: unobserved[0]?.kind === 'web' ? 'web' : firstUntracked?.kind || 'unknown' };
}
private async claim(id: string, commit: boolean) {
@@ -334,8 +287,8 @@ export class AgentPushCoordinator {
const state = await tx.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
const row = await tx.agentPushDelivery.findUnique({ where: { id } });
if (!row || row.status !== 'pending' || row.dueAt.getTime() > now) return;
const suppress = async ({ reason, clientKind, until }: Suppression) => {
await tx.agentPushDelivery.update({ where: { id }, data: { dueAt: date(Math.min(row.expiresAt.getTime(), Math.max(now + 100, until ?? now + 30_000))),
const suppress = async ({ reason, clientKind }: Suppression) => {
await tx.agentPushDelivery.update({ where: { id }, data: { dueAt: date(now + PUSH_POLICY.debounceMs),
lastSuppressionReason: reason, lastSuppressionClientKind: clientKind, lastSuppressedAt: date(now) } });
};
const installation = await tx.agentPushInstallation.findUnique({ where: { id: row.installationId } });
@@ -346,15 +299,15 @@ export class AgentPushCoordinator {
}
if (!this.options.config.enabled || state.lockOwner !== this.instanceId || !state.lockUntil || state.lockUntil.getTime() <= now
|| state.uncertain || !state.contextObservedAt || state.contextObservedAt.getTime() < now - 10_000 || now < this.warmUntil) {
await suppress({ reason: now < this.warmUntil ? 'startup_warmup' : 'source_or_coordinator_unavailable', clientKind: 'system', until: now < this.warmUntil ? this.warmUntil : this.nextRuntimeChange }); return;
await suppress({ reason: now < this.warmUntil ? 'startup_warmup' : 'source_or_coordinator_unavailable', clientKind: 'system' }); return;
}
if (row.kind === 'message') {
const attention: Suppression | undefined = state.nativeActive ? { reason: 'runtime_active', clientKind: 'runtime_web', until: this.nextRuntimeChange }
: state.nativeUnknown ? { reason: 'runtime_unknown', clientKind: 'runtime_unknown', until: this.nextRuntimeChange } : await this.browserSuppression(tx, now);
const attention: Suppression | undefined = state.nativeActive ? { reason: 'runtime_active', clientKind: 'runtime_web' }
: state.nativeUnknown ? { reason: 'runtime_unknown', clientKind: 'runtime_unknown' } : await this.browserSuppression(tx, now);
if (attention) { await suppress(attention); return; }
if ((state.latestUserMessageAt && state.latestUserMessageAt.getTime() + PUSH_POLICY.promptGraceMs > now)
|| (state.localPromptAt && state.localPromptAt.getTime() + PUSH_POLICY.promptGraceMs > now)) {
await suppress({ reason: 'prompt_grace', clientKind: 'system', until: Math.max(state.latestUserMessageAt?.getTime() || 0, state.localPromptAt?.getTime() || 0) + PUSH_POLICY.promptGraceMs }); return;
await suppress({ reason: 'prompt_grace', clientKind: 'system' }); return;
}
}
if (row.attempts >= MAX_ATTEMPTS) { await tx.agentPushDelivery.update({ where: { id }, data: { status: 'failed', outcome: 'attempt_limit' } }); return; }
@@ -392,45 +345,32 @@ export class AgentPushCoordinator {
await this.options.db.$transaction(async tx => {
const pending = result.outcome === 'retry' && claim.row.attempts + 1 < MAX_ATTEMPTS;
await tx.agentPushDelivery.updateMany({ where: { id, status: 'intent' }, data: { status: pending ? 'pending' : result.outcome === 'retry' ? 'failed' : result.outcome,
outcome: result.outcome, ...(pending ? { lastSuppressionReason: null, dueAt: date(this.now() + (result.retryAfterMs ?? Math.min(60_000, 2_000 * 2 ** claim.row.attempts))) } : {}) } });
outcome: result.outcome, ...(pending ? { dueAt: date(this.now() + (result.retryAfterMs ?? Math.min(60_000, 2_000 * 2 ** claim.row.attempts))) } : {}) } });
if (result.outcome === 'invalid') await tx.agentPushInstallation.updateMany({ where: { id: claim.row.installationId, version: claim.row.installationVersion, ownerEpoch: this.epoch }, data: { enabled: false } });
});
}
async tick() {
if (this.running || this.stopped || !this.options.config.adminToken) return;
this.running = true;
this.dirty = false;
const reconsider = this.reconsider; this.reconsider = false;
let retryAt: number | undefined;
let acquired = false;
try {
await this.initialize();
acquired = await this.acquire();
if (!acquired) { retryAt = this.now() + 30_000; return; }
await this.initialize(); if (!await this.acquire()) return;
// Only the lease holder recovers expired intents. Never resend a potentially accepted provider request.
await this.options.db.agentPushDelivery.updateMany({ where: { status: 'intent', updatedAt: { lt: date(this.now() - 30_000) } }, data: { status: 'ambiguous', outcome: 'interrupted_send' } });
let more = true;
for (let i = 0; i < 10 && more; i++) { if (!await this.acquire()) { retryAt = this.now() + 30_000; return; } more = await this.observe(); }
if (more) { retryAt = this.now() + 100; return; } // Catch up before dispatch.
if (reconsider) await this.options.db.agentPushDelivery.updateMany({ where: { status: 'pending', lastSuppressionReason: { not: null }, dueAt: { gt: date(this.now()) } }, data: { dueAt: date(this.now()) } });
for (let i = 0; i < 10 && more; i++) { if (!await this.acquire()) return; more = await this.observe(); }
if (more) return; // Current native read/prompt/presence must be caught up before dispatch.
const pending = await this.options.db.agentPushDelivery.findMany({ where: { status: 'pending', dueAt: { lte: date(this.now()) } }, orderBy: [{ priority: 'desc' }, { dueAt: 'asc' }], take: 20, select: { id: true } });
for (const row of pending) {
if (this.stopped) break;
if (!await this.acquire()) { retryAt = this.now() + 30_000; break; }
if (this.stopped || !await this.acquire()) break;
// Fetch fresh runtime native presence/read/latest prompt before EACH provider boundary.
if (await this.observe()) break;
await this.dispatch(row.id);
}
await this.options.db.agentPushDelivery.updateMany({ where: { status: 'pending', expiresAt: { lte: date(this.now()) } }, data: { status: 'cancelled', outcome: 'expired' } });
if (this.now() >= this.nextMaintenance) {
await this.options.db.agentPushPresence.deleteMany({ where: { expiresAt: { lt: date(this.now() - 86_400_000) } } });
await this.options.db.agentPushConnection.deleteMany({ where: { expiresAt: { lt: date(this.now()) } } });
await this.options.db.agentPushDelivery.deleteMany({ where: { status: { notIn: activeStatuses }, updatedAt: { lt: date(this.now() - 7 * 86_400_000) } } });
this.nextMaintenance = this.now() + 3_600_000;
}
this.failures = 0;
await this.options.db.agentPushPresence.deleteMany({ where: { expiresAt: { lt: date(this.now() - 86_400_000) } } });
await this.options.db.agentPushConnection.deleteMany({ where: { expiresAt: { lt: date(this.now()) } } });
await this.options.db.agentPushDelivery.deleteMany({ where: { status: { notIn: activeStatuses }, updatedAt: { lt: date(this.now() - 7 * 86_400_000) } } });
} catch {
retryAt = this.now() + Math.min(30_000, 1_000 * 2 ** Math.min(this.failures++, 5));
// An unavailable source, DB, malformed contract or clock uncertainty blocks dispatch without leaking payloads.
try {
await this.options.db.$transaction(async tx => {
@@ -440,14 +380,8 @@ export class AgentPushCoordinator {
});
} catch { /* Retry on next loop. */ }
} finally {
try { if (acquired) await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', lockOwner: this.instanceId }, data: { lockOwner: null, lockUntil: null } }); } catch { /* Lease expiry recovers. */ }
if (retryAt) this.reconsider ||= reconsider;
try { await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', lockOwner: this.instanceId }, data: { lockOwner: null, lockUntil: null } }); } catch { /* Lease expiry recovers. */ }
this.running = false;
if (this.enabled && !this.stopped) {
if (retryAt) this.schedule(retryAt);
else if (this.dirty) this.schedule(this.now() + 100);
else try { await this.schedulePending(); } catch { this.schedule(this.now() + 30_000); }
}
}
}
}
@@ -456,7 +390,6 @@ export async function registerPushRoutes(app: FastifyInstance, db: PrismaClient,
overrides?: Partial<Options>): Promise<AgentPushCoordinator> {
const config = overrides?.config || loadPushConfig();
const coordinator = new AgentPushCoordinator({ db, config, broadcast,
subscribe: changed => subscribeNotificationEvents(upstream, changed),
context: async (after, sourceId) => {
if (!upstream.apiKey) throw new Error('Runtime unavailable');
// A replaced/restored runtime can reject the old cursor before returning its identity.
-24
View File
@@ -1,24 +0,0 @@
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { createServer } from 'node:http';
import test from 'node:test';
import { WebSocketServer } from 'ws';
import { subscribeNotificationEvents } from '../src/agent-push-events.js';
test('notification observer authenticates, reconnects and stops without leaking missed invalidations', async t => {
const server = createServer(); const sockets = new WebSocketServer({ server });
server.listen(0, '127.0.0.1'); await once(server, 'listening');
let connections = 0, wakes = 0;
sockets.on('connection', (socket, request) => {
connections++;
assert.equal(request.url, '/api/notification-events');
assert.equal(request.headers.authorization, 'Bearer fixture-key');
assert.equal(request.headers['x-sybil-client'], 'backend-observer-v1');
socket.send(JSON.stringify({ type: 'notification_changed', version: 1 }));
});
const stop = subscribeNotificationEvents({ baseUrl: `http://127.0.0.1:${(server.address() as any).port}`, apiKey: 'fixture-key' }, () => { wakes++; }, { retryMs: 20 });
t.after(async () => { stop(); for (const socket of sockets.clients) socket.terminate(); sockets.close(); await new Promise<void>(resolve => server.close(() => resolve())); });
const waitFor = async (count: number) => { const deadline = Date.now() + 3000; while (wakes < count) { assert.ok(Date.now() < deadline); await new Promise(resolve => setTimeout(resolve, 10)); } };
await waitFor(1); for (const socket of sockets.clients) socket.terminate(); await waitFor(2);
assert.equal(connections, 2); stop(); await new Promise(resolve => setTimeout(resolve, 80)); assert.equal(connections, 2);
});
+1 -38
View File
@@ -51,7 +51,7 @@ async function fixture(options: { warmupMs?: number; config?: PushConfig } = {})
const installationId = randomUUID(); await manager.register(installationId, apns); await manager.tick();
return { manager, installationId, source, sent, reads, get now() { return now; }, advance(ms: number) { now += ms; },
result(value: SendResult) { result = value; }, failure(value: boolean) { failure = value; },
context(value: Partial<Context>) { context = { ...context, ...value }; manager.wake(); },
context(value: Partial<Context>) { context = { ...context, ...value }; },
candidate(cursor = '11', trigger: Context['candidates'][number]['trigger'] = 'user') { context = { ...context, cursor,
candidates: [...context.candidates, { cursor, messageId: `outbound:${cursor}`, createdAt: new Date(now).toISOString(), trigger }] }; },
};
@@ -439,40 +439,3 @@ test('endpoint rebind also retires disabled registrations from a prior credentia
await assert.rejects(current.register(f.installationId, apns), { statusCode: 409 });
await current.revoke(newer); assert.equal(await prisma.agentPushInstallation.count({ where: { enabled: true } }), 0);
});
async function eventually(predicate: () => Promise<boolean> | boolean) {
const deadline = Date.now() + 5000;
while (!await predicate()) { if (Date.now() > deadline) assert.fail('event-driven reconciliation timed out'); await new Promise(resolve => setTimeout(resolve, 10)); }
}
test('event scheduler stays idle, coalesces wakes, dispatches at the deadline and retains events during a snapshot', async t => {
const f = await fixture();
let wake!: () => void, observations = 0, unsubscribed = false, wakeDuringFetch = false;
const original = f.manager.options.context;
const manager = new AgentPushCoordinator({ ...f.manager.options, now: Date.now, subscribe: callback => { wake = callback; return () => { unsubscribed = true; }; },
context: async (...args) => { observations++; const result = await original(...args);
if (wakeDuringFetch) { wakeDuringFetch = false; wake(); }
return { ...result, webPresence: { ...result.webPresence, observedAt: new Date().toISOString() } }; } });
t.after(() => manager.stop()); manager.start();
await eventually(() => observations === 1); await new Promise(resolve => setTimeout(resolve, 2300));
assert.equal(observations, 1, 'no two-second source polling while idle');
f.candidate(); wake(); wake(); wake();
await eventually(async () => Boolean(await prisma.agentPushDelivery.findFirst({ where: { status: 'pending' } })));
await eventually(() => f.sent.length === 1);
const settled = observations;
await new Promise(resolve => setTimeout(resolve, 300)); assert.equal(observations, settled, 'no repeated polling after the outbox drains');
wakeDuringFetch = true; wake(); await eventually(() => observations >= settled + 2);
await manager.stop(); assert.equal(unsubscribed, true);
});
test('presence wake releases long suppression promptly but cannot shorten a provider retry', async () => {
const f = await fixture(), tab = randomUUID();
await f.manager.presence(tab, { sequence: 1, visibility: 'visible', focused: true, interacted: false });
f.candidate(); await f.manager.tick(); f.advance(2000); await f.manager.tick();
assert.equal((await prisma.agentPushDelivery.findFirstOrThrow()).dueAt.getTime(), f.now + 58000, 'focus waits for the lease, not another two-second tick');
await f.manager.presence(tab, { sequence: 2, visibility: 'hidden', focused: false, interacted: false });
await f.manager.tick(); assert.equal((await prisma.agentPushDelivery.findFirstOrThrow()).dueAt.getTime(), f.now + 5000);
f.advance(5000); f.result({ outcome: 'retry', retryAfterMs: 10000 }); await f.manager.tick(); assert.equal(f.sent.length, 1);
f.advance(100); f.manager.wake(); await f.manager.tick(); assert.equal(f.sent.length, 1, 'event does not bypass provider backoff');
f.advance(9900); f.result({ outcome: 'accepted' }); await f.manager.tick(); assert.equal(f.sent.length, 2);
});
-1
View File
@@ -48,7 +48,6 @@ const upstream = createServer(async (req, res) => {
const sourceSockets = new WebSocketServer({ noServer: true });
upstream.on('upgrade', (req, socket, head) => {
assert.equal(req.headers.authorization, `Bearer ${privateKey}`);
if (req.url === '/api/notification-events') { socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n'); return; }
assert.equal(req.url, '/api/ws');
connections++;
sourceSockets.handleUpgrade(req, socket, head, ws => {
-8
View File
@@ -60,11 +60,3 @@ Client API contract docs:
- `/`: full Sybil app (sidebar + chat/search workspace)
- `/search?q=bitcoin+price`: standalone search page that renders only the search query box and results panel
## Mobile web
Installed Sybil uses `standalone` display, retaining the system status bar where the browser supports it. The viewport keeps pinch zoom disabled. Compact regular-chat replies use the transcript width and the active provider/model appears beneath the title; Agent keeps its existing layout.
Horizontal navigation swipes start on workspace chrome or empty space. Text, tables, quotations, code, controls, existing selections and nested horizontal scrollers keep native gestures, including at scroll edges. Long-press/right-click on transcript text and links uses the native browser menu; the message-actions button provides Fork.
Agent notification settings include separate browser/server failure guidance and an expandable Android / GrapheneOS setup guide. See [mobile QA results and limitations](../docs/mobile-web-qa.md), including how to run the browser fixtures.
+1 -1
View File
@@ -8,7 +8,7 @@
<meta name="theme-color" content="#0f172a" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Sybil" />
<meta name="format-detection" content="telephone=no" />
<link rel="manifest" href="/manifest.webmanifest" />
+2 -1
View File
@@ -5,7 +5,8 @@
"description": "Sybil chat and search workspace",
"start_url": "/",
"scope": "/",
"display": "standalone",
"display": "fullscreen",
"display_override": ["fullscreen", "standalone"],
"background_color": "#0b0718",
"theme_color": "#0f172a",
"icons": [
+16 -25
View File
@@ -28,7 +28,6 @@ import { ChatComposer } from "@/components/chat/chat-composer";
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
import { AgentConversation } from "@/components/agent/agent-conversation";
import { AgentNotifications } from "@/components/agent/agent-notifications";
import { hasTextSelection, ownsMobileGesture } from "@/lib/mobile-workspace-gestures";
import { SearchResultsPanel } from "@/components/search/search-results-panel";
import { SearchMatchLabel } from "@/components/search/search-match-label";
import { SybilCharacter } from "@/components/sybil-character";
@@ -202,7 +201,6 @@ type MobileWorkspaceSwipe = {
touchIdentifier: number;
startX: number;
startY: number;
startTimestamp: number;
lastX: number;
lastTimestamp: number;
velocityX: number;
@@ -1810,11 +1808,8 @@ export default function App() {
};
const handleMobileWorkspaceTouchStart = (event: TargetedTouchEvent<HTMLElement>) => {
resetMobileWorkspaceSwipe();
if (
event.defaultPrevented ||
event.touches.length !== 1 ||
hasTextSelection() ||
!isMobileLayout() ||
isMobileSidebarOpen ||
isQuickQuestionOpen ||
@@ -1824,7 +1819,13 @@ export default function App() {
return;
}
if (ownsMobileGesture(event.target, event.currentTarget)) return;
const target = event.target;
if (
target instanceof Element &&
target.closest("input, textarea, select, button, a, [role='dialog'], [data-mobile-swipe-ignore]")
) {
return;
}
const touch = event.touches.item(0);
if (!touch) return;
@@ -1832,7 +1833,6 @@ export default function App() {
touchIdentifier: touch.identifier,
startX: touch.clientX,
startY: touch.clientY,
startTimestamp: event.timeStamp,
lastX: touch.clientX,
lastTimestamp: event.timeStamp,
velocityX: 0,
@@ -1843,10 +1843,6 @@ export default function App() {
const handleMobileWorkspaceTouchMove = (event: TargetedTouchEvent<HTMLElement>) => {
const swipe = mobileWorkspaceSwipeRef.current;
if (!swipe) return;
if (event.touches.length !== 1 || hasTextSelection() || (swipe.direction === null && event.timeStamp - swipe.startTimestamp > 350)) {
resetMobileWorkspaceSwipe();
return;
}
const touch = findTouch(event.touches, swipe.touchIdentifier);
if (!touch) return;
@@ -1894,7 +1890,7 @@ export default function App() {
swipe.velocityX = ((touch.clientX - swipe.lastX) / elapsedMs) * 1000;
}
resetMobileWorkspaceSwipe();
if (direction === null || hasTextSelection()) return;
if (direction === null) return;
const directionalDistance = deltaX * direction;
const directionalVelocity = swipe.velocityX * direction;
@@ -2204,9 +2200,8 @@ export default function App() {
const menuWidth = 176;
const menuHeight = 48;
const padding = 8;
const anchor = (event.currentTarget as HTMLElement).getBoundingClientRect();
const x = Math.min(anchor.left, window.innerWidth - menuWidth - padding);
const y = Math.min(anchor.bottom, window.innerHeight - menuHeight - padding);
const x = Math.min(event.clientX, window.innerWidth - menuWidth - padding);
const y = Math.min(event.clientY, window.innerHeight - menuHeight - padding);
setContextMenu({
kind: "message",
chatId: selectedItem.id,
@@ -3509,7 +3504,7 @@ export default function App() {
<aside
className={cn(
"workspace-sidebar glass-panel fixed inset-y-0 left-0 z-40 flex w-[86vw] max-w-80 shrink-0 flex-col overflow-y-auto overscroll-contain border-r border-violet-300/18 transition-transform md:static md:z-auto md:w-80 md:max-w-none",
"workspace-sidebar glass-panel fixed inset-y-0 left-0 z-40 flex w-[86vw] max-w-80 shrink-0 flex-col border-r border-violet-300/18 transition-transform md:static md:z-auto md:w-80 md:max-w-none",
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)}
>
@@ -3732,12 +3727,11 @@ export default function App() {
</aside>
<main
className="workspace-content glass-panel relative flex min-w-0 flex-1 flex-col overflow-hidden"
className="workspace-content glass-panel relative flex min-w-0 flex-1 touch-pan-y flex-col overflow-hidden md:touch-auto"
onTouchStart={handleMobileWorkspaceTouchStart}
onTouchMove={handleMobileWorkspaceTouchMove}
onTouchEnd={handleMobileWorkspaceTouchEnd}
onTouchCancel={handleMobileWorkspaceTouchCancel}
onContextMenu={resetMobileWorkspaceSwipe}
>
{isAgentMode ? (
<AgentConversation room={agentRoom} onOpenSidebar={() => setIsMobileSidebarOpen(true)} isVisible={!isMobileSidebarOpen && !isQuickQuestionOpen} />
@@ -3755,11 +3749,8 @@ export default function App() {
<Menu className="h-4 w-4" />
</Button>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-1.5">
<h1 className="truncate text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
{!isSearchMode ? <p className="truncate text-xs text-violet-100/58 md:hidden" title={`${getProviderLabel(provider)} · ${model || "No model"}`}>
{getProviderLabel(provider)} · {model || "No model"}
</p> : null}
</div>
</div>
<div className="flex shrink-0 items-center justify-end gap-2">
@@ -3773,8 +3764,8 @@ export default function App() {
aria-label="Open chat settings"
>
<Settings2 className="h-4 w-4 shrink-0" />
<span className="hidden shrink-0 md:inline">Settings</span>
<span className="hidden min-w-0 max-w-[18rem] truncate text-xs font-medium text-violet-100/58 md:inline">
<span className="hidden shrink-0 sm:inline">Settings</span>
<span className="hidden min-w-0 max-w-[18rem] truncate text-xs font-medium text-violet-100/58 sm:inline">
{getProviderLabel(provider)} · {model || "No model"}
</span>
</Button>
@@ -3805,7 +3796,7 @@ export default function App() {
messages={displayMessages}
isLoading={isLoadingSelection}
isSending={isSendingActiveChat}
onMessageActions={openMessageContextMenu}
onMessageContextMenu={openMessageContextMenu}
/>
) : (
<SearchResultsPanel
@@ -6,7 +6,7 @@ import { disableAgentBrowserPush, enableAgentBrowserPush, getAgentPushState, obs
export function AgentNotifications({ onLogout }: { onLogout: () => Promise<void> }) {
const [state, setState] = useState(getAgentPushState), [signingOut, setSigningOut] = useState(false);
useEffect(() => observeAgentPush(setState), []);
return <details className="max-h-[50dvh] shrink-0 overflow-y-auto overscroll-contain border-t border-violet-300/15 px-3 py-2 text-xs text-violet-100/75">
return <details className="shrink-0 border-t border-violet-300/15 px-3 py-2 text-xs text-violet-100/75">
<summary className="flex cursor-pointer items-center gap-2 rounded py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"><Bell className="h-4 w-4" />Agent notifications</summary>
<div className="space-y-2 pb-1 pt-2">
<p>Notifications include Agent message text and may appear on your lock screen.</p>
@@ -18,15 +18,6 @@ export function AgentNotifications({ onLogout }: { onLogout: () => Promise<void>
<Button className="h-8 text-xs" variant="ghost" disabled={state.busy} onClick={() => void refreshAgentBrowserPush()}>Refresh</Button>
<Button className="h-8 text-xs" variant="ghost" disabled={signingOut} onClick={() => { setSigningOut(true); void onLogout().finally(() => setSigningOut(false)); }}>Sign out</Button>
</div>
<details>
<summary className="cursor-pointer py-2 underline">Android / GrapheneOS help</summary>
<div className="space-y-2 pb-2">
<p>Vanadium uses Google Play services for Web Push. On GrapheneOS, sandboxed Google Play must be installed in the same profile as Vanadium, with network access and a battery optimization exception for Play services.</p>
<p>Allow notifications for Sybil in browser site settings and for Vanadium in Android settings. Then reload Sybil and enable notifications again. Permission alone does not mean the browser can register for push.</p>
<p>If you choose not to use Google Play, you need a browser with an independent Web Push service. Sybil cannot replace the browser's push service or detect whether Play services is installed.</p>
<p><a className="underline" href="https://grapheneos.org/usage#sandboxed-google-play" target="_blank" rel="noopener noreferrer">GrapheneOS setup guide</a>{" · "}<a className="underline" href="https://chromium.googlesource.com/chromium/src/+/main/components/gcm_driver/android/java/src/org/chromium/components/gcm_driver/GoogleCloudMessagingV2.java" target="_blank" rel="noopener noreferrer">Chromium push implementation</a></p>
</div>
</details>
</div>
</details>;
}
@@ -5,13 +5,13 @@ import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
import { getMessageAttachments, type Message } from "@/lib/api";
import { asStreamingAssistantMetadata } from "@/lib/chat-stream-presentation";
import { MarkdownContent } from "@/components/markdown/markdown-content";
import { ChevronDown, ChevronUp, Globe2, Link2, MoreHorizontal, Wrench } from "lucide-preact";
import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact";
type Props = {
messages: Message[];
isLoading: boolean;
isSending: boolean;
onMessageActions?: (event: MouseEvent, messageId: string) => void;
onMessageContextMenu?: (event: MouseEvent, messageId: string) => void;
};
type ToolLogMetadata = {
@@ -533,7 +533,7 @@ export function StreamingAssistantPresentation({ message, isSending }: { message
);
}
export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageActions }: Props) {
export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageContextMenu }: Props) {
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-"));
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
@@ -604,8 +604,13 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageAct
"min-w-0 space-y-3",
isUser
? "message-bubble message-bubble--user max-w-[87%] md:max-w-[78%]"
: "w-full max-w-full md:w-auto md:max-w-[85%] text-base leading-7 text-violet-50"
: "max-w-[85%] text-base leading-7 text-violet-50"
)}
onContextMenu={
message.role === "assistant" && !message.id.startsWith("temp-") && onMessageContextMenu
? (event) => onMessageContextMenu(event, message.id)
: undefined
}
>
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
{streamingAssistantMetadata ? (
@@ -623,13 +628,6 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageAct
className={cn("[&_a]:text-inherit [&_a]:underline", !isUser && "leading-[1.82] text-violet-50")}
/>
) : null}
{message.role === "assistant" && !message.id.startsWith("temp-") && onMessageActions ? (
<button type="button" aria-label="Message actions" title="Message actions"
className="flex h-9 w-9 items-center justify-center rounded text-muted-foreground hover:bg-violet-400/12 hover:text-violet-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"
onClick={event => onMessageActions(event, message.id)}>
<MoreHorizontal className="h-4 w-4" />
</button>
) : null}
</div>
</div>
);
-11
View File
@@ -462,17 +462,6 @@ textarea {
word-break: break-word;
}
.md-table-scroll,
.md-content pre,
.md-content blockquote {
overscroll-behavior-x: contain;
}
.md-content blockquote {
max-width: 100%;
overflow-x: auto;
}
.md-content > :first-child {
margin-top: 0;
}
-55
View File
@@ -1,55 +0,0 @@
import type { Message } from "./api";
// The runtime also uses the Unix epoch for historical entries without a time.
// The transcript renders these as "Earlier", without inventing a send time.
export const UNKNOWN_AGENT_TIME = "1970-01-01T00:00:00.000Z";
const object = (value: unknown): Record<string, unknown> | undefined => value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown> : undefined;
const timestamp = (value: unknown): string | undefined => {
if (typeof value !== "string" || !value.trim()) return undefined;
const time = Date.parse(value);
return Number.isFinite(time) && time !== 0 ? new Date(time).toISOString() : undefined;
};
export function agentMessageTime(message: Pick<Message, "createdAt" | "metadata">): string | undefined {
const info = object(message.metadata);
return timestamp(message.createdAt) ?? timestamp(info?.startedAt) ?? timestamp(info?.completedAt);
}
/** Normalize historical/partial rows at both transports, before state or cursors change. */
export function normalizeAgentMessage(value: unknown): Message {
const message = object(value);
if (!message || typeof message.id !== "string" || !message.id
|| typeof message.role !== "string" || !["system", "user", "assistant", "tool"].includes(message.role)) throw new Error("Invalid agent message.");
const info = object(message.metadata);
// Older runtime REST redaction emitted protected tool rows with only id/role/name.
// Keep their identity for a later complete upsert; never discard a whole page.
const content = typeof message.content === "string" ? message.content
: message.role === "tool" && message.content == null ? (typeof message.name === "string" ? message.name : "Tool") : undefined;
if (content === undefined) throw new Error("Invalid agent message.");
const createdAt = agentMessageTime(message as Message) ?? UNKNOWN_AGENT_TIME;
const needsToolMetadata = message.role === "tool" && info?.kind !== "tool_call";
if (message.createdAt === createdAt && message.content === content && !needsToolMetadata) return message as Message;
return { ...message, content, createdAt,
...(needsToolMetadata ? { metadata: { ...info, kind: "tool_call" } } : {}),
} as Message;
}
function cursor(message: Message): bigint | undefined {
const entryId = object(message.metadata)?.entryId;
if ((typeof entryId === "number" && Number.isSafeInteger(entryId) && entryId >= 0)
|| (typeof entryId === "string" && /^\d+$/.test(entryId))) return BigInt(entryId);
}
/** One total order for history, queue snapshots and optimistic rows, including ties. */
export function compareAgentMessages(left: Message, right: Message): number {
const byTime = Date.parse(agentMessageTime(left) ?? UNKNOWN_AGENT_TIME) - Date.parse(agentMessageTime(right) ?? UNKNOWN_AGENT_TIME);
if (byTime) return byTime;
const a = cursor(left), b = cursor(right);
if (a !== b) {
if (a === undefined) return 1;
if (b === undefined) return -1;
return a < b ? -1 : 1;
}
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
}
+1 -2
View File
@@ -1,6 +1,5 @@
import type { Message } from "./api";
import { agentAttachments, attachmentCaption, parseAgentAttachment, type AgentAttachment } from "./agent-attachments.ts";
import { compareAgentMessages, normalizeAgentMessage } from "./agent-messages.ts";
export const AGENT_OUTBOX_STORAGE_KEY = "sybil.agent.optimistic-outbox.v1";
export type AgentDeliveryStatus = "sending" | "queued" | "complete" | "failed" | "unconfirmed";
@@ -166,7 +165,7 @@ export function createAgentOptimisticOutbox(options: {
metadata: { kind: "message", status: row.status, channel: "sybil", requestId: row.requestId, ...(row.attachments?.length ? { agentAttachments: row.attachments } : {}), ...(row.error ? { error: row.error } : {}) },
});
}
return [...displayed.values()].map(normalizeAgentMessage).sort(compareAgentMessages);
return [...displayed.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
},
snapshot(): AgentOutboxRow[] { return [...rows.values()].map(row => ({ ...row })); },
};
+11 -10
View File
@@ -1,6 +1,5 @@
import { getApiBaseUrl, getAuthToken } from "./api";
import { ensureServiceWorker } from "../pwa";
import { browserPushOperation } from "./agent-push-errors.ts";
import {
AGENT_PUSH_STORAGE_KEY, AgentPushClient, agentPushAuthorityScope, agentPushCursor, agentPushSourceId,
createAgentPresenceReporter, createAgentPushController, parseAgentPushConsent, type AgentPushView, type PushSubscriptionHandle,
@@ -25,12 +24,17 @@ async function consentScope() {
return digest;
}
const listeners = new Set<(state: AgentPushView) => void>();
function bounded<T>(promise: Promise<T>, timeoutMs = 10000): Promise<T> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Browser notification operation timed out.")), timeoutMs);
promise.then(value => { clearTimeout(timeout); resolve(value); }, () => { clearTimeout(timeout); reject(new Error("Browser notification operation failed.")); });
});
}
const subscriptionHandle = (subscription: PushSubscription): PushSubscriptionHandle => ({
toJSON: () => subscription.toJSON(), unsubscribe: () => browserPushOperation(() => subscription.unsubscribe(), "subscription"),
toJSON: () => subscription.toJSON(), unsubscribe: () => bounded(subscription.unsubscribe()),
});
const readConsent = () => { try { return parseAgentPushConsent(localStorage.getItem(AGENT_PUSH_STORAGE_KEY)); } catch { return null; } };
const support = () => {
if (!window.isSecureContext) return "insecure" as const;
const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
const standalone = window.matchMedia("(display-mode: standalone)").matches || window.matchMedia("(display-mode: fullscreen)").matches
|| (navigator as Navigator & { standalone?: boolean }).standalone === true;
@@ -75,14 +79,11 @@ const controller = createAgentPushController({
requestPermission: () => Notification.requestPermission(),
subscription: async () => {
if (!("serviceWorker" in navigator)) return null;
const registration = await browserPushOperation(() => navigator.serviceWorker.getRegistration("/"), "worker");
const subscription = registration ? await browserPushOperation(() => registration.pushManager.getSubscription(), "subscription") : null;
const registration = await bounded(navigator.serviceWorker.getRegistration("/"));
const subscription = registration ? await bounded(registration.pushManager.getSubscription()) : null;
return subscription ? subscriptionHandle(subscription) : null;
},
subscribe: async key => {
const registration = await browserPushOperation(ensureServiceWorker, "worker", 20000);
return subscriptionHandle(await browserPushOperation(() => registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: decodePublicKey(key) }), "subscription"));
},
subscribe: async key => subscriptionHandle(await bounded((await ensureServiceWorker()).pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: decodePublicKey(key) }))),
clearNotifications: () => postWorker({ type: "sybil-agent-logout", version: 1 }),
},
});
@@ -90,7 +91,7 @@ export const getAgentPushState = () => controller.state();
export function observeAgentPush(listener: (state: AgentPushView) => void) { listeners.add(listener); listener(controller.state()); return () => { listeners.delete(listener); }; }
export const enableAgentBrowserPush = () => controller.enable();
export const disableAgentBrowserPush = () => controller.disable();
export const refreshAgentBrowserPush = () => controller.refresh(true);
export const refreshAgentBrowserPush = () => controller.refresh();
const presence = createAgentPresenceReporter({
snapshot: () => ({ visibility: !document.hidden && document.visibilityState === "visible" ? "visible" : "hidden", focused: document.hasFocus() }),
-43
View File
@@ -1,43 +0,0 @@
/** Only messages constructed here may reach the UI; provider bodies can contain secrets. */
export class AgentPushError extends Error {}
export type AgentPushStage = "permission" | "worker" | "subscription" | "storage" | "lock" | "service";
export function agentPushFailure(cause: unknown, stage: AgentPushStage): AgentPushError {
if (cause instanceof AgentPushError) return cause;
const name = cause instanceof Error || (typeof DOMException !== "undefined" && cause instanceof DOMException) ? cause.name : "";
if (stage === "storage" || name === "QuotaExceededError") return new AgentPushError("Browser storage is unavailable. Allow site storage and free some space, then try again.");
if (stage === "worker") return new AgentPushError("The notification worker could not start. Reload Sybil and try again. If this continues, check that this browser allows service workers and site storage.");
if (stage === "lock") return new AgentPushError("Another Sybil tab is still updating notifications. Close other Sybil tabs, then try again.");
if ((stage === "permission" || stage === "subscription") && ["NotAllowedError", "SecurityError"].includes(name)) {
return new AgentPushError("The browser did not allow notifications. Allow notifications for Sybil in site settings and for the browser in system settings, then try again.");
}
if (stage === "permission") return new AgentPushError("The browser could not request notification permission. Check browser site settings and system settings, then reload Sybil and try again.");
if (stage === "subscription") {
if (name === "InvalidStateError") return new AgentPushError("The browser has an incompatible notification subscription. Turn off notifications for Sybil in site settings, reload, then enable them again.");
if (["InvalidAccessError", "DataError", "TypeError"].includes(name)) return new AgentPushError("The browser rejected the server's notification key. Ask the server administrator to check Web Push configuration.");
if (name === "NotSupportedError") return new AgentPushError("This browser exposes notification APIs but cannot create a push subscription. Update it or use a browser with a working Web Push service. See Android / GrapheneOS help below.");
const detail = ["AbortError", "NetworkError", "TimeoutError"].includes(name) ? ` (${name})` : "";
return new AgentPushError(`The browser could not connect to its push service${detail}. Check your connection and browser push support, then try again. On Vanadium, see Android / GrapheneOS help below.`);
}
return new AgentPushError("The Sybil notification service could not be reached. Check your connection and try again.");
}
export function agentPushHttpFailure(status: number): AgentPushError {
if (status === 401 || status === 403) return new AgentPushError("Your sign-in is no longer authorized. Sign out and sign in again to manage notifications.");
if (status === 400 || status === 413) return new AgentPushError("The server rejected the notification subscription. Refresh Sybil and retry; if it continues, ask the server administrator to check Web Push configuration.");
if (status === 409) return new AgentPushError("This notification registration has expired. Refresh and enable notifications again to create a new registration.");
if (status === 429) return new AgentPushError("The notification service is busy. Wait a moment, then try again.");
if (status === 503) return new AgentPushError("Web Push is unavailable on the Sybil server. Ask the server administrator to check notification configuration and service health.");
return new AgentPushError("The Sybil server could not save notification settings. Try again; if it continues, ask the server administrator to check service health.");
}
/** Keep DOMException names for diagnosis, but never expose their arbitrary messages. */
export function browserPushOperation<T>(operation: () => Promise<T>, stage: AgentPushStage, timeoutMs = 10000): Promise<T> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(agentPushFailure(new DOMException("", "TimeoutError"), stage)), timeoutMs);
const fail = (cause: unknown) => { clearTimeout(timeout); reject(agentPushFailure(cause, stage)); };
try { operation().then(value => { clearTimeout(timeout); resolve(value); }, fail); }
catch (cause) { fail(cause); }
});
}
+24 -43
View File
@@ -1,5 +1,3 @@
import { AgentPushError, agentPushFailure, agentPushHttpFailure, type AgentPushStage } from "./agent-push-errors.ts";
/** Browser-only push contracts. Secrets stay in page request headers, never worker messages. */
export const AGENT_PUSH_STORAGE_KEY = "sybil.agent.push.v1";
export const AGENT_PUSH_CHANGED_EVENT = "sybil-agent-push-changed";
@@ -20,9 +18,8 @@ export function parseAgentPushConfig(value: unknown): AgentPushConfig {
|| (config.quarantined !== undefined && typeof config.quarantined !== "boolean") || !policy
|| typeof policy.presenceHeartbeatMs !== "number" || policy.presenceHeartbeatMs < 5000 || policy.presenceHeartbeatMs > 30000
|| typeof policy.presenceLeaseMs !== "number" || policy.presenceLeaseMs < 30000 || policy.presenceLeaseMs > 120000
|| (config.webPushAvailable && config.vapidPublicKey === undefined)
|| (config.vapidPublicKey !== undefined && (typeof config.vapidPublicKey !== "string" || !/^[A-Za-z0-9_-]{87}$/.test(config.vapidPublicKey)))) {
throw new AgentPushError("The server returned invalid notification configuration. Refresh Sybil; if it continues, ask the server administrator to check Web Push configuration.");
throw new Error("Notification configuration is unavailable.");
}
return { ...config, quarantined: config.quarantined === true } as AgentPushConfig;
}
@@ -39,28 +36,28 @@ export class AgentPushClient {
constructor(options: { baseUrl: string; token: () => string | null; fetch?: typeof globalThis.fetch }) { this.options = options; }
private async request(path: string, method = "GET", body?: unknown, keepalive = false, expectedScope?: string): Promise<unknown> {
const token = this.options.token();
if (!token) throw new AgentPushError("Sign in to manage notifications.");
if (!token) throw new Error("Sign in to manage notifications.");
const controller = new AbortController(), timeout = setTimeout(() => controller.abort(), 8000);
try {
// Capture the bearer before hashing, then use that exact bearer for the
// request. A concurrent sign-in must not turn old-authority cleanup into
// a new-authority DELETE whose204 falsely clears a durable tombstone.
if (expectedScope !== undefined && expectedScope !== await agentPushAuthorityScope(this.options.baseUrl, token)) throw new AgentPushError("The signed-in server changed. Refresh and enable notifications again.");
if (expectedScope !== undefined && expectedScope !== await agentPushAuthorityScope(this.options.baseUrl, token)) throw new Error("Notification authority changed.");
const response = await (this.options.fetch || globalThis.fetch)(`${this.options.baseUrl.replace(/\/+$/, "")}/v1/agent${path}`, {
method, headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
...(body === undefined ? {} : { body: JSON.stringify(body) }), cache: "no-store", redirect: "error", credentials: "omit", signal: controller.signal, keepalive,
});
// Never echo server/provider bodies: they may contain endpoint or key material.
if (!response.ok) throw agentPushHttpFailure(response.status);
if (!response.ok) throw new Error(response.status === 401 ? "Sign in to manage notifications." : "Notification settings could not be saved. Try again.");
return response.status === 204 ? undefined : await response.json();
} catch (cause) { throw agentPushFailure(cause, "service"); }
} catch { throw new Error("Notification service is unavailable. Try again when connected."); }
finally { clearTimeout(timeout); }
}
async config() { return parseAgentPushConfig(await this.request("/push/config")); }
async register(id: string, subscription: PushSubscriptionJSON, expectedScope?: string) {
if (!agentPushUuid(id)) throw new Error("Invalid notification installation.");
const value = object(await this.request(`/push/installations/${id}`, "PUT", { transport: "web", enabled: true, subscription }, false, expectedScope));
if (!value || value.installationId !== id || value.transport !== "web" || value.enabled !== true || !Number.isSafeInteger(value.version) || Number(value.version) < 1) throw new AgentPushError("The Sybil server did not acknowledge the notification registration. Refresh and try again.");
if (!value || value.installationId !== id || value.transport !== "web" || value.enabled !== true || !Number.isSafeInteger(value.version) || Number(value.version) < 1) throw new Error("Notification registration was not acknowledged.");
}
async revoke(id: string, expectedScope?: string) {
if (!agentPushUuid(id)) throw new Error("Invalid notification installation.");
@@ -97,7 +94,7 @@ export function parseAgentPushConsent(raw: string | null): AgentPushConsent | nu
export type AgentPushView = { busy: boolean; enabled: boolean; canEnable: boolean; message: string; error: string | null };
export type PushSubscriptionHandle = Pick<PushSubscription, "toJSON" | "unsubscribe">;
type PushPlatform = {
support: () => "supported" | "unsupported" | "install" | "insecure";
support: () => "supported" | "unsupported" | "install";
permission: () => NotificationPermission;
requestPermission: () => Promise<NotificationPermission>;
subscription: () => Promise<PushSubscriptionHandle | null>;
@@ -112,16 +109,15 @@ export function createAgentPushController(options: {
uuid: () => string; lock: <T>(operation: () => Promise<T>) => Promise<T>; changed: (view: AgentPushView) => void;
}) {
let config: AgentPushConfig | null = null, busy = false, error: string | null = null, localRevocation: AgentPushConsent | null = null;
let refreshError: string | null = null;
let scope: string | null = null;
let registeredSubscription: string | null = null;
const currentConsent = () => localRevocation || options.read();
const matchesContext = (record: AgentPushConsent) => !!scope && record.scope === scope
&& agentPushSourceId(record.sourceId) && record.sourceId === config?.sourceId;
const transportAvailable = () => !!config?.enabled && config.webPushAvailable && !!config.vapidPublicKey && !config.quarantined && agentPushSourceId(config.sourceId);
const transportAvailable = () => !!config?.enabled && config.webPushAvailable && !config.quarantined && agentPushSourceId(config.sourceId);
const refreshContext = async () => {
const nextScope = await options.scope(), nextConfig = await options.api.config();
if (nextScope !== await options.scope()) { scope = null; config = null; throw new AgentPushError("The signed-in server changed. Refresh and enable notifications again."); }
if (nextScope !== await options.scope()) { scope = null; config = null; throw new Error("Notification authority changed."); }
scope = nextScope; config = nextConfig;
};
const recheckContext = async (record: AgentPushConsent) => {
@@ -134,17 +130,14 @@ export function createAgentPushController(options: {
const cleanupFull = (consent?.pendingRevocations?.length || 0) >= MAX_AGENT_PUSH_REVOCATIONS;
const enabled = consent?.enabled === true && !consent.revocationPending && permission === "granted";
const message = support === "install" ? "On iPhone or iPad, add Sybil to your Home Screen, then enable notifications there."
: support === "insecure" ? "Notifications require a secure connection. Open Sybil over HTTPS, then try again."
: support === "unsupported" ? "This browser does not support notifications here. Use a supported browser over HTTPS."
: permission === "denied" ? "Notifications are blocked. Allow Sybil in browser site settings and allow the browser's notifications in system settings, then refresh."
: permission === "denied" ? "Notifications are blocked. Allow them in browser settings, then try again."
: cleanupFull ? "Notification cleanup is full. Sign in to a previous server to finish removing old registrations before enabling more."
: consent?.revocationPending ? "Notifications are off here. Server removal will retry when connected."
: config?.quarantined ? "Notification delivery is paused after an Agent source change. Ask the server administrator to reconcile the source before enabling notifications."
: config?.enabled && config.webPushAvailable && !agentPushSourceId(config.sourceId) ? "The Agent notification source is not ready yet. Refresh in a moment; if it continues, ask the server administrator to check the Agent connection."
: !available ? "Notifications are not configured on this server yet."
: enabled ? "Notifications are on for this browser."
: "Get an alert for unread Agent replies when all your web sessions are inactive.";
return { busy, enabled, canEnable: !busy && available && support === "supported" && permission !== "denied" && !consent?.revocationPending && !cleanupFull, message, error: error || refreshError };
return { busy, enabled, canEnable: !busy && available && support === "supported" && permission !== "denied" && !consent?.revocationPending && !cleanupFull, message, error };
};
const publish = () => options.changed(state());
const write = (consent: AgentPushConsent) => { options.write(consent); publish(); };
@@ -202,7 +195,7 @@ export function createAgentPushController(options: {
await revoke(pending);
};
const disable = async () => {
error = null; refreshError = null;
error = null;
const record = { ...consent(), enabled: false, revocationPending: true, revision: options.uuid() };
// Synchronous durable intent is necessary before logout clears page credentials.
registeredSubscription = null;
@@ -223,9 +216,8 @@ export function createAgentPushController(options: {
}
finally { busy = false; publish(); }
};
const refresh = async (clearError = false) => {
const refresh = async () => {
if (busy) return;
if (clearError) error = null;
try {
await refreshContext();
await options.lock(async () => {
@@ -254,18 +246,18 @@ export function createAgentPushController(options: {
const stillMatching = await recheckContext(record);
if (currentConsent()?.revision !== record.revision) { await retireAtOriginalAuthority(record); return; }
if (!stillMatching) { await invalidate(record); return; }
} catch (cause) { await invalidate(record); throw cause; }
} catch { await invalidate(record); throw new Error("Notification service is unavailable."); }
registeredSubscription = serialized;
}
}
});
refreshError = null;
} catch (cause) { refreshError = agentPushFailure(cause, "service").message; }
error = null;
} catch { error = "Notification service is unavailable. Try again when connected."; }
publish();
};
const enable = async () => {
if (!state().canEnable || state().enabled || busy || !config?.vapidPublicKey || !scope || !agentPushSourceId(config.sourceId)) return;
error = null; refreshError = null;
error = null;
// Explicit revocation permanently retires the old UUID on the server. Each
// new opt-in has a new identity, so a delayed old PUT cannot resurrect it.
const record: AgentPushConsent = { version: 1, installationId: options.uuid(), revision: options.uuid(), scope, sourceId: config.sourceId,
@@ -275,46 +267,35 @@ export function createAgentPushController(options: {
// Must execute in the original click stack, before locks, worker startup or fetches.
let permission: Promise<NotificationPermission>;
try { permission = options.platform.requestPermission(); }
catch (cause) { error = agentPushFailure(cause, "permission").message; publish(); return; }
catch { error = "Notifications could not be requested. Check browser settings and try again."; publish(); return; }
busy = true; publish();
let stage: AgentPushStage = "permission";
try {
const decision = await permission;
if (decision !== "granted") { error = decision === "denied"
? "Notifications were not allowed. Allow Sybil in browser site settings and allow the browser's notifications in system settings, then refresh."
: "The notification prompt was dismissed. Tap Enable notifications again and choose Allow."; return; }
stage = "lock";
if (await permission !== "granted") { error = "Notifications were not allowed. You can change this in browser settings."; return; }
await options.lock(async () => {
stage = "service";
const unchanged = () => options.read()?.revision === record.revision;
if (!unchanged()) return;
const matching = await recheckContext(record);
if (!unchanged()) return;
if (!matching || config?.vapidPublicKey !== publicKey) { await invalidate(record); return; }
stage = "subscription";
const subscription = await options.platform.subscription() || await options.platform.subscribe(publicKey);
if (!unchanged()) { await subscription.unsubscribe(); return; }
// A tombstone also covers a lost registration response or failed local storage write.
const pending = { ...record, enabled: false, revocationPending: true };
const pending = { ...record, enabled: false, revocationPending: true }; write(pending);
try {
stage = "storage"; write(pending);
stage = "service";
const matchingBeforeRegister = await recheckContext(record);
if (!unchanged() || !matchingBeforeRegister || config?.vapidPublicKey !== publicKey) { await revoke(pending); return; }
await options.api.register(record.installationId, subscription.toJSON(), record.scope);
if (!unchanged()) { await retireAtOriginalAuthority(record); await subscription.unsubscribe(); return; }
const matchingAfterRegister = await recheckContext(record);
if (!unchanged() || !matchingAfterRegister || config?.vapidPublicKey !== publicKey) { await revoke(pending); return; }
stage = "storage"; write({ ...record, enabled: true, revocationPending: false });
write({ ...record, enabled: true, revocationPending: false });
registeredSubscription = JSON.stringify(subscription.toJSON());
} catch (cause) {
// Retain cleanup intent even when storage fails after subscribing.
if (options.read()?.revision === record.revision) localRevocation = pending;
} catch {
await revoke(pending);
throw agentPushFailure(cause, stage);
throw new Error("Notifications could not be enabled. Try again when connected.");
}
});
} catch (cause) { error = agentPushFailure(cause, stage).message; }
} catch { error = "Notifications could not be enabled. Try again when connected."; }
finally { busy = false; publish(); }
};
return { state, refresh, enable, disable };
+23 -37
View File
@@ -1,7 +1,6 @@
import type { Message } from "./api";
import { parseAgentAttachment, type AgentAttachment, type AgentAttachmentCapability } from "./agent-attachments.ts";
import { agentPushCursor, agentPushSourceId, agentPushUuid } from "./agent-push.ts";
import { agentMessageTime, compareAgentMessages, normalizeAgentMessage, UNKNOWN_AGENT_TIME } from "./agent-messages.ts";
export type AgentActivity = {
state: "idle" | "thinking" | "tool" | "waiting" | "error";
@@ -57,9 +56,7 @@ function mergeMessage(previous: Message, incoming: Message): Message {
const prior = metadata(previous), next = metadata(incoming);
// A delayed activity snapshot must not demote an already placed user message.
if (prior?.status === "complete" && next?.status === "queued") return previous;
const priorTime = agentMessageTime(previous), nextTime = agentMessageTime(incoming);
if (prior?.kind !== "tool_call" || next?.kind !== "tool_call") return { ...previous, ...incoming,
createdAt: nextTime ?? priorTime ?? UNKNOWN_AGENT_TIME };
if (prior?.kind !== "tool_call" || next?.kind !== "tool_call") return { ...previous, ...incoming };
const terminal = prior.status === "completed" || prior.status === "failed";
const staleStart = terminal && next.status === "initiated";
const merged = staleStart ? { ...defined(next), ...defined(prior) } : { ...defined(prior), ...defined(next) };
@@ -67,32 +64,23 @@ function mergeMessage(previous: Message, incoming: Message): Message {
const oldArgs = object(prior.args), newArgs = object(next.args);
if (oldArgs && (!newArgs || Object.keys(newArgs).length === 0)) merged.args = oldArgs;
return { ...previous, ...incoming, ...(staleStart ? { content: previous.content, name: previous.name } : {}),
createdAt: priorTime && nextTime ? (priorTime < nextTime ? priorTime : nextTime) : priorTime ?? nextTime ?? UNKNOWN_AGENT_TIME, metadata: merged };
createdAt: previous.createdAt < incoming.createdAt ? previous.createdAt : incoming.createdAt, metadata: merged };
}
/** Replays and separate tool-start/result pages update stable rows instead of duplicating them. */
export function mergeAgentMessages(previous: readonly Message[], incoming: readonly Message[]): Message[] {
const messages = new Map(previous.map(normalizeAgentMessage).map(message => [message.id, message]));
for (const message of incoming.map(normalizeAgentMessage)) {
const messages = new Map(previous.map(message => [message.id, message]));
for (const message of incoming) {
const prior = messages.get(message.id);
messages.set(message.id, prior ? mergeMessage(prior, message) : message);
}
return [...messages.values()].sort(compareAgentMessages);
}
function parseAgentPage(value: unknown): AgentMessagesPage {
const page = object(value);
if (!page || typeof page.conversationId !== "string" || !Array.isArray(page.messages) || typeof page.hasMore !== "boolean") throw new Error("Invalid agent messages frame.");
if (page.cursor !== null) normalizeAgentCursor(page.cursor);
if (page.beforeCursor != null) normalizeAgentCursor(page.beforeCursor);
return { ...page, beforeCursor: page.beforeCursor ?? null, messages: page.messages.map(normalizeAgentMessage) } as AgentMessagesPage;
}
function parseAgentActivity(value: unknown): AgentActivity {
const activity = object(value);
if (!activity || (activity.tools !== undefined && !Array.isArray(activity.tools))
|| (activity.queuedMessages !== undefined && !Array.isArray(activity.queuedMessages))) throw new Error("Invalid agent activity frame.");
return { ...activity, tools: activity.tools ?? [],
queuedMessages: ((activity.queuedMessages ?? []) as unknown[]).map(normalizeAgentMessage) } as AgentActivity;
return [...messages.values()].sort((a, b) => {
const byTime = a.createdAt.localeCompare(b.createdAt);
if (byTime) return byTime;
const left = messageCursor(a), right = messageCursor(b);
if (left !== null && right !== null && left !== right) return BigInt(left) < BigInt(right) ? -1 : 1;
return a.id.localeCompare(b.id);
});
}
export const createAgentRoomState = (): AgentRoomState => ({
conversationId: null, messages: [], cursor: "0", readCursor: "0", activity: null,
@@ -109,7 +97,6 @@ function readSource(state: Pick<AgentRoomState, "sourceId" | "cursor" | "readCur
return incoming;
}
export function applyAgentPage(state: AgentRoomState, page: AgentMessagesPage): AgentRoomState {
page = parseAgentPage(page);
if (state.conversationId !== null && state.conversationId !== page.conversationId) throw new Error("Agent conversation changed; reload the page.");
// Validate before changing state. Failed delivery/decoding cannot advance replay position.
const cursor = page.cursor === null ? state.cursor : advanceAgentCursor(state.cursor, page.cursor);
@@ -124,7 +111,7 @@ export function applyAgentEvent(state: AgentRoomState, event: AgentRoomEvent): A
if (event.type === "read_state") return applyAgentReadState(state, event);
if (event.type !== "activity") return state;
if (state.conversationId !== null && state.conversationId !== event.conversationId) throw new Error("Agent activity belongs to a different conversation.");
return { ...state, conversationId: event.conversationId, activity: parseAgentActivity(event.activity) };
return { ...state, conversationId: event.conversationId, activity: event.activity };
}
export function agentUnreadCount(state: AgentRoomState): number {
return state.messages.filter(message => {
@@ -145,12 +132,15 @@ export function parseAgentRoomEvent(raw: string): AgentRoomEvent | null {
|| (event.updatedAt !== null && typeof event.updatedAt !== "string")
|| (event.sourceId !== undefined && event.sourceId !== null && !agentPushSourceId(event.sourceId)))) throw new Error("Invalid Agent read frame.");
if (event.type === "messages") {
return { ...parseAgentPage(event), type: "messages" };
}
if (event.type === "activity") {
if (typeof event.conversationId !== "string") throw new Error("Invalid agent activity frame.");
return { ...event, activity: parseAgentActivity(event.activity) } as AgentRoomEvent;
if (typeof event.conversationId !== "string" || !Array.isArray(event.messages) || typeof event.hasMore !== "boolean") throw new Error("Invalid agent messages frame.");
if (event.cursor !== null) normalizeAgentCursor(event.cursor);
for (const item of event.messages) {
const message = object(item);
if (!message || typeof message.id !== "string" || typeof message.content !== "string" || typeof message.createdAt !== "string"
|| !["system", "user", "assistant", "tool"].includes(String(message.role))) throw new Error("Invalid agent message.");
}
}
if (event.type === "activity" && (typeof event.conversationId !== "string" || !object(event.activity))) throw new Error("Invalid agent activity frame.");
return event as AgentRoomEvent;
}
export function agentAuthFrame(token: string | null, cursor: string, presenceSessionId?: string): string {
@@ -184,13 +174,9 @@ export class AgentRoomClient {
}
return value as T;
}
async descriptor(signal?: AbortSignal): Promise<AgentDescriptor> {
const value = object(await this.request("", { signal }));
if (!value || typeof value.id !== "string") throw new Error("Invalid agent descriptor.");
return { ...value, activity: parseAgentActivity(value.activity) } as AgentDescriptor;
}
async messages(after: string, signal?: AbortSignal): Promise<AgentMessagesPage> {
return parseAgentPage(await this.request(`/messages?${new URLSearchParams({ after: normalizeAgentCursor(after), limit: "200" })}`, { signal }));
descriptor(signal?: AbortSignal): Promise<AgentDescriptor> { return this.request("", { signal }); }
messages(after: string, signal?: AbortSignal): Promise<AgentMessagesPage> {
return this.request(`/messages?${new URLSearchParams({ after: normalizeAgentCursor(after), limit: "200" })}`, { signal });
}
readState(signal?: AbortSignal): Promise<AgentReadState> { return this.request("/read-state", { signal }); }
markRead(cursor: string, signal?: AbortSignal, sourceId?: string | null): Promise<AgentReadState> {
-14
View File
@@ -1,14 +0,0 @@
/** Text, controls and nested scrollers own their gestures, even at a scroll edge. */
export function ownsMobileGesture(target: EventTarget | null, workspace: HTMLElement): boolean {
if (!(target instanceof Element)) return true;
if (target.closest("input, textarea, select, button, a, summary, [contenteditable]:not([contenteditable='false']), [role='button'], [role='dialog'], [data-mobile-swipe-ignore], .md-content, .message-bubble, .agent-room__plain-message, pre, code, table, blockquote")) return true;
for (let element: Element | null = target; element && element !== workspace; element = element.parentElement) {
if (element.scrollWidth > element.clientWidth + 1 && /auto|scroll/.test(getComputedStyle(element).overflowX)) return true;
}
return false;
}
export function hasTextSelection(): boolean {
const selection = window.getSelection();
return !!selection && !selection.isCollapsed;
}
@@ -7,15 +7,6 @@ const canonical = (id, content = "Hello", time = "2026-10-14T12:01:00.000Z") =>
metadata: { kind: "message", entryId: 123, status: "complete", channel: "sybil" },
});
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
test("display sorts partial server rows and tied entry cursors consistently", () => {
const box = createAgentOptimisticOutbox({ submit: async () => assert.fail("no send"),
storage: { read: () => [], write: () => {} }, changed: () => {} });
const rows = [canonical("z"), { id: "tool:1:partial", role: "tool", name: "owner_phone_context" },
{ ...canonical("a"), metadata: { entryId: 124 } }];
assert.deepEqual(box.display(rows).map(row => row.id), ["tool:1:partial", "z", "a"]);
assert.deepEqual(box.display([...rows].reverse()), box.display(rows));
});
function fixture(submit, legacy = {}) {
let stored = null;
let serial = 0;
-140
View File
@@ -1,140 +0,0 @@
import assert from "node:assert/strict";
// Production bundle/worker, mobile Chromium; all API and push-provider operations are fixtures.
// Use the same PLAYWRIGHT_MODULE, CHROME_EXECUTABLE and WEB_TEST_URL as agent-push.browser.mjs.
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || "playwright-core");
const target = new URL(process.env.WEB_TEST_URL || "http://127.0.0.1:5197/");
assert.ok(target.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(target.hostname));
const browser = await chromium.launch({ executablePath: process.env.CHROME_EXECUTABLE || undefined, headless: true,
...(process.env.BROWSER_NO_SANDBOX === "1" ? { args: ["--no-sandbox"] } : {}) });
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce";
const config = { version: 1, enabled: true, webPushAvailable: true, apnsAvailable: false, sourceId, quarantined: false,
vapidPublicKey: "B".repeat(87), policy: { presenceHeartbeatMs: 15000, presenceLeaseMs: 60000 } };
let registerStatus = 200, configStatus = 200, puts = 0;
const errors = [];
await context.addInitScript(() => {
localStorage.setItem("sybil_admin_token", "mobile-notification-fixture");
window.__permission = "default"; window.__decision = "default"; window.__subscribed = false; window.__subscribes = 0;
window.__subscribeError = null; window.__workerError = false; window.__subscribeHang = false; window.__permissionGesture = false;
Object.defineProperty(Notification, "permission", { configurable: true, get: () => window.__permission });
Notification.requestPermission = () => {
window.__permissionGesture = navigator.userActivation.isActive;
window.__permission = window.__decision; return Promise.resolve(window.__permission);
};
const register = navigator.serviceWorker.register.bind(navigator.serviceWorker);
navigator.serviceWorker.register = (...args) => window.__workerError ? Promise.reject(new Error("private-worker-detail")) : register(...args);
const subscription = { toJSON: () => ({ endpoint: "https://fcm.googleapis.com/fcm/send/private-endpoint", keys: { p256dh: "B".repeat(87), auth: "A".repeat(22) } }),
unsubscribe: async () => { window.__subscribed = false; return true; } };
PushManager.prototype.getSubscription = async () => window.__subscribed ? subscription : null;
PushManager.prototype.subscribe = async () => {
window.__subscribes++;
if (window.__subscribeError) throw new DOMException("private-provider-key", window.__subscribeError);
if (window.__subscribeHang) return await new Promise(() => {});
window.__subscribed = true; return subscription;
};
});
await context.route("**/*", async route => {
const request = route.request(), url = new URL(request.url()), path = url.pathname;
if (url.origin !== target.origin) { await route.abort(); return; }
if (!path.startsWith("/api/")) { await route.continue(); return; }
let value = {}, status = 200;
if (path.endsWith("/push/config")) { value = config; status = configStatus; }
else if (path.includes("/push/installations/")) {
if (request.method() === "DELETE") { await route.fulfill({ status: 204 }); return; }
puts++; status = registerStatus;
value = status === 200 ? { installationId: path.split("/").at(-1), transport: "web", enabled: true, version: 1 } : { error: "private-server-token" };
} else if (path.endsWith("/auth/session")) value = { mode: "token", authenticated: true };
else if (path.endsWith("/workspace-items")) value = { items: [] };
else if (path.endsWith("/models")) value = { providers: {} };
else if (path.endsWith("/chat-tools")) value = { tools: [] };
else if (path.endsWith("/active-runs")) value = { chats: [], searches: [] };
else if (path.endsWith("/read-state")) value = { sourceId, cursor: "0", updatedAt: null };
await route.fulfill({ status, json: value });
});
await context.routeWebSocket("**/api/v1/agent/ws", socket => socket.close());
const page = await context.newPage(); page.on("pageerror", error => errors.push(error.message));
const enable = page.getByRole("button", { name: "Enable notifications", exact: true });
const refresh = page.getByRole("button", { name: "Refresh", exact: true });
const alert = page.locator(".workspace-sidebar [role=alert]");
const waitEnable = () => page.waitForFunction(() => Array.from(document.querySelectorAll("button")).some(el => el.textContent === "Enable notifications" && !el.disabled));
async function attempt(pattern) {
await refresh.click(); await waitEnable(); await enable.click();
await page.waitForFunction(pattern => {
const el = document.querySelector(".workspace-sidebar [role=alert]");
return el && new RegExp(pattern).test(el.textContent);
}, pattern.source);
assert.match(await alert.textContent(), pattern);
assert.equal(await page.evaluate(() => window.__permissionGesture), true);
assert.equal(await page.getByRole("button", { name: "Turn off notifications", exact: true }).count(), 0);
assert.doesNotMatch(await page.locator(".workspace-sidebar").textContent(), /private-worker-detail|private-provider-key|private-server-token|private-endpoint/);
}
try {
await page.goto(target.href, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "Open sidebar", exact: true }).click();
await page.getByText("Agent notifications", { exact: true }).click();
await attempt(/prompt was dismissed/); assert.equal(puts, 0);
await page.evaluate(() => { window.__decision = "denied"; });
await attempt(/not allowed/); assert.equal(puts, 0); assert.ok(await enable.isDisabled());
await page.evaluate(() => { window.__permission = "default"; window.__decision = "granted"; window.__workerError = true; });
await attempt(/worker could not start/); assert.equal(puts, 0);
await page.evaluate(() => { window.__workerError = false; });
for (const [name, pattern] of [["AbortError", /push service \(AbortError\)/], ["NotAllowedError", /site settings.*system settings/],
["NotSupportedError", /cannot create a push subscription/], ["InvalidStateError", /incompatible notification subscription/]]) {
await page.evaluate(name => { window.__subscribeError = name; }, name);
await attempt(pattern); assert.equal(puts, 0);
// Successful foreground config refresh must not dismiss an opt-in diagnosis.
await page.evaluate(() => window.dispatchEvent(new Event("online")));
await page.waitForTimeout(150);
assert.match(await alert.textContent(), pattern);
}
await page.evaluate(() => { window.__subscribeError = null; window.__subscribeHang = true; });
await page.clock.install();
await refresh.click(); await waitEnable();
const before = await page.evaluate(() => window.__subscribes);
await enable.click(); await page.waitForFunction(before => window.__subscribes > before, before);
await page.clock.fastForward(10001);
await alert.filter({ hasText: "TimeoutError" }).waitFor(); assert.equal(puts, 0);
await page.clock.resume();
await page.evaluate(() => { window.__subscribeHang = false; });
for (const [status, pattern] of [[401, /sign-in.*authorized/], [403, /sign-in.*authorized/], [400, /rejected.*subscription/],
[409, /expired/], [429, /busy/], [503, /unavailable on the Sybil server/]]) {
registerStatus = status; await attempt(pattern);
assert.equal(await page.evaluate(() => window.__subscribed), false, "failed registration rolls back the subscription");
}
registerStatus = 200; configStatus = 503;
await refresh.click(); await alert.filter({ hasText: "unavailable on the Sybil server" }).waitFor();
configStatus = 200;
await refresh.click(); await waitEnable(); await enable.click();
await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor();
assert.equal(await alert.count(), 0);
await page.getByText("Android / GrapheneOS help", { exact: true }).click();
await page.getByRole("link", { name: "GrapheneOS setup guide" }).waitFor();
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
if (process.env.PUSH_ERROR_SCREENSHOT) await page.screenshot({ path: process.env.PUSH_ERROR_SCREENSHOT });
for (const viewport of [{ width: 320, height: 568 }, { width: 390, height: 450 }]) {
await page.setViewportSize(viewport);
const signOut = page.getByRole("button", { name: "Sign out", exact: true });
await signOut.scrollIntoViewIfNeeded();
assert.ok(await signOut.evaluate(el => {
const box = el.getBoundingClientRect(); return box.top >= 0 && box.bottom <= innerHeight;
}), "notification controls remain reachable with long help and a short viewport");
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
}
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole("button", { name: "Turn off notifications", exact: true }).click(); await waitEnable();
await page.evaluate(() => { delete window.PushManager; });
await refresh.click(); await page.getByText(/This browser does not support notifications here/).waitFor();
assert.ok(await enable.isDisabled());
await page.evaluate(() => {
Object.defineProperty(window, "isSecureContext", { configurable: true, value: true });
Object.defineProperty(navigator, "userAgent", { configurable: true, value: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)" });
});
await refresh.click(); await page.getByText(/On iPhone or iPad, add Sybil to your Home Screen/).waitFor();
assert.ok(await enable.isDisabled());
await page.evaluate(() => { Object.defineProperty(window, "isSecureContext", { configurable: true, value: false }); });
await refresh.click(); await page.getByText(/Notifications require a secure connection/).waitFor();
assert.ok(await enable.isDisabled());
assert.deepEqual(errors, []);
console.log("PASS mobile permission dismissal/denial, worker/subscription failures, timeout, HTTP statuses, rollback, retained diagnostics, recovery, unsupported/insecure browsers and GrapheneOS guidance");
} finally { await context.close(); await browser.close(); }
+1 -1
View File
@@ -75,7 +75,7 @@ const expectEventually = async predicate => { for (let i = 0; i < 100; i++) { if
try {
await page.goto(target.href, { waitUntil: "networkidle" });
await page.getByText("Agent notifications", { exact: true }).click();
await page.getByText(/The Agent notification source is not ready yet/).waitFor();
await page.getByText("Notifications are not configured on this server yet.", { exact: true }).waitFor();
assert.equal(await page.getByRole("button", { name: "Enable notifications", exact: true }).isDisabled(), true, "unknown source cannot opt in");
assert.deepEqual(await page.evaluate(() => window.__pushCalls), []);
config.sourceId = sourceId;
+1 -68
View File
@@ -6,7 +6,6 @@ import {
MAX_AGENT_PUSH_REVOCATIONS, parseAgentPushConfig, parseAgentPushConsent,
} from "../src/lib/agent-push.ts";
import { agentAuthFrame, applyAgentEvent, createAgentRoomState, parseAgentRoomEvent } from "../src/lib/agent-room.ts";
import { AgentPushError, browserPushOperation } from "../src/lib/agent-push-errors.ts";
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce";
const replacementSourceId = "22222222-2222-4222-8222-222222222222";
@@ -121,72 +120,7 @@ test("a stuck cross-tab lock cannot prevent server retirement or local logout cl
test("synchronous permission API failures remain a recoverable opt-in error", async () => {
const f = fixture({ platform: { requestPermission: () => { throw new Error("user gesture rejected"); } } });
await f.controller.refresh(); await f.controller.enable();
assert.equal(f.record().enabled, false); assert.match(f.controller.state().error, /browser.*system settings/);
});
test("browser subscription failures retain safe diagnoses across background refreshes", async () => {
for (const [name, message] of [["AbortError", /push service \(AbortError\)/], ["NotAllowedError", /site settings.*system settings/],
["NotSupportedError", /cannot create a push subscription/], ["InvalidStateError", /incompatible notification subscription/], ["InvalidAccessError", /notification key/]]) {
const f = fixture({ platform: { subscribe: () => browserPushOperation(() => Promise.reject(new DOMException("private-endpoint-and-key", name)), "subscription") } });
await f.controller.refresh(); await f.controller.enable();
assert.match(f.controller.state().error, message);
assert.equal(f.controller.state().enabled, false);
assert.ok(!f.calls.some(([name]) => name === "register"));
await f.controller.refresh();
assert.match(f.controller.state().error, message, "heartbeat cannot erase a failed opt-in");
assert.doesNotMatch(JSON.stringify(f.states), /private-endpoint-and-key/);
await f.controller.refresh(true);
assert.equal(f.controller.state().error, null, "explicit refresh clears the previous attempt");
}
});
test("worker startup and subscription timeouts have distinct actionable errors", async () => {
for (const [stage, message] of [["worker", /worker could not start/], ["subscription", /push service \(TimeoutError\)/]]) {
const f = fixture({ platform: { subscribe: () => browserPushOperation(() => new Promise(() => {}), stage, 5) } });
await f.controller.refresh(); await f.controller.enable();
assert.match(f.controller.state().error, message); assert.equal(f.controller.state().busy, false);
assert.equal(f.controller.state().enabled, false);
}
});
test("dismissed prompts and insecure contexts do not attempt a subscription", async () => {
const f = fixture({ platform: { requestPermission: async () => "default" } });
await f.controller.refresh(); await f.controller.enable();
assert.match(f.controller.state().error, /dismissed/); assert.equal(f.calls.length, 0);
f.setSupport("insecure");
assert.equal(f.controller.state().canEnable, false); assert.match(f.controller.state().message, /HTTPS/);
});
test("notification API errors retain status-specific guidance through rollback without echoing bodies", async () => {
for (const [status, message] of [[401, /sign-in.*authorized/], [403, /sign-in.*authorized/], [400, /rejected.*subscription/],
[409, /expired/], [429, /busy/], [503, /unavailable on the Sybil server/], [500, /server.*save notification settings/]]) {
const api = new AgentPushClient({ baseUrl: "https://sybil.example", token: () => "private-auth-token",
fetch: async () => new Response(JSON.stringify({ error: "private-endpoint-and-key" }), { status }) });
const f = fixture({ api: { register: (id, subscription) => api.register(id, subscription) } });
await f.controller.refresh(); await f.controller.enable();
assert.match(f.controller.state().error, message); assert.equal(f.controller.state().enabled, false);
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
assert.doesNotMatch(JSON.stringify(f.states), /private-auth-token|private-endpoint-and-key/);
}
});
test("failed storage after subscribing still unsubscribes and retains cleanup intent", async () => {
const f = fixture(); let writes = 0;
const write = f.options.write;
f.options.write = value => { if (++writes > 1) throw new DOMException("private-storage-detail", "QuotaExceededError"); write(value); };
await f.controller.refresh(); await f.controller.enable();
assert.match(f.controller.state().error, /storage.*free some space/);
assert.equal(f.controller.state().enabled, false);
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
assert.ok(f.calls.some(([name]) => name === "revoke"));
assert.ok(!f.calls.some(([name]) => name === "register"));
assert.doesNotMatch(JSON.stringify(f.states), /private-storage-detail/);
});
test("server errors during refresh are distinct from unavailable browser push", async () => {
const f = fixture({ api: { config: async () => { throw new AgentPushError("Web Push is unavailable on the Sybil server."); } } });
await f.controller.refresh();
assert.match(f.controller.state().error, /Sybil server/); assert.equal(f.controller.state().canEnable, false);
assert.equal(f.record().enabled, false); assert.match(f.controller.state().error, /could not be requested/);
});
test("subscription rotation upserts the same installation; permission revocation removes it", async () => {
@@ -484,7 +418,6 @@ test("read frames validate source/cursor and preserve existing read semantics",
assert.throws(() => parseAgentRoomEvent(JSON.stringify({ type: "read_state", cursor: "1", updatedAt: null, sourceId: "https://evil.test" })));
assert.equal(parseAgentPushConsent('{"enabled":true}'), null);
assert.throws(() => parseAgentPushConfig({ ...configuration(), policy: { presenceHeartbeatMs: 1, presenceLeaseMs: 60000 } }));
assert.throws(() => parseAgentPushConfig({ ...configuration(), vapidPublicKey: undefined }), /invalid notification configuration/);
});
test("API keeps credentials in headers, validates receipts, handles 204 and redacts provider bodies", async () => {
+1 -83
View File
@@ -20,88 +20,6 @@ const sourceA = "4599d348-e053-4937-987d-1392746fb2ce", sourceB = "22222222-2222
const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; };
const sourceRoom = () => applyAgentPage(applyAgentReadState(createAgentRoomState(), { sourceId: sourceA, cursor: "90", updatedAt: null }), page("100", [message(100)]));
test("redacted historical tool rows survive REST loading, socket replay and outbox rendering", async () => {
const redacted = { id: "tool:1:protected", role: "tool", name: "owner_phone_context" };
const input = page("3", [message(1), redacted, message(3)]);
const client = new AgentRoomClient({ fetch: async () => Response.json(input) });
let state = createAgentRoomState();
await syncAgentHistory(client, "0", update => { state = applyAgentPage(state, update); });
const initial = state.messages;
const row = initial.find(row => row.id === redacted.id);
assert.equal(row.createdAt, "1970-01-01T00:00:00.000Z", "unknown times are stable, never fabricated as now");
assert.equal(row.content, "owner_phone_context");
assert.equal(state.cursor, "3");
const event = parseAgentRoomEvent(JSON.stringify({ type: "messages", ...input }));
state = applyAgentEvent(state, event);
assert.deepEqual(state.messages, initial, "replaying partial history is idempotent");
const box = createAgentOptimisticOutbox({ submit: async () => assert.fail("no send"),
storage: { read: () => [], write: () => {} }, changed: () => {} });
assert.deepEqual(box.display(agentDisplayMessages(state)), initial);
});
test("partial tool updates retain known time, terminal metadata and chronological ordering", () => {
const id = "tool:1:partial";
const start = message(2, "tool", { id, metadata: { kind: "tool_call", entryId: 2, status: "initiated", startedAt: "2026-10-14T10:00:02.000Z" } });
const done = message(3, "tool", { id, createdAt: undefined, metadata: { kind: "tool_call", entryId: 3, status: "completed", completedAt: "2026-10-14T10:00:03.000Z" } });
const partial = { id, role: "tool", name: "owner_phone_context" };
const rows = mergeAgentMessages([message(1), start], [done, partial, start, message(4)]);
assert.deepEqual(rows.map(row => row.id), ["entry:1", id, "entry:4"]);
assert.equal(rows[1].createdAt, start.createdAt);
assert.equal(rows[1].metadata.status, "completed");
assert.equal(rows[1].metadata.completedAt, done.metadata.completedAt);
assert.equal(mergeAgentMessages([], [done])[0].createdAt, done.metadata.completedAt);
assert.equal(mergeAgentMessages([partial], [start])[0].createdAt, start.createdAt);
});
test("an unknown historical tool start never masks a known completion timestamp", () => {
const unknown = "1970-01-01T00:00:00.000Z", known = "2026-10-14T10:00:03.000Z";
const start = message(2, "tool", { id: "tool:1:historical", createdAt: unknown,
metadata: { kind: "tool_call", entryId: 2, status: "initiated", startedAt: unknown } });
const done = message(3, "tool", { id: start.id, createdAt: known,
metadata: { kind: "tool_call", entryId: 3, status: "completed", completedAt: known } });
for (const rows of [[start, done], [done, start], [{ ...start, metadata: { ...start.metadata, ...done.metadata } }]]) {
const merged = mergeAgentMessages([], rows);
assert.equal(merged[0].createdAt, known);
assert.equal(merged[0].metadata.status, "completed");
}
});
test("missing, null, invalid and tied timestamps have stable ordering independent of arrival order", () => {
const rows = [message(11, "assistant", { createdAt: null }), message(2, "assistant", { createdAt: undefined }),
message(3, "assistant", { createdAt: "not-a-date" }), message(4, "assistant", { createdAt: 0 }),
message(5, "assistant", { createdAt: null, metadata: { kind: "message" } })];
const expected = ["entry:2", "entry:3", "entry:4", "entry:11", "entry:5"];
assert.deepEqual(mergeAgentMessages([], rows).map(row => row.id), expected);
assert.deepEqual(mergeAgentMessages([], [...rows].reverse()).map(row => row.id), expected);
const tied = [message(11, "assistant", { createdAt: "2026-10-14T10:00:00Z" }), message(2, "assistant", { createdAt: "2026-10-14T11:00:00+01:00" })];
assert.deepEqual(mergeAgentMessages([], tied).map(row => row.id), ["entry:2", "entry:11"]);
});
test("REST descriptors and socket activity normalize partial queued messages consistently", async () => {
const pending = message(1, "user", { id: "inbound:sybil:partial", createdAt: undefined, metadata: { kind: "message", status: "queued" } });
const value = { id: "1", activity: activity([pending]) };
const client = new AgentRoomClient({ fetch: async () => Response.json(value) });
const descriptor = await client.descriptor();
const fromRest = applyAgentEvent(createAgentRoomState(), { type: "activity", conversationId: descriptor.id, activity: descriptor.activity });
const fromSocket = applyAgentEvent(createAgentRoomState(), parseAgentRoomEvent(JSON.stringify({ type: "activity", conversationId: "1", activity: value.activity })));
assert.deepEqual(agentDisplayMessages(fromRest), agentDisplayMessages(fromSocket));
assert.equal(agentDisplayMessages(fromRest)[0].createdAt, "1970-01-01T00:00:00.000Z");
const malformed = { ...pending, content: undefined };
assert.throws(() => applyAgentEvent(fromRest, { type: "activity", conversationId: "1", activity: activity([malformed]) }), /Invalid agent message/);
assert.equal(fromRest.cursor, "0");
});
test("invalid message bodies fail at both transport boundaries before advancing replay", async () => {
for (const malformed of [null, { role: "assistant", content: "missing ID" }, message(2, "assistant", { content: null }), message(2, ["assistant"])]) {
const input = page("3", [message(1), malformed]);
const client = new AgentRoomClient({ fetch: async () => Response.json(input) });
let applied = false;
await assert.rejects(syncAgentHistory(client, "0", () => { applied = true; }), /Invalid agent message/);
assert.equal(applied, false);
assert.throws(() => parseAgentRoomEvent(JSON.stringify({ type: "messages", ...input })), /Invalid agent message/);
}
});
test("tool starts and results merge across replay without downgrading completed cards", () => {
const start = message(2, "tool", { id: "tool:1:call1", metadata: { kind: "tool_call", entryId: 2, status: "initiated", args: { query: "cookie" }, startedAt: "2026-10-14T10:00:00Z" } });
const done = message(3, "tool", { id: start.id, metadata: { kind: "tool_call", entryId: 3, status: "completed", resultPreview: "found" } });
@@ -321,7 +239,7 @@ test("client uses Sybil auth and routes while websocket credentials remain in th
let token = "first-session-token";
const client = new AgentRoomClient({ baseUrl: "https://sybil.example/api/", token: () => token, fetch: async (url, init) => {
calls.push({ url, init }); return new Response(JSON.stringify(init.method === "POST"
? { id: 1, status: "queued", messageId: "inbound:sybil:request-1" } : url.includes("/messages?") ? page("0") : { cursor: "0", updatedAt: null }), { status: 200 });
? { id: 1, status: "queued", messageId: "inbound:sybil:request-1" } : { cursor: "0", updatedAt: null }), { status: 200 });
} });
await client.messages("0");
token = "second-session-token";
-226
View File
@@ -1,226 +0,0 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
// Run against a local production preview after npm run build. No live API calls.
// PLAYWRIGHT_MODULE=/path/playwright-core/index.mjs CHROME_EXECUTABLE=/path/chrome
// WEB_TEST_URL=http://127.0.0.1:5197 node tests/mobile-workspace.browser.mjs
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || "playwright-core");
const target = new URL(process.env.WEB_TEST_URL || "http://127.0.0.1:5197/");
assert.ok(target.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(target.hostname));
const baseline = process.env.MOBILE_BASELINE === "1";
const artifactDir = process.env.MOBILE_ARTIFACT_DIR;
if (artifactDir) await mkdir(artifactDir, { recursive: true });
const browser = await chromium.launch({ executablePath: process.env.CHROME_EXECUTABLE || undefined, headless: true,
...(process.env.BROWSER_NO_SANDBOX === "1" ? { args: ["--no-sandbox"] } : {}) });
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true,
permissions: ["clipboard-read", "clipboard-write"] });
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce";
const date = "2026-11-02T12:00:00.000Z";
const markdown = [
"Mobile answer with selectable text. " + "This regular chat response should use the available width. ".repeat(7),
"| Name | Description with a very wide column | Another long heading | Final column |\n| --- | --- | --- | --- |\n| Alpha | Wide table content | More content | End |",
"> A quotation with selectable text and a nested horizontal code block:\n>\n> ```text\n> " + "quoted-unbroken-code-".repeat(22) + "\n> ```",
"```js\nconst longLine = '" + "horizontal-code-".repeat(28) + "';\n```",
"A final paragraph with a [native link menu](https://example.test/document).",
].join("\n\n");
const chat = { id: "mobile-chat", title: "Mobile QA conversation with a long title", parentChatId: null, titleGenerationPending: false,
createdAt: date, updatedAt: date, starred: false, starredAt: null, initiatedProvider: "openai", initiatedModel: "mobile-model-one",
lastUsedProvider: "openai", lastUsedModel: "mobile-model-one", additionalSystemPrompt: null, enabledTools: [],
messages: [{ id: "mobile-assistant", role: "assistant", content: markdown, createdAt: date, name: null, metadata: null }] };
const idle = { state: "idle", isThinking: false, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [] };
const errors = [], calls = [];
let readCursor = "0";
await context.addInitScript(() => localStorage.setItem("sybil_admin_token", "mobile-fixture-token"));
await context.route("**/*", async route => {
const url = new URL(route.request().url());
if (url.origin !== target.origin) { await route.abort(); return; }
if (!url.pathname.startsWith("/api/")) { await route.continue(); return; }
const path = url.pathname, method = route.request().method();
calls.push({ path, method });
let value;
if (path.endsWith("/auth/session")) value = { mode: "token", authenticated: true };
else if (path.endsWith("/workspace-items")) value = { items: [{ ...chat, type: "chat" }] };
else if (path.endsWith("/models")) value = { providers: { openai: { models: ["mobile-model-one", "mobile-model-two"], loadedAt: date, error: null } } };
else if (path.endsWith("/chat-tools") || path.endsWith("/agent/tools")) value = { tools: [] };
else if (path.endsWith("/active-runs")) value = { chats: [], searches: [] };
else if (path.endsWith("/chats/mobile-chat")) {
if (method === "PATCH") Object.assign(chat, route.request().postDataJSON());
value = { chat };
} else if (path.endsWith("/agent/messages")) value = { conversationId: "1", messages: [{ ...chat.messages[0], id: "outbound:1", metadata: { entryId: 1 } }], cursor: "1", beforeCursor: null, hasMore: false };
else if (path.endsWith("/agent")) value = { id: "1", title: "Sybil Agent", activity: idle, protocol: "pi-room.v1", websocket: "/v1/agent/ws" };
else if (path.endsWith("/read-state")) {
if (method === "PUT") readCursor = route.request().postDataJSON().cursor;
value = { sourceId, cursor: readCursor, updatedAt: null };
}
else if (path.endsWith("/push/config")) value = { version: 1, enabled: false, webPushAvailable: false, apnsAvailable: false, sourceId, quarantined: false, policy: { presenceHeartbeatMs: 15000, presenceLeaseMs: 60000 } };
else if (path.includes("/presence/")) value = { expiresAt: date };
else value = {};
await route.fulfill({ json: value });
});
await context.routeWebSocket("**/api/v1/agent/ws", socket => socket.onMessage(raw => {
if (JSON.parse(String(raw)).type === "auth") socket.send(JSON.stringify({ type: "ready", conversationId: "1" }));
}));
const page = await context.newPage();
page.on("pageerror", error => errors.push(error.message));
const cdp = await context.newCDPSession(page);
async function openSidebar() {
const sidebar = page.locator(".workspace-sidebar");
if (await sidebar.evaluate(el => el.getBoundingClientRect().right <= 0)) {
await page.getByRole("button", { name: "Open sidebar", exact: true }).click();
}
}
async function selectChat() {
await openSidebar();
await page.getByRole("button", { name: new RegExp(chat.title) }).click();
await page.locator("main h1").filter({ hasText: chat.title }).waitFor();
await page.locator("main .md-content").waitFor();
await page.waitForFunction(() => document.querySelector(".workspace-sidebar").getBoundingClientRect().right <= 1);
}
async function drag(x, y, dx, dy = 0) {
await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
for (let step = 1; step <= 8; step++) {
await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: x + dx * step / 8, y: y + dy * step / 8 }] });
await page.waitForTimeout(25);
}
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
await page.waitForTimeout(200);
}
async function scrollHorizontally(locator) {
await locator.scrollIntoViewIfNeeded();
await locator.evaluate(el => { el.scrollLeft = 0; });
const box = await locator.boundingBox();
const x = Math.min(box.x + box.width - 30, 350), y = box.y + Math.min(box.height / 2, 30);
await drag(x, y, -130);
}
async function contextMenuCanceled(locator) {
return locator.evaluate(el => !el.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 100, clientY: 200 })));
}
try {
await page.goto(target.href, { waitUntil: "networkidle" });
await selectChat();
const metrics = await page.locator("main .md-content").evaluate(el => ({
width: el.getBoundingClientRect().width, parentWidth: el.parentElement.parentElement.getBoundingClientRect().width,
touchAction: getComputedStyle(document.querySelector("main")).touchAction,
modelVisible: Array.from(document.querySelectorAll("main header p")).some(el => el.textContent.includes("mobile-model-one") && el.getBoundingClientRect().height > 0),
}));
if (artifactDir) await page.screenshot({ path: `${artifactDir}/regular-390.png` });
const canceled = await contextMenuCanceled(page.locator("main .md-content p").first());
if (baseline) { await page.getByRole("button", { name: "Fork", exact: true }).waitFor(); await page.keyboard.press("Escape"); }
const table = page.locator("main .md-table-scroll");
await scrollHorizontally(table);
const stayed = await page.locator("main h1").textContent() === chat.title;
const scrolled = stayed ? await table.evaluate(el => el.scrollLeft) : 0;
console.log(JSON.stringify({ baseline, metrics, contextMenuCanceled: canceled, tableScrollLeft: scrolled, stayedInChat: stayed }));
if (baseline) {
await openSidebar(); await page.locator('[data-testid="agent-sidebar-item"]').click();
await page.locator(".agent-room .md-content").waitFor();
await page.waitForFunction(() => document.querySelector(".workspace-sidebar").getBoundingClientRect().right <= 1);
console.log("Agent width", await page.locator(".agent-room .md-content").evaluate(el => el.getBoundingClientRect().width));
if (artifactDir) await page.screenshot({ path: `${artifactDir}/agent-390.png` });
} else {
assert.equal(metrics.touchAction, "auto");
assert.equal(metrics.modelVisible, true);
assert.ok(Math.abs(metrics.width - metrics.parentWidth) < 2, "compact answer uses full transcript width");
assert.equal(canceled, false, "native menu is not canceled");
assert.ok(stayed && scrolled > 50, "trusted horizontal pan scrolls the table without navigation");
// Even at either end of nested scrollers, pans belong to the content.
for (const locator of [table, page.locator("main blockquote pre"), page.locator("main .md-content > pre")]) {
await scrollHorizontally(locator);
assert.equal(await page.locator("main h1").textContent(), chat.title);
assert.ok(await locator.evaluate(el => el.scrollLeft > 50));
await locator.evaluate(el => { el.scrollLeft = el.scrollWidth; });
await locator.scrollIntoViewIfNeeded();
let box = await locator.boundingBox();
await drag(box.x + box.width - 30, box.y + 20, -130);
assert.equal(await page.locator("main h1").textContent(), chat.title);
await locator.evaluate(el => { el.scrollLeft = 0; });
box = await locator.boundingBox();
await drag(box.x + 30, box.y + 20, 130);
assert.ok(await page.locator(".workspace-sidebar").evaluate(el => el.getBoundingClientRect().right <= 0));
}
const paragraph = page.locator("main .md-content p").first();
await paragraph.scrollIntoViewIfNeeded();
await paragraph.evaluate(el => {
const range = document.createRange(); range.selectNodeContents(el);
window.getSelection().removeAllRanges(); window.getSelection().addRange(range);
});
await page.keyboard.press("Control+c");
assert.match(await page.evaluate(() => navigator.clipboard.readText()), /Mobile answer/);
assert.equal(await contextMenuCanceled(paragraph), false);
assert.equal(await contextMenuCanceled(page.locator("main .md-content a")), false);
await page.keyboard.press("Escape");
// Selection, multi-touch, long-press and touchcancel must not become navigation.
for (const scenario of ["selection", "multi", "hold", "cancel"]) {
await page.locator("main header").evaluate((el, scenario) => {
if (scenario !== "selection") window.getSelection().removeAllRanges();
const touch = (x, id = 1) => new Touch({ identifier: id, target: el, clientX: x, clientY: 20 });
const send = (type, touches, changed = touches, time = 0) => {
const event = new TouchEvent(type, { bubbles: true, cancelable: true, touches, changedTouches: changed });
Object.defineProperty(event, "timeStamp", { value: 1000 + time }); el.dispatchEvent(event);
};
send("touchstart", [touch(120)]);
if (scenario === "multi") send("touchstart", [touch(120), touch(160, 2)]);
if (scenario === "cancel") send("touchcancel", [], [touch(120)]);
send("touchmove", [touch(280)], undefined, scenario === "hold" ? 500 : 100);
send("touchend", [], [touch(280)], 600);
}, scenario);
assert.ok(await page.locator(".workspace-sidebar").evaluate(el => el.getBoundingClientRect().right <= 0), scenario);
}
await page.evaluate(() => window.getSelection().removeAllRanges());
const composer = page.locator("main textarea");
assert.equal(await contextMenuCanceled(composer), false);
await page.evaluate(() => navigator.clipboard.writeText("native pasted draft"));
await composer.focus(); await page.keyboard.press("Control+v");
assert.equal(await composer.inputValue(), "native pasted draft");
assert.equal(calls.filter(call => call.method === "POST").length, 0);
// Explicit message actions preserve Fork without replacing the native menu.
await page.getByRole("button", { name: "Message actions", exact: true }).click();
await page.getByRole("button", { name: "Fork", exact: true }).waitFor();
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Open chat settings" }).click();
await page.getByRole("dialog").waitFor();
await page.getByPlaceholder("Select or type model", { exact: true }).fill("mobile-model-two");
await page.getByPlaceholder("Select or type model", { exact: true }).press("Enter");
await page.getByRole("button", { name: "Save", exact: true }).first().click();
await page.getByRole("dialog").waitFor({ state: "detached" });
assert.match(await page.locator("main header p").textContent(), /mobile-model-two/);
for (const width of [320, 390, 430, 767]) {
await page.setViewportSize({ width, height: 844 });
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, `${width}px page overflow`);
assert.ok(await page.locator("main header p").isVisible());
if (artifactDir) await page.screenshot({ path: `${artifactDir}/regular-${width}.png` });
}
await page.setViewportSize({ width: 390, height: 844 });
// Swipes still work in workspace chrome, away from text and scrollable content.
await drag(90, 8, 150);
await page.waitForFunction(() => document.querySelector(".workspace-sidebar").getBoundingClientRect().left >= 0);
await page.locator('[data-testid="agent-sidebar-item"]').click();
await page.locator(".agent-room .md-content").waitFor();
await page.waitForFunction(() => document.querySelector(".workspace-sidebar").getBoundingClientRect().right <= 1);
console.log("Agent width", await page.locator(".agent-room .md-content").evaluate(el => el.getBoundingClientRect().width));
if (artifactDir) await page.screenshot({ path: `${artifactDir}/agent-390.png` });
await scrollHorizontally(page.locator(".agent-room .md-table-scroll"));
assert.ok(await page.locator(".agent-room .md-table-scroll").evaluate(el => el.scrollLeft > 50));
assert.equal(await contextMenuCanceled(page.locator(".agent-room .md-content p").first()), false);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
// Keyboard-height approximation: both composers remain inside a shorter viewport.
await page.setViewportSize({ width: 390, height: 450 });
assert.ok(await page.locator(".agent-room textarea").evaluate(el => el.getBoundingClientRect().bottom <= innerHeight));
await selectChat();
assert.ok(await composer.evaluate(el => el.getBoundingClientRect().bottom <= innerHeight));
await page.setViewportSize({ width: 1280, height: 900 });
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
assert.ok(await page.locator("main header button").filter({ hasText: "mobile-model-one" }).isVisible());
assert.equal(await contextMenuCanceled(paragraph), false);
const manifest = await page.evaluate(async () => (await fetch("/manifest.webmanifest")).json());
assert.equal(manifest.display, "standalone"); assert.ok(!manifest.display_override?.includes("fullscreen"));
const viewport = await page.locator('meta[name="viewport"]').getAttribute("content");
assert.match(viewport, /user-scalable=no/);
assert.match(viewport, /maximum-scale=1\.0(?:,|$)/);
await page.setViewportSize({ width: 390, height: 844 });
await drag(300, 8, -160);
await page.getByRole("heading", { name: "New chat", exact: true }).waitFor();
assert.deepEqual(errors, []);
console.log("PASS mobile horizontal scrolling/edges, native menu/copy/paste, selection/multitouch/cancellation, compact widths/models, Agent layout, shorter viewport, desktop and PWA settings");
}
} finally { await context.close(); await browser.close(); }