Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
is still recognised, so a pending message waits instead of pressing Enter on it, and the same
words quoted in Claude's output no longer hold pending messages.
([#764](https://github.com/devswha/herdr-web-ui/pull/764))
- 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

Expand Down
4 changes: 4 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
76 changes: 76 additions & 0 deletions server/api.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1989,6 +1989,82 @@ 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<ReturnType<typeof workspaceCreate>> | undefined;
let dir: string | undefined;
let device: FakePushService | undefined;
let bridge: ReturnType<typeof createServer> | 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<void> => {
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<boolean> => {
// 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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
};
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.
Expand Down
22 changes: 22 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SocketData>;

/**
Expand Down Expand Up @@ -448,6 +460,8 @@ export function createServer(
// herdr releases its exclusive attach slot only after the old process exits.
const retiringAttachments = new Map<string, Promise<void>>();
const clients = new Set<Client>();
/** connections whose page is in use (`presence`), relayed ones included: while any is, no web push goes out */
const present = new Set<Client>();
type PendingLease = { attachment: PaneAttachment; pty: PtySession | MirrorSession; authority: object };
const pendingAuthorities = new WeakMap<Client, Map<string, object>>();
type PendingItem = PendingRecord<Client, PendingLease>;
Expand Down Expand Up @@ -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));
},
Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
38 changes: 38 additions & 0 deletions server/push.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,44 @@ 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);
});

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", () => {
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 } });
Expand Down
12 changes: 11 additions & 1 deletion server/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -103,6 +108,8 @@ export interface PushServiceOptions {
timing?: Partial<AlertTiming>;
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;
}
Expand Down Expand Up @@ -407,7 +414,10 @@ export function createPushService(options: PushServiceOptions): PushService {
}
if (groups.size === 0) return;
schedule(key, groups, async (to) => {
if (options.inUse?.()) return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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);
Expand All @@ -423,7 +433,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);
},
Expand Down
5 changes: 5 additions & 0 deletions shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
3 changes: 3 additions & 0 deletions site/demo/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/lib/machineSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,6 +25,7 @@ export class MachineSession {
private readonly handlers = new Set<Handler>();
private readonly offMessage: () => void;
private readonly offDisconnect: () => void;
private readonly offPresence: () => void;
private closed = false;

constructor(readonly machineId: string, options: MachineSessionOptions = {}) {
Expand All @@ -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; }
Expand Down Expand Up @@ -88,6 +91,7 @@ export class MachineSession {
this.disconnected();
this.offMessage();
this.offDisconnect();
this.offPresence();
this.handlers.clear();
this.socket.close();
}
Expand Down
25 changes: 25 additions & 0 deletions src/lib/presence.ts
Original file line number Diff line number Diff line change
@@ -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);
};
}
19 changes: 19 additions & 0 deletions src/lib/ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
11 changes: 11 additions & 0 deletions src/lib/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServerFeature>();
Expand Down Expand Up @@ -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 } : {}) });
}
Expand Down Expand Up @@ -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;
Expand Down
Loading