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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
description: Existing release tag to publish or retry
required: true
type: choice
options: [terminal-v0.4.3, terminal-v0.4.2, ext-v0.4.1, terminal-v0.4.1, dsh-v0.4.0, terminal-v0.4.0, ext-v0.4.0, dsh-v0.3.0, terminal-v0.3.0, ext-v0.3.0, dsh-v0.2.0, terminal-v0.2.0, terminal-v0.2.1, ext-v0.2.0, ext-v0.2.1]
options: [ext-v0.4.2, terminal-v0.4.3, terminal-v0.4.2, ext-v0.4.1, terminal-v0.4.1, dsh-v0.4.0, terminal-v0.4.0, ext-v0.4.0, dsh-v0.3.0, terminal-v0.3.0, ext-v0.3.0, dsh-v0.2.0, terminal-v0.2.0, terminal-v0.2.1, ext-v0.2.0, ext-v0.2.1]
push:
tags: ["ext-v*", "dsh-v*", "terminal-v*"]

Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Extension 0.4.2 — 2026-10-29

- Drag the floating AgentOnWeb icon with a mouse, trackpad or touch to uncover page controls. Remember its position across pages and reloads, and adapt it when the window changes size.
- Distinguish dragging from clicking, preserve keyboard activation and restore the original position when a drag is cancelled. Keep the launcher, controls and notifications within the viewport.
- Publish Chrome and Firefox extension packages. Safari source metadata is prepared as 0.4.2, build 11; Safari store publication is separate. Terminal host remains 0.4.3, DSH remains 0.4.0 and connector protocol remains 1.

## Terminal host 0.4.3 — 2026-10-22

- Recognize an older running terminal host after the CLI package has been upgraded. Report its missing maintenance interface and allow explicit `aow update --force` or `aow service restart --force` only after confirming the host PID belongs to the macOS service. Preserve the running service if npm installation fails.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ AgentOnWeb brings your coding workspace onto the page you already have open. Wat

Switching modes keeps your session running. In Chill, double-tap **Option / Alt** to interact with the website; double-tap again to return to your workspace.

Drag the floating AgentOnWeb icon to keep page controls accessible. Its position is remembered across pages and reloads, and the controls stay within the window when opened.

## Watch the demo

[![Watch the AgentOnWeb demo on YouTube](docs/assets/agentonweb-demo-cover.png)](https://www.youtube.com/watch?v=s083RpD38HU)
Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ AgentOnWeb 将你的编程工作区带到你当前打开的页面上。一边看

切换模式不会中断会话。在 Chill 模式下,双击 **Option / Alt** 可与网页交互;再次双击即可返回工作区。

拖动 AgentOnWeb 悬浮图标即可避开网页上的按钮和内容。位置会在跨页面和刷新后保留,展开的控制面板也会自动保持在窗口内。

## 观看演示

[![在 YouTube 上观看 AgentOnWeb 演示](docs/assets/agentonweb-demo-cover.png)](https://www.youtube.com/watch?v=s083RpD38HU)
Expand Down
2 changes: 1 addition & 1 deletion apps/extension/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@agentonweb/extension",
"version": "0.4.1",
"version": "0.4.2",
"private": true,
"license": "MIT",
"type": "module",
Expand Down
23 changes: 23 additions & 0 deletions apps/extension/src/background.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,29 @@ beforeEach(() => {
afterEach(() => { vi.clearAllTimers(); vi.useRealTimers(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); });

describe("cross-browser native connection lifecycle", () => {
it("persists the launcher position, broadcasts it and restores it after a worker restart", async () => {
await boot();
const dockPosition = { x: 0.25, y: 0.75 };
await message("dock.position.set", undefined, { position: { ...dockPosition, unexpected: "discard" } });
expect(data[STATE_STORAGE_KEY]).toMatchObject({ dockPosition });
expect((data[STATE_STORAGE_KEY] as Record<string, unknown>).dockPosition).toEqual(dockPosition);
expect(chromeMock.tabs.sendMessage).toHaveBeenCalledWith(tab.id, expect.objectContaining({
type: "state.update", state: expect.objectContaining({ dockPosition }),
}));
vi.resetModules(); chromeMock = createChrome(); vi.stubGlobal("chrome", chromeMock);
await boot();
expect((await message("state.get")).result.dockPosition).toEqual(dockPosition);
});

it.each([null, {}, { x: -0.1, y: 0 }, { x: 0, y: 1.1 }, { x: NaN, y: 0 }, { x: 0, y: Infinity }, { x: "0", y: 0 }])(
"ignores invalid stored and requested launcher positions %j", async dockPosition => {
data = { [STATE_STORAGE_KEY]: { dockPosition } };
await boot();
expect((await message("state.get")).result.dockPosition).toBeUndefined();
expect(await message("dock.position.set", undefined, { position: dockPosition })).toBeUndefined();
},
);

it("restores a delegated cookie removed while its connector remains connected", async () => {
await boot();
await message("runtime.connect", undefined, { runtimeId: runtime.id });
Expand Down
9 changes: 8 additions & 1 deletion apps/extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { HeaderLeaseManager } from "./header-leases.js";
import { DEFAULT_SURFACE_OPACITY, normalizeSurfaceOpacity } from "./interaction.js";
import { PartitionLeaseManager } from "./partition-leases.js";
import { SafariCookieLeaseManager } from "./safari-cookie-leases.js";
import { COOKIE_SCOPES_STORAGE_KEY, CREDENTIAL_STORAGE_KEY, STATE_STORAGE_KEY, isContentRequest, type ContentRequest, type StateUpdate, type SurfaceCommand, type SurfaceViewState } from "./shared.js";
import { COOKIE_SCOPES_STORAGE_KEY, CREDENTIAL_STORAGE_KEY, STATE_STORAGE_KEY, isContentRequest, isDockPosition, type ContentRequest, type StateUpdate, type SurfaceCommand, type SurfaceViewState } from "./shared.js";
import { nativeSetup, probeNativeSetup } from "./native-setup.js";
import { topLevelSite } from "./surface-cookie.js";
import { commandMode } from "./mode-shortcuts.js";
Expand Down Expand Up @@ -197,6 +197,7 @@ async function initialize(): Promise<void> {
mode: storedState?.mode === "watch" || storedState?.mode === "focus" ? storedState.mode : "chill",
dismissed: storedState?.dismissed === true,
opacity: normalizeSurfaceOpacity(storedState?.opacity),
...(isDockPosition(storedState?.dockPosition) ? { dockPosition: storedState.dockPosition } : {}),
...(Array.isArray(storedState?.readAttentionIds) ? {
readAttentionIds: [...new Set(storedState.readAttentionIds.filter((id): id is string =>
typeof id === "string" && id.length > 0 && id.length <= 200))].slice(-256),
Expand Down Expand Up @@ -225,6 +226,10 @@ async function handleRequest(request: ContentRequest, tab?: Browser.tabs.Tab): P
await applyModeShortcut(request.mode, tab, "page");
return { ok: true };
case "opacity.set": patch({ opacity: normalizeSurfaceOpacity(request.opacity) }); return viewForTab(tab);
case "dock.position.set":
patch({ dockPosition: { x: request.position.x, y: request.position.y } });
await publication;
return { ok: true };
case "runtime.activate":
case "runtime.connect":
if (tab?.id !== undefined && request.runtimeId) tabRuntimes.set(tab.id, request.runtimeId);
Expand Down Expand Up @@ -361,6 +366,7 @@ async function broadcast(stateSnapshot: SurfaceViewState, surfaceSnapshot?: Nati
// Persist presentation preferences, never prompts, approval details or live sessions.
const preferences = {
mode: stateSnapshot.mode, opacity: stateSnapshot.opacity,
...(stateSnapshot.dockPosition ? { dockPosition: stateSnapshot.dockPosition } : {}),
...(stateSnapshot.dismissed !== undefined ? { dismissed: stateSnapshot.dismissed } : {}),
...(stateSnapshot.runtimeId ? { runtimeId: stateSnapshot.runtimeId } : {}),
...(stateSnapshot.readAttentionIds ? { readAttentionIds: stateSnapshot.readAttentionIds } : {}),
Expand Down Expand Up @@ -397,6 +403,7 @@ async function viewForTab(
// A queued broadcast can still contain the removed runtime's connection
// fields. Keep presentation preferences and use the current fallback view.
stateSnapshot = { mode: stateSnapshot.mode, opacity: stateSnapshot.opacity,
...(stateSnapshot.dockPosition ? { dockPosition: stateSnapshot.dockPosition } : {}),
...(stateSnapshot.readAttentionIds ? { readAttentionIds: stateSnapshot.readAttentionIds } : {}),
...(stateSnapshot.dismissed === undefined ? {} : { dismissed: stateSnapshot.dismissed }), ...connectionSnapshot.view };
}
Expand Down
106 changes: 106 additions & 0 deletions apps/extension/src/content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,112 @@ async function page(
};
}

function pointer(ui: Awaited<ReturnType<typeof page>>, target: EventTarget, type: string, clientX: number, clientY: number, fields: Record<string, unknown> = {}) {
const event = new ui.window.MouseEvent(type, { clientX, clientY, button: 0, detail: 1, bubbles: true, composed: true, cancelable: true });
Object.defineProperties(event, Object.fromEntries(Object.entries({ pointerId: 1, isPrimary: true, ...fields })
.map(([name, value]) => [name, { value }])));
target.dispatchEvent(event);
return event;
}

describe("floating launcher position", () => {
it("keeps small pointer movements as clicks and preserves keyboard activation", async () => {
const ui = await page();
const toggle = ui.shadow.querySelector<HTMLButtonElement>(".surface-toggle")!;
pointer(ui, toggle, "pointerdown", 986, 730);
pointer(ui, toggle, "pointermove", 989, 733);
pointer(ui, toggle, "pointerup", 989, 733);
pointer(ui, toggle, "click", 989, 733);
expect(toggle.getAttribute("aria-expanded")).toBe("true");
expect(ui.sendMessage.mock.calls.some(([request]) => request.type === "dock.position.set")).toBe(false);
toggle.click();
expect(toggle.getAttribute("aria-expanded")).toBe("false");
});

it("moves across the page without opening controls and persists only on release", async () => {
const ui = await page();
const toggle = ui.shadow.querySelector<HTMLButtonElement>(".surface-toggle")!;
const dock = ui.shadow.querySelector<HTMLElement>(".surface-dock")!;
pointer(ui, toggle, "pointerdown", 986, 730);
pointer(ui, ui.window, "pointermove", 120, 160);
expect(dock.style.right).toBe("880px");
expect(dock.style.bottom).toBe("584px");
expect(ui.sendMessage.mock.calls.some(([request]) => request.type === "dock.position.set")).toBe(false);
ui.emit("state.update");
expect(dock.style.right).toBe("880px");
pointer(ui, ui.window, "pointerup", 120, 160);
const click = pointer(ui, toggle, "click", 120, 160);
expect(click.defaultPrevented).toBe(true);
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect(ui.sendMessage.mock.calls.filter(([request]) => request.type === "dock.position.set")).toEqual([
[{ source: "agentonweb-content", type: "dock.position.set", position: { x: 82 / 948, y: 122 / 692 } }],
]);
ui.emit("state.update");
expect(dock.style.right).toBe("880px");
toggle.click();
expect(toggle.getAttribute("aria-expanded")).toBe("true");
});

it("clamps dragging at the viewport edges and allows the next click", async () => {
const ui = await page();
const toggle = ui.shadow.querySelector<HTMLButtonElement>(".surface-toggle")!;
const dock = ui.shadow.querySelector<HTMLElement>(".surface-dock")!;
pointer(ui, toggle, "pointerdown", 986, 730);
pointer(ui, ui.window, "pointerup", -200, -200);
expect(dock.style.right).toBe("962px");
expect(dock.style.bottom).toBe("706px");
expect(ui.sendMessage).toHaveBeenCalledWith({ source: "agentonweb-content", type: "dock.position.set", position: { x: 0, y: 0 } });
pointer(ui, toggle, "click", 38, 38);
pointer(ui, toggle, "pointerdown", 38, 38);
pointer(ui, toggle, "pointerup", 38, 38);
pointer(ui, toggle, "click", 38, 38);
expect(toggle.getAttribute("aria-expanded")).toBe("true");
});

it.each(["pointercancel", "lostpointercapture", "blur"])("rolls back a cancelled drag on %s", async type => {
const ui = await page();
const toggle = ui.shadow.querySelector<HTMLButtonElement>(".surface-toggle")!;
const dock = ui.shadow.querySelector<HTMLElement>(".surface-dock")!;
pointer(ui, toggle, "pointerdown", 986, 730);
pointer(ui, ui.window, "pointermove", 120, 160);
pointer(ui, type === "lostpointercapture" ? toggle : ui.window, type, 120, 160);
expect(dock.style.right).toBe("14px");
expect(dock.style.bottom).toBe("14px");
expect(dock.dataset.dragging).toBeUndefined();
expect(ui.sendMessage.mock.calls.some(([request]) => request.type === "dock.position.set")).toBe(false);
});

it("restores the saved position, follows broadcasts and stays visible on resize", async () => {
const ui = await page({ ...idle, dockPosition: { x: 0, y: 0 } });
const dock = ui.shadow.querySelector<HTMLElement>(".surface-dock")!;
expect(dock.style.right).toBe("962px");
expect(dock.style.bottom).toBe("706px");
ui.emit("state.update", { ...idle, dockPosition: { x: 0.5, y: 0.5 } });
expect(dock.style.right).toBe("488px");
expect(dock.style.bottom).toBe("360px");
Object.assign(ui.window, { innerWidth: 390, innerHeight: 480 });
ui.window.dispatchEvent(new ui.window.Event("resize"));
expect(dock.style.right).toBe("171px");
expect(dock.style.bottom).toBe("216px");
});

it("ignores secondary buttons, non-primary pointers and unrelated pointer IDs", async () => {
const ui = await page();
const toggle = ui.shadow.querySelector<HTMLButtonElement>(".surface-toggle")!;
const dock = ui.shadow.querySelector<HTMLElement>(".surface-dock")!;
for (const fields of [{ button: 2 }, { isPrimary: false }]) {
pointer(ui, toggle, "pointerdown", 986, 730, fields);
pointer(ui, ui.window, "pointermove", 120, 160);
pointer(ui, ui.window, "pointerup", 120, 160);
}
pointer(ui, toggle, "pointerdown", 986, 730);
pointer(ui, ui.window, "pointermove", 120, 160, { pointerId: 2 });
pointer(ui, ui.window, "pointerup", 120, 160, { pointerId: 2 });
expect(dock.style.right).toBe("14px");
expect(ui.sendMessage.mock.calls.some(([request]) => request.type === "dock.position.set")).toBe(false);
});
});

describe("page-local AgentOnWeb visibility", () => {
it("applies an acknowledged mode click after a page update even without a background broadcast", async () => {
const p = await page(connected);
Expand Down
1 change: 1 addition & 0 deletions apps/extension/src/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ if (!document.getElementById(HOST_ID)) {
postOpacity(opacity);
send({ source: "agentonweb-content", type: "opacity.set", opacity });
};
dock.onPosition = position => send({ source: "agentonweb-content", type: "dock.position.set", position });
dock.onOpacityPreview = (opacity) => {
host.style.setProperty("--agentonweb-surface-opacity", String(opacity));
postOpacity(opacity);
Expand Down
147 changes: 147 additions & 0 deletions apps/extension/src/dock-position.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import type { DockPosition } from "./shared.js";

const SIZE = 48;
const MARGIN = 14;
const GAP = 10;
const DRAG_THRESHOLD = 5;

export function positionDock(
element: HTMLElement,
toggle: HTMLButtonElement,
panel: HTMLElement,
toast: HTMLElement,
onChange: (position: DockPosition) => void,
) {
let position: DockPosition = { x: 1, y: 1 };
let saved: DockPosition | undefined;
let suppressClick = false;
let gesture: {
pointerId: number;
clientX: number;
clientY: number;
left: number;
top: number;
original: DockPosition;
dragged: boolean;
} | undefined;

function viewport() {
const marginX = Math.min(MARGIN, Math.max(0, (window.innerWidth - SIZE) / 2));
const marginY = Math.min(MARGIN, Math.max(0, (window.innerHeight - SIZE) / 2));
return {
marginX,
marginY,
travelX: Math.max(0, window.innerWidth - marginX * 2 - SIZE),
travelY: Math.max(0, window.innerHeight - marginY * 2 - SIZE),
};
}

function layout() {
const { marginX, marginY, travelX, travelY } = viewport();
const left = marginX + position.x * travelX;
const top = marginY + position.y * travelY;
element.style.right = `${window.innerWidth - left - SIZE}px`;
element.style.bottom = `${window.innerHeight - top - SIZE}px`;
const panelWidth = Math.min(292, window.innerWidth - marginX * 2);
const panelHeight = Math.min(264, window.innerHeight - marginY * 2);
const panelLeft = clamp(left + SIZE - panelWidth, marginX, window.innerWidth - marginX - panelWidth);
const panelTop = floatingTop(panelHeight, top, marginY);
panel.style.right = `${left + SIZE - panelLeft - panelWidth}px`;
panel.style.bottom = `${top + SIZE - panelTop - panelHeight}px`;
const toastHeight = toast.offsetHeight;
let toastTop = floatingTop(toastHeight, top, marginY);
if (element.dataset.expanded === "true") {
toastTop = panelTop - GAP - toastHeight >= marginY
? panelTop - GAP - toastHeight
: clamp(panelTop + panelHeight + GAP, marginY, window.innerHeight - marginY - toastHeight);
}
toast.style.right = panel.style.right;
toast.style.bottom = `${top + SIZE - toastTop - toastHeight}px`;
}

function floatingTop(height: number, top: number, margin: number) {
const above = top - GAP - height;
const below = top + SIZE + GAP;
return above >= margin ? above
: below + height <= window.innerHeight - margin ? below
: clamp(above, margin, window.innerHeight - margin - height);
}

function move(event: PointerEvent) {
if (!gesture || event.pointerId !== gesture.pointerId) return;
const deltaX = event.clientX - gesture.clientX;
const deltaY = event.clientY - gesture.clientY;
if (!gesture.dragged && Math.hypot(deltaX, deltaY) < DRAG_THRESHOLD) return;
gesture.dragged = true;
element.dataset.dragging = "true";
event.preventDefault();
event.stopPropagation();
const { marginX, marginY, travelX, travelY } = viewport();
position = {
x: travelX ? clamp((gesture.left + deltaX - marginX) / travelX, 0, 1) : position.x,
y: travelY ? clamp((gesture.top + deltaY - marginY) / travelY, 0, 1) : position.y,
};
layout();
}

function finish(cancelled: boolean) {
if (!gesture) return;
const { pointerId, dragged, original } = gesture;
gesture = undefined;
delete element.dataset.dragging;
suppressClick = dragged;
if (cancelled) position = original;
if (toggle.hasPointerCapture?.(pointerId)) toggle.releasePointerCapture(pointerId);
layout();
if (dragged && !cancelled) onChange(position);
}

toggle.title = "Click to toggle controls; drag to move";
toggle.addEventListener("pointerdown", event => {
if (event.button !== 0 || event.isPrimary === false || gesture) return;
suppressClick = false;
const { marginX, marginY, travelX, travelY } = viewport();
gesture = {
pointerId: event.pointerId,
clientX: event.clientX,
clientY: event.clientY,
left: marginX + position.x * travelX,
top: marginY + position.y * travelY,
original: position,
dragged: false,
};
toggle.setPointerCapture?.(event.pointerId);
});
toggle.addEventListener("click", event => {
if (!suppressClick || event.detail === 0) return;
suppressClick = false;
event.preventDefault();
event.stopImmediatePropagation();
}, true);
window.addEventListener("pointermove", move, { capture: true, passive: false });
window.addEventListener("pointerup", event => {
if (event.pointerId !== gesture?.pointerId) return;
move(event);
finish(false);
}, true);
window.addEventListener("pointercancel", event => {
if (event.pointerId === gesture?.pointerId) finish(true);
}, true);
toggle.addEventListener("lostpointercapture", () => finish(true));
window.addEventListener("blur", () => finish(true));
window.addEventListener("resize", () => { finish(true); layout(); });
return {
layout,
render(next?: DockPosition) {
if (!gesture && (next?.x !== saved?.x || next?.y !== saved?.y)) {
position = next ?? { x: 1, y: 1 };
saved = next;
}
layout();
},
};
}

function clamp(value: number, minimum: number, maximum: number) {
return Math.max(minimum, Math.min(maximum, value));
}
Loading
Loading