From 94c0b0db70db3d058db4e586ff094a4dce5091b0 Mon Sep 17 00:00:00 2001 From: Hako Date: Sun, 11 Oct 2026 10:13:39 +0900 Subject: [PATCH 1/3] fix(push): hold web push while a window of the app is in use A page reports whether it is visible and focused over its WebSocket (presence). While any connection of the server says so, no device gets a push alert; it is checked when the alert would go out, and the test push is exempt. Fixes #751. --- CHANGELOG.md | 5 +++ docs/guide.md | 4 ++ server/api.contract.test.ts | 74 +++++++++++++++++++++++++++++++++++++ server/index.ts | 22 +++++++++++ server/push.test.ts | 30 +++++++++++++++ server/push.ts | 10 ++++- shared/protocol.ts | 5 +++ site/demo/transport.ts | 3 ++ src/lib/machineSession.ts | 4 ++ src/lib/presence.ts | 25 +++++++++++++ src/lib/ws.test.ts | 19 ++++++++++ src/lib/ws.ts | 11 ++++++ 12 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 src/lib/presence.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d395e4245d..e03bbc25fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ## [Unreleased] +### Fixed +- Push alerts no longer reach every device while you are using the app: while any window of it + is visible and focused, on any device, no device gets an alert. Alerts resume once no window + is in use, and **Send test** still always sends. + ## [0.4.6] - 2026-10-11 ### Added diff --git a/docs/guide.md b/docs/guide.md index a84ac8f19a..1e8ca2e79c 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -271,6 +271,10 @@ Only devices in your tailnet can open that address. Your own devices get in with To check alerts later, choose **Settings → Alerts → Send test**. The result tells you whether the test was sent or failed; a missing subscription offers **Turn alerts on again**. +While the app is in front on any device, its window visible and focused, no alert goes to any +device: you are already looking at the change. Alerts resume once no window of the app is in use. +The test push is always sent. + On a phone: - Agent panes open in the chat. - The terminal gets a key bar above the keyboard (Esc, Tab, Ctrl+C, Ctrl, Alt, Shift, Enter, arrows). diff --git a/server/api.contract.test.ts b/server/api.contract.test.ts index 7b4eb8eca6..fb111c6727 100644 --- a/server/api.contract.test.ts +++ b/server/api.contract.test.ts @@ -1989,6 +1989,80 @@ describe("web push", () => { expect(pushed!.vapidValid).toBe(true); }, 40_000); + it("sends no device an alert while a connected page says it is in use, and alerts again once it is not (#751)", async () => { + let created: Awaited> | undefined; + let dir: string | undefined; + let device: FakePushService | undefined; + let bridge: ReturnType | undefined; + let page: RecordingSocket | undefined; + try { + created = await workspaceCreate({ cwd: tmpdir(), label: "herdr-web-ui-test-push-presence" }); + const paneId = created.root_pane.pane_id; + dir = mkdtempSync(join(tmpdir(), "herdr-web-ui-push-presence-")); + device = await startFakePushService(); + bridge = createServer({ port: 0, hostname: "127.0.0.1", token: "", stateDir: dir, machines: false, pushLoopbackHttp: true, + alertTiming: { short: 0, long: 0, longTurn: 0 } }); + const subscribe = await fetch(`http://127.0.0.1:${bridge.port}/api/push/subscribe`, { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ subscription: device.subscription }), + }); + expect(subscribe.status).toBe(204); + page = await RecordingSocket.connect(`ws://127.0.0.1:${bridge.port}/ws`); + // a connection's frames are handled in order: the role-ack says the presence before it was read + const tell = async (active: boolean): Promise => { + page!.seen.length = 0; + page!.send({ type: "presence", active }); + page!.send({ type: "role", mode: "interact" }); + await page!.waitFor((message) => message.type === "role-ack", "role-ack after presence", 5_000); + }; + const report = async (state: "working" | "blocked"): Promise => { + await herdrRpc("pane.report_agent", { pane_id: paneId, source: "manual", agent: "claude", state }); + return page!.waitFor((message) => message.type === "pane-status" && message.pane_id === paneId && message.agent_status === state, + `${state} on the page`, 2_500).then(() => true).catch(() => false); + }; + const alerts = () => device!.received.filter((push) => push.payload.pane_id === paneId); + + await tell(true); + // the collector may still be subscribing to the new pane: alternate until a blocked lands + let blocked = false; + for (let attempt = 0; attempt < 8 && !blocked; attempt++) { + await report("working"); + blocked = await report("blocked"); + } + expect(blocked).toBe(true); + await bridge.alertsSettled(); + expect(alerts()).toHaveLength(0); + + await tell(false); + expect(await report("working")).toBe(true); + expect(await report("blocked")).toBe(true); + const pushed = await device.waitFor((push) => push.payload.pane_id === paneId, "alert once no page is in use", 5_000); + expect(pushed.payload.body).toBe("waiting for your input"); + + // a page that leaves without saying so no longer holds the alerts back + await tell(true); + page.close(); + page = undefined; + const watcher = await RecordingSocket.connect(`ws://127.0.0.1:${bridge.port}/ws`); + page = watcher; + // the server reads the close on its own time: alternate until an alert comes, which only + // happens once the closed page stopped holding them back + let alerted = false; + for (let attempt = 0; attempt < 8 && !alerted; attempt++) { + expect(await report("working")).toBe(true); + expect(await report("blocked")).toBe(true); + await bridge.alertsSettled(); + alerted = alerts().length === 2; + } + expect(alerted).toBe(true); + } finally { + page?.close(); + bridge?.stop(); + device?.stop(); + if (created) await workspaceClose(created.workspace.workspace_id).catch(() => undefined); + if (dir) rmSync(dir, { recursive: true, force: true }); + } + }, 60_000); + it("reports a Codex unknown after work as done over WS and HTTP and delivers its completion push", async () => { // Exercise the real collector -> CompletionTracker -> PushService path using // manually reported statuses in owned panes. No Codex request or real device. diff --git a/server/index.ts b/server/index.ts index ff48ebf960..3707acbf85 100644 --- a/server/index.ts +++ b/server/index.ts @@ -309,6 +309,18 @@ interface SocketData { roles: number; } +/** A `presence` frame's answer, or null for any other frame. A relayed frame is only parsed when it can be one. */ +function presenceOf(raw: string | Buffer): boolean | null { + const text = String(raw); + if (!text.includes("\"presence\"")) return null; + try { + const message = JSON.parse(text) as { type?: unknown; active?: unknown }; + return message?.type === "presence" ? message.active === true : null; + } catch { + return null; + } +} + type Client = ServerWebSocket; /** @@ -448,6 +460,8 @@ export function createServer( // herdr releases its exclusive attach slot only after the old process exits. const retiringAttachments = new Map>(); const clients = new Set(); + /** connections whose page is in use (`presence`), relayed ones included: while any is, no web push goes out */ + const present = new Set(); type PendingLease = { attachment: PaneAttachment; pty: PtySession | MirrorSession; authority: object }; const pendingAuthorities = new WeakMap>(); type PendingItem = PendingRecord; @@ -809,6 +823,7 @@ export function createServer( now: options.statusNow, loopbackHttp: options.pushLoopbackHttp === true, canDeliver: (id) => id === null || (id === undefined ? !devices.gated : devices.has(id)), + inUse: () => present.size > 0, lookupTitle: async (paneId) => { return paneTitle(await paneGet(paneId)); }, @@ -2379,6 +2394,7 @@ export function createServer( client.data.closing = true; killWatches(client); clients.delete(client); + present.delete(client); for (const paneId of client.data.attached.keys()) detach(paneId, client); client.data.attached.clear(); client.data.output.clear(); @@ -2399,7 +2415,12 @@ export function createServer( async message(client, raw) { if (client.data.closing) return; + // read here for a relayed connection too: alerts for every PC go out from this server + const presence = presenceOf(raw); + if (presence === true) present.add(client); + else if (presence === false) present.delete(client); if (client.data.relay) { client.data.relay.message(raw); return; } + if (presence !== null) return; let message: ClientMessage; try { message = JSON.parse(String(raw)) as ClientMessage; @@ -2911,6 +2932,7 @@ export function createServer( close(client) { client.data.unwatchDevice?.(); + present.delete(client); if (client.data.relay) { client.data.relay.close(); return; } killWatches(client); pending.close(client); diff --git a/server/push.test.ts b/server/push.test.ts index 8b8a939c67..98958fe233 100644 --- a/server/push.test.ts +++ b/server/push.test.ts @@ -468,6 +468,36 @@ it("never follows a redirect: an https endpoint cannot send the alert on to a lo } finally { redirector.stop(true); } }); +describe("a window of the app in use (#751)", () => { + it("sends no device an alert while some window is in use, and sends again once none is", async () => { + let inUse = true; + const push = subscribed({ inUse: () => inUse }); + push.seed([pane("w1:p1", "working", "claude: fix the build")]); + await push.onStatus("w1:p1", "blocked"); + await push.onEnded("w1:p1"); + expect(fake.received).toHaveLength(0); + // the confirmation a device asked for is not an alert: it still goes out + expect(await push.sendTest(fake.subscription.endpoint)).toEqual({ ok: true }); + expect(fake.received.map((r) => r.payload.tag)).toEqual(["herdr-test"]); + inUse = false; + await push.onStatus("w1:p1", "working"); + await push.onStatus("w1:p1", "blocked"); + expect(fake.received.map((r) => r.payload.body)).toEqual(["Alerts are on for this device", "waiting for your input"]); + }); + + it("asks when the alert would go out, not when the status changed", async () => { + let inUse = false; + const push = createPushService({ loopbackHttp: true, stateDir, timing: { short: 0, long: 0, longTurn: 0 }, inUse: () => inUse }); + push.subscribe(fake.subscription); + push.seed([pane("w1:p1", "working", "claude")]); + await push.onStatus("w1:p1", "blocked"); + // the window came forward while the alert waited for its timer + inUse = true; + await push.settled(); + expect(fake.received).toHaveLength(0); + }); +}); + describe("push resync after lost status events", () => { it("calls off the waiting alert of a pane that closed while events were lost", async () => { const push = createPushService({ loopbackHttp: true, stateDir, timing: { short: 50, long: 50, longTurn: 0 } }); diff --git a/server/push.ts b/server/push.ts index 62838f4595..47e10d39fc 100644 --- a/server/push.ts +++ b/server/push.ts @@ -20,6 +20,11 @@ * prompt and ~60 s after a reply before it notifies). A question waits `short`; a finish * waits `long`, and only one after a turn that worked `longTurn` or more is worth it, * unless the device asked for every finish (which then waits `short`). + * + * No device gets an alert while a window of the app is in use somewhere (`inUse`: a + * connected page that is visible and has focus, which says so over its WebSocket): the + * person is at a screen that already shows the change (#751). It is asked when an alert + * would go out, so a window brought forward while the alert waits still holds it back. */ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; @@ -103,6 +108,8 @@ export interface PushServiceOptions { timing?: Partial; now?: () => number; canDeliver?: (deviceId: string | null | undefined) => boolean; + /** whether a window of the app is in use right now: then no alert goes to any device (the test push still does) */ + inUse?: () => boolean; /** accept plain-http loopback endpoints: only for the tests' fake push service (push.fake.ts) */ loopbackHttp?: boolean; } @@ -407,6 +414,7 @@ export function createPushService(options: PushServiceOptions): PushService { } if (groups.size === 0) return; schedule(key, groups, async (to) => { + if (options.inUse?.()) return; const title = machineId === "local" ? await titleOf(paneId) : titles.get(key) ?? paneId; // a device that dropped out meanwhile is not written to const live = to.flatMap((subscription) => { @@ -423,7 +431,7 @@ export function createPushService(options: PushServiceOptions): PushService { turnStart.delete(key); // an ended terminal is a finish of its own: a device that wants none hears none const to = [...store().values()].filter((subscription) => (subscription.alerts ?? DEFAULT_ALERTS).done !== "off"); - if (to.length === 0) return; + if (to.length === 0 || options.inUse?.()) return; // the pane may already be gone from herdr: the seeded title is what is left await broadcast({ ...endedMessage(paneId, titles.get(key) ?? paneId), ...(machineId === "local" ? {} : { machine_id: machineId }), tag: paneNotificationTag(paneId, machineId) }, "normal", to); }, diff --git a/shared/protocol.ts b/shared/protocol.ts index 3f7d4c51f7..4c63a24720 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -958,6 +958,11 @@ export type ClientMessage = /** Cumulative UTF-8 payload bytes processed by xterm, only for this subscription. */ | { type: "pty-ack"; pane_id: string; stream_id: string; offset: number } | { type: "role"; mode: ClientRole } + /** whether this page is in use: visible and focused. While any connection of a server says + * so, that server sends no web push to any device (#751). Sent on connect and on every change, + * also through a remote PC's socket (the connection server reads it there too); an older + * server ignores it, so it needs no feature. */ + | { type: "presence"; active: boolean } /** a read-only view of the pane at this grid, for a tab out of use (feature "watch"); never resizes the pane */ | { type: "watch"; pane_id: string; cols: number; rows: number } | { type: "unwatch"; pane_id: string }; diff --git a/site/demo/transport.ts b/site/demo/transport.ts index 7cf28d8321..b10579d82f 100644 --- a/site/demo/transport.ts +++ b/site/demo/transport.ts @@ -1209,6 +1209,9 @@ class DemoSocket extends EventTarget { if (this.mode === "observe") this.holdPending(undefined, "read_only", "This connection now only watches. Pending messages will not be sent automatically."); this.push({ type: "role-ack", mode: this.mode }); break; + case "presence": + // the demo sends no alerts, so whether the page is in use holds nothing back + break; case "attach": if (message.pane_id) { this.resizeGrid(message.pane_id, message.cols, message.rows); this.attach(message.pane_id); } break; diff --git a/src/lib/machineSession.ts b/src/lib/machineSession.ts index b52bbfb2c3..e7e169d154 100644 --- a/src/lib/machineSession.ts +++ b/src/lib/machineSession.ts @@ -2,6 +2,7 @@ import type { PendingMessage, ServerMessage } from "../../shared/protocol.ts"; import { paneStorageId } from "../../shared/machines.ts"; import { afterSend, afterSettled, greetingMemory, rememberGreeting } from "./greeting.ts"; import { pendingMessages, type PendingMessageStore } from "./pendingMessages.ts"; +import { watchPageInUse } from "./presence.ts"; import { HerdrSocket } from "./ws.ts"; type Handler = (message: ServerMessage, sent: boolean) => void; @@ -24,6 +25,7 @@ export class MachineSession { private readonly handlers = new Set(); private readonly offMessage: () => void; private readonly offDisconnect: () => void; + private readonly offPresence: () => void; private closed = false; constructor(readonly machineId: string, options: MachineSessionOptions = {}) { @@ -48,6 +50,7 @@ export class MachineSession { for (const handler of this.handlers) handler(message, sent); }); this.offDisconnect = this.socket.onDisconnect(() => this.disconnected()); + this.offPresence = watchPageInUse((active) => this.socket.setPresence(active)); } get scope(): string | null { return this.currentScope; } @@ -88,6 +91,7 @@ export class MachineSession { this.disconnected(); this.offMessage(); this.offDisconnect(); + this.offPresence(); this.handlers.clear(); this.socket.close(); } diff --git a/src/lib/presence.ts b/src/lib/presence.ts new file mode 100644 index 0000000000..b65fd175e5 --- /dev/null +++ b/src/lib/presence.ts @@ -0,0 +1,25 @@ +/** + * Whether this page is in use: on screen and holding the focus, the same test PaneTerminal's + * `inUse` makes. Every PC connection tells its server (WS `presence`), and while any page is in + * use the server sends no web push to any device (#751). + */ +export function pageInUse(): boolean { + return typeof document !== "undefined" && document.visibilityState === "visible" && document.hasFocus(); +} + +/** Calls `listener` with `pageInUse()` now and again whenever focus or visibility changes. */ +export function watchPageInUse(listener: (active: boolean) => void): () => void { + if (typeof window === "undefined" || typeof document === "undefined") return () => {}; + const update = (): void => listener(pageInUse()); + update(); + window.addEventListener("focus", update); + window.addEventListener("blur", update); + window.addEventListener("pagehide", update); + document.addEventListener("visibilitychange", update); + return () => { + window.removeEventListener("focus", update); + window.removeEventListener("blur", update); + window.removeEventListener("pagehide", update); + document.removeEventListener("visibilitychange", update); + }; +} diff --git a/src/lib/ws.test.ts b/src/lib/ws.test.ts index cafecc4556..25a202e9f6 100644 --- a/src/lib/ws.test.ts +++ b/src/lib/ws.test.ts @@ -467,3 +467,22 @@ it("waits for capabilities when output precedes snapshot, and supports old bridg client.close(); } }); + +it("tells the server whether the page is in use: on connect, on a change, and again after a reconnect", () => { + const presence = (socket: FakeSocket) => socket.sent.filter((frame) => frame.type === "presence"); + const client = new HerdrSocket("ws://test/ws"); + client.setPresence(true); + client.connect(); + const first = FakeSocket.last; + first.open(); + expect(first.sent.map((frame) => frame.type)).toEqual(["role", "presence"]); + client.setPresence(true); + client.setPresence(false); + expect(presence(first)).toEqual([{ type: "presence", active: true }, { type: "presence", active: false }]); + first.disconnect(); + client.connect(); + const second = FakeSocket.last; + second.open(); + expect(presence(second)).toEqual([{ type: "presence", active: false }]); + client.close(); +}); diff --git a/src/lib/ws.ts b/src/lib/ws.ts index abda6e5b18..b73ecec978 100644 --- a/src/lib/ws.ts +++ b/src/lib/ws.ts @@ -57,6 +57,8 @@ export class HerdrSocket { private reconnectTimer: number | null = null; private disposed = false; private mode: ClientRole = "interact"; + /** whether the page is in use (`presence`), as last told; null until the page says */ + private presence: boolean | null = null; private outputStopped = false; /** what the connected server listed in its snapshot: empty until it arrives, and on older bridges */ private features = new Set(); @@ -97,6 +99,8 @@ export class HerdrSocket { // the reconnect would leave the server on the stale role and no ack would ever // arrive - the UI would stay stuck in the old role while the header pill lies this.rawSend({ type: "role", mode: this.mode }); + // a new connection is a new client to the server: it has to hear again whether the page is in use + if (this.presence !== null) this.rawSend({ type: "presence", active: this.presence }); for (const [paneId, state] of this.attached) { this.rawSend({ type: "attach", pane_id: paneId, cols: state.cols, rows: state.rows, flow_control: "ack", ...(state.keepSize ? { keep_size: true } : {}) }); } @@ -291,6 +295,13 @@ export class HerdrSocket { if (state) state.keepSize = true; } + /** Whether the page is in use (visible and focused): the server holds web push while one is. Replays on reconnect. */ + setPresence(active: boolean): void { + if (this.presence === active) return; + this.presence = active; + if (this.connected) this.rawSend({ type: "presence", active }); + } + /** Sets the connection's role. Not queued: the role replays before the attaches on reconnect. */ setMode(mode: ClientRole): void { this.mode = mode; From 25b3c5260002f665cdcefc6f9f9cc51488843695 Mon Sep 17 00:00:00 2001 From: Hako Date: Sun, 11 Oct 2026 10:14:03 +0900 Subject: [PATCH 2/3] docs(changelog): link #759 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e03bbc25fc..6e7bfb73e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa - Push alerts no longer reach every device while you are using the app: while any window of it is visible and focused, on any device, no device gets an alert. Alerts resume once no window is in use, and **Send test** still always sends. + ([#759](https://github.com/devswha/herdr-web-ui/pull/759)) ## [0.4.6] - 2026-10-11 From 37ea47f4c8adec4801e3e293190d428d4e6f886a Mon Sep 17 00:00:00 2001 From: Hako Date: Sun, 11 Oct 2026 10:22:29 +0900 Subject: [PATCH 3/3] fix(push): recheck presence after the title lookup; contract waits only for new frames --- server/api.contract.test.ts | 2 ++ server/push.test.ts | 8 ++++++++ server/push.ts | 2 ++ 3 files changed, 12 insertions(+) diff --git a/server/api.contract.test.ts b/server/api.contract.test.ts index fb111c6727..f6eb05129e 100644 --- a/server/api.contract.test.ts +++ b/server/api.contract.test.ts @@ -2015,6 +2015,8 @@ describe("web push", () => { await page!.waitFor((message) => message.type === "role-ack", "role-ack after presence", 5_000); }; const report = async (state: "working" | "blocked"): Promise => { + // only a frame this report caused may answer it + page!.seen.length = 0; await herdrRpc("pane.report_agent", { pane_id: paneId, source: "manual", agent: "claude", state }); return page!.waitFor((message) => message.type === "pane-status" && message.pane_id === paneId && message.agent_status === state, `${state} on the page`, 2_500).then(() => true).catch(() => false); diff --git a/server/push.test.ts b/server/push.test.ts index 98958fe233..c3c7dc72c8 100644 --- a/server/push.test.ts +++ b/server/push.test.ts @@ -496,6 +496,14 @@ describe("a window of the app in use (#751)", () => { await push.settled(); expect(fake.received).toHaveLength(0); }); + + it("holds an alert when a window comes into use while its title is looked up", async () => { + let inUse = false; + const push = subscribed({ inUse: () => inUse, lookupTitle: async () => { inUse = true; return "claude"; } }); + push.seed([pane("w1:p1", "working", "claude")]); + await push.onStatus("w1:p1", "blocked"); + expect(fake.received).toHaveLength(0); + }); }); describe("push resync after lost status events", () => { diff --git a/server/push.ts b/server/push.ts index 47e10d39fc..ad9e9c0b24 100644 --- a/server/push.ts +++ b/server/push.ts @@ -416,6 +416,8 @@ export function createPushService(options: PushServiceOptions): PushService { schedule(key, groups, async (to) => { if (options.inUse?.()) return; const title = machineId === "local" ? await titleOf(paneId) : titles.get(key) ?? paneId; + // the title can wait for herdr: a window that came into use meanwhile still holds the alert + if (options.inUse?.()) return; // a device that dropped out meanwhile is not written to const live = to.flatMap((subscription) => { const current = store().get(subscription.endpoint);