diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4cd72d..de98e1d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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*"] diff --git a/CHANGELOG.md b/CHANGELOG.md index fb75df0..c714cf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 56348b8..e882820 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/README.zh-CN.md b/README.zh-CN.md index 2d548e8..4d4efa3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -16,6 +16,8 @@ AgentOnWeb 将你的编程工作区带到你当前打开的页面上。一边看 切换模式不会中断会话。在 Chill 模式下,双击 **Option / Alt** 可与网页交互;再次双击即可返回工作区。 +拖动 AgentOnWeb 悬浮图标即可避开网页上的按钮和内容。位置会在跨页面和刷新后保留,展开的控制面板也会自动保持在窗口内。 + ## 观看演示 [![在 YouTube 上观看 AgentOnWeb 演示](docs/assets/agentonweb-demo-cover.png)](https://www.youtube.com/watch?v=s083RpD38HU) diff --git a/apps/extension/package.json b/apps/extension/package.json index ab38c63..9f385c7 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -1,6 +1,6 @@ { "name": "@agentonweb/extension", - "version": "0.4.1", + "version": "0.4.2", "private": true, "license": "MIT", "type": "module", diff --git a/apps/extension/src/background.test.ts b/apps/extension/src/background.test.ts index 930b453..65cb959 100644 --- a/apps/extension/src/background.test.ts +++ b/apps/extension/src/background.test.ts @@ -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).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 }); diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts index fe100e2..720f2a5 100644 --- a/apps/extension/src/background.ts +++ b/apps/extension/src/background.ts @@ -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"; @@ -197,6 +197,7 @@ async function initialize(): Promise { 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), @@ -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); @@ -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 } : {}), @@ -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 }; } diff --git a/apps/extension/src/content.test.ts b/apps/extension/src/content.test.ts index d501e6e..3af919b 100644 --- a/apps/extension/src/content.test.ts +++ b/apps/extension/src/content.test.ts @@ -123,6 +123,112 @@ async function page( }; } +function pointer(ui: Awaited>, target: EventTarget, type: string, clientX: number, clientY: number, fields: Record = {}) { + 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(".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(".surface-toggle")!; + const dock = ui.shadow.querySelector(".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(".surface-toggle")!; + const dock = ui.shadow.querySelector(".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(".surface-toggle")!; + const dock = ui.shadow.querySelector(".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(".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(".surface-toggle")!; + const dock = ui.shadow.querySelector(".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); diff --git a/apps/extension/src/content.tsx b/apps/extension/src/content.tsx index 0a5a0e5..8318fec 100644 --- a/apps/extension/src/content.tsx +++ b/apps/extension/src/content.tsx @@ -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); diff --git a/apps/extension/src/dock-position.ts b/apps/extension/src/dock-position.ts new file mode 100644 index 0000000..5a1e84b --- /dev/null +++ b/apps/extension/src/dock-position.ts @@ -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)); +} diff --git a/apps/extension/src/dock.ts b/apps/extension/src/dock.ts index 83b71f1..6904a2f 100644 --- a/apps/extension/src/dock.ts +++ b/apps/extension/src/dock.ts @@ -3,6 +3,7 @@ import { createAgentActivity } from "./agent-activity.js"; import type { AgentSession } from "@agentonweb/connector-contract"; import { nextRuntimeId } from "./runtime-navigation.js"; import { createRuntimePicker } from "./runtime-picker.js"; +import { positionDock } from "./dock-position.js"; import chillIcon from "@phosphor-icons/core/regular/cloud-sun.svg?raw"; import focusIcon from "@phosphor-icons/core/regular/crosshair-simple.svg?raw"; import watchIcon from "@phosphor-icons/core/regular/eye.svg?raw"; @@ -10,7 +11,7 @@ import layersIcon from "@phosphor-icons/core/regular/stack.svg?raw"; import backIcon from "@phosphor-icons/core/regular/arrow-left.svg?raw"; import plusIcon from "@phosphor-icons/core/regular/plus.svg?raw"; import type { AgentOnWebMode } from "@agentonweb/connector-contract"; -import type { SurfaceViewState } from "./shared.js"; +import type { DockPosition, SurfaceViewState } from "./shared.js"; const MODES = [ { mode: "chill", label: "Chill", icon: chillIcon }, @@ -28,6 +29,7 @@ export interface SurfaceDock { onModeShortcut: (mode: AgentOnWebMode) => void; onOpacityPreview: (opacity: number) => void; onOpacity: (opacity: number) => void; + onPosition: (position: DockPosition) => void; shortcut: (action: SurfaceShortcut) => void; focus: () => void; render: (state: SurfaceViewState) => void; @@ -48,6 +50,8 @@ export function createDock(options: { const element = document.createElement("nav"); element.className = "surface-dock"; element.setAttribute("aria-label", "AgentOnWeb presentation controls"); + const panel = document.createElement("div"); + panel.className = "surface-panel"; const plate = document.createElement("div"); plate.className = "surface-plate"; plate.setAttribute("aria-hidden", "true"); @@ -125,7 +129,9 @@ export function createDock(options: { sweep.className = "surface-sweep"; sweep.setAttribute("aria-hidden", "true"); const badge = document.createElement("span"); badge.className = "agent-count"; badge.hidden = true; toggle.append(badge); - element.append(plate, palette, runtimePicker.element, toggle, sweep, activity.toast); + panel.append(plate, palette, runtimePicker.element, sweep); + element.append(panel, toggle, activity.toast); + const placement = positionDock(element, toggle, panel, activity.toast, position => api.onPosition(position)); element.addEventListener("keydown", event => { if (event.key === "Escape") { event.stopPropagation(); api.setExpanded(false); } }); @@ -151,10 +157,11 @@ export function createDock(options: { discover.hidden = workspaceView; toggle.setAttribute("aria-expanded", String(expanded)); toggle.setAttribute("aria-label", expanded ? "Collapse AgentOnWeb controls" : "Expand AgentOnWeb controls"); + placement.layout(); } const api: SurfaceDock = { element, - onSession: () => {}, onAttentionRead: () => {}, onDiscover: () => {}, onRuntime: () => {}, onMode: () => {}, onOpacityPreview: () => {}, onOpacity: () => {}, + onSession: () => {}, onAttentionRead: () => {}, onDiscover: () => {}, onRuntime: () => {}, onMode: () => {}, onOpacityPreview: () => {}, onOpacity: () => {}, onPosition: () => {}, onModeShortcut: mode => api.onMode(mode), shortcut(action) { if (action !== "runtime.toggle") { api.onMode(action); return; } @@ -180,6 +187,7 @@ export function createDock(options: { opacityControl.dataset.disabled = String(opacityInput.disabled); opacityValue.value = `${Math.round(state.opacity * 100)}%`; toggle.dataset.unread = String(state.runtimes?.some(runtime => runtime.newOutput) ?? false); + placement.render(state.dockPosition); }, setExpanded(next) { if (!next && element.contains(element.getRootNode() instanceof ShadowRoot diff --git a/apps/extension/src/overlay.css b/apps/extension/src/overlay.css index 2d90f93..d32eccb 100644 --- a/apps/extension/src/overlay.css +++ b/apps/extension/src/overlay.css @@ -59,13 +59,13 @@ button { cursor: pointer; } pointer-events: none; } -/* The 48px launcher is the fixed anchor; all larger surfaces float above it. */ -.surface-dock { --surface-panel-height:264px; position:absolute; right:14px; bottom:14px; z-index:4; width:min(292px,calc(100vw - 28px)); height:52px; color:#dbe8e0; pointer-events:none; font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; isolation:isolate; } +.surface-dock { --surface-panel-height:264px; position:absolute; right:14px; bottom:14px; z-index:4; width:48px; height:48px; color:#dbe8e0; pointer-events:none; font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; isolation:isolate; } +.surface-panel { position:absolute; right:0; bottom:58px; width:min(292px,calc(100vw - 28px)); height:min(var(--surface-panel-height),calc(100vh - 28px)); } .surface-dock[hidden], .surface-dock [hidden] { display:none !important; } .surface-dock button { color:inherit; font:inherit; background:transparent; border:0; } .surface-dock button:focus-visible, .surface-dock input:focus-visible { outline:2px solid #72edb6; outline-offset:2px; } .surface-dock svg { width:18px; height:18px; fill:currentColor; flex:none; } -.surface-plate { position:absolute; inset:auto 0 0; height:var(--surface-panel-height); background:#0b1410; border:1px solid #375849; border-radius:11px; box-shadow:0 12px 36px #0006; transform-origin:right bottom; transform:scale(.1644,.1951); opacity:0; transition:transform 320ms cubic-bezier(.2,.8,.2,1),opacity 240ms; z-index:-1; } +.surface-plate { position:absolute; inset:0; background:#0b1410; border:1px solid #375849; border-radius:11px; box-shadow:0 12px 36px #0006; transform-origin:right bottom; transform:scale(.1644,.1951); opacity:0; transition:transform 320ms cubic-bezier(.2,.8,.2,1),opacity 240ms; z-index:-1; } .surface-dock[data-expanded="true"] .surface-plate { transform:none; opacity:1; } .surface-palette { position:absolute; left:0; right:0; bottom:52px; height:212px; padding:13px 14px 14px; opacity:0; transform:translateY(12px); transition:opacity 180ms,transform 320ms; pointer-events:none; } .surface-dock[data-expanded="true"] .surface-palette { opacity:1; transform:none; pointer-events:auto; } @@ -83,15 +83,13 @@ button { cursor: pointer; } .surface-opacity-label { display:flex; justify-content:space-between; color:#9cb3a5; font-size:11px; letter-spacing:1px; } .surface-opacity-value { color:#72edb6; font-variant-numeric:tabular-nums; } .surface-opacity input { display:block; width:100%; height:20px; margin:5px 0 0; accent-color:#72edb6; } -.surface-dock .surface-toggle { position:absolute; right:0; bottom:0; display:grid; place-items:center; width:48px; height:48px; padding:0; color:#72edb6; background:#0b1410; border:1px solid #375849; border-radius:11px; pointer-events:auto; } +.surface-dock .surface-toggle { position:absolute; right:0; bottom:0; display:grid; place-items:center; width:48px; height:48px; padding:0; color:#72edb6; background:#0b1410; border:1px solid #375849; border-radius:11px; pointer-events:auto; cursor:grab; touch-action:none; user-select:none; -webkit-user-select:none; } +.surface-dock[data-dragging="true"] .surface-toggle { cursor:grabbing; } .surface-toggle svg { transition:transform 320ms; } -.surface-dock[data-expanded="true"] .surface-toggle { background:transparent; border-color:transparent; } .surface-dock[data-expanded="true"] .surface-toggle svg { transform:rotate(180deg); } -.surface-toggle::before { content:""; position:absolute; left:0; top:12px; bottom:12px; width:1px; background:#2a4034; opacity:0; } -.surface-dock[data-expanded="true"] .surface-toggle::before { opacity:1; } .surface-toggle[data-unread="true"]::after { content:""; position:absolute; top:7px; right:7px; width:5px; height:5px; border-radius:50%; background:#ffc978; } .runtime-switch { position:static; } -.runtime-current { position:absolute; left:6px; right:48px; bottom:4px; height:44px; padding:0 9px; display:grid; grid-template-columns:18px minmax(0,1fr) auto 12px; align-items:center; gap:9px; border-radius:6px; opacity:0; transform:translateX(16px); transition:opacity 160ms,transform 320ms; pointer-events:none; } +.runtime-current { position:absolute; left:6px; right:6px; bottom:4px; height:44px; padding:0 9px; display:grid; grid-template-columns:18px minmax(0,1fr) auto 12px; align-items:center; gap:9px; border-radius:6px; opacity:0; transform:translateX(16px); transition:opacity 160ms,transform 320ms; pointer-events:none; } .surface-dock[data-expanded="true"] .runtime-current { opacity:1; transform:none; pointer-events:auto; } .runtime-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; } .runtime-symbol, .runtime-caret { display:flex; color:#72edb6; } @@ -106,7 +104,7 @@ button { cursor: pointer; } .runtime-options button[aria-pressed="true"]::after { content:"✓"; position:absolute; right:8px; top:12px; } .runtime-current::after { content:"01 ア 10 カ 01 ウ"; position:absolute; inset:0; display:grid; place-items:center; background:#0b1410; color:#72edb6; opacity:0; pointer-events:none; letter-spacing:3px; border-radius:6px; } .is-switching .runtime-current::after { animation:runtime-decode 520ms steps(6,end) both; } -.surface-sweep { position:absolute; inset:auto 0 0; height:var(--surface-panel-height); overflow:hidden; border-radius:11px; pointer-events:none; } +.surface-sweep { position:absolute; inset:0; overflow:hidden; border-radius:11px; pointer-events:none; } .surface-sweep::after { content:""; position:absolute; top:0; left:8px; right:8px; height:1px; background:#72edb6; box-shadow:0 0 12px #72edb6; opacity:0; } .surface-dock[data-expanded="true"].is-pulsing .surface-sweep::after { animation:surface-scan 620ms ease-out; } @keyframes surface-scan { 0% { transform:translateY(2px); opacity:0; } 20% { opacity:.6; } 100% { transform:translateY(calc(var(--surface-panel-height) - 2px)); opacity:0; } } @@ -203,7 +201,6 @@ button { cursor: pointer; } .agent-count { position:absolute; right:2px; top:1px; min-width:16px; padding:0 3px; border-radius:8px; background:#72edb6; color:#0b1410; font-size:10px; line-height:16px; } [data-approval="true"] .agent-count { background:#ffc978; } .surface-dock .agent-notification { position:absolute; bottom:62px; right:0; width:min(292px,calc(100vw - 28px)); display:flex; align-items:flex-start; padding:12px; gap:8px; border:1px solid #4b7960; border-radius:10px; background:#0f1e15; box-shadow:0 8px 28px #0005; pointer-events:auto; } -.surface-dock[data-expanded="true"] .agent-notification { bottom:274px; } .surface-dock .agent-notification[data-status="approval"] { border-color:#997844; } .agent-notification button:first-of-type { flex:1; min-width:0; text-align:left; padding:0; } .agent-notification button:last-of-type { flex:none; width:24px; height:24px; font-size:18px; } diff --git a/apps/extension/src/shared.ts b/apps/extension/src/shared.ts index ed69d0e..7239a2a 100644 --- a/apps/extension/src/shared.ts +++ b/apps/extension/src/shared.ts @@ -6,6 +6,18 @@ export const COOKIE_SCOPES_STORAGE_KEY = "agentonweb.cookie-scopes"; export type SurfaceConnection = "disconnected" | "connecting" | "awaiting-approval" | "connected" | "reconnecting"; +export interface DockPosition { + readonly x: number; + readonly y: number; +} + +export function isDockPosition(value: unknown): value is DockPosition { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return typeof candidate.x === "number" && Number.isFinite(candidate.x) && candidate.x >= 0 && candidate.x <= 1 + && typeof candidate.y === "number" && Number.isFinite(candidate.y) && candidate.y >= 0 && candidate.y <= 1; +} + export interface NativeSurfaceView { readonly runtimeId: string; readonly displayName: string; @@ -18,6 +30,7 @@ export interface SurfaceViewState { readonly agentSessions?: readonly import("@agentonweb/connector-contract").AgentSession[]; readonly mode: AgentOnWebMode; readonly opacity: number; + readonly dockPosition?: DockPosition; readonly dismissed?: boolean; readonly connection: SurfaceConnection; readonly surface?: NativeSurfaceView; @@ -39,6 +52,7 @@ export type ContentRequest = | { readonly source: "agentonweb-content"; readonly type: "mode.set"; readonly mode: AgentOnWebMode } | { readonly source: "agentonweb-content"; readonly type: "mode.shortcut"; readonly mode: AgentOnWebMode } | { readonly source: "agentonweb-content"; readonly type: "opacity.set"; readonly opacity: number } + | { readonly source: "agentonweb-content"; readonly type: "dock.position.set"; readonly position: DockPosition } | { readonly source: "agentonweb-content"; readonly type: "runtime.connect" | "runtime.activate"; readonly runtimeId?: string } | { readonly source: "agentonweb-content"; readonly type: "runtime.approval" }; @@ -65,13 +79,14 @@ export function isSurfaceCommand(value: unknown): value is SurfaceCommand { export function isContentRequest(value: unknown): value is ContentRequest { if (!value || typeof value !== "object") return false; - const candidate = value as { source?: unknown; type?: unknown; mode?: unknown; opacity?: unknown; visible?: unknown; runtimeId?: unknown; attentionId?: unknown }; + const candidate = value as { source?: unknown; type?: unknown; mode?: unknown; opacity?: unknown; visible?: unknown; runtimeId?: unknown; attentionId?: unknown; position?: unknown }; if (candidate.source !== "agentonweb-content") return false; if (candidate.type === "agent.attention.read") return typeof candidate.attentionId === "string" && candidate.attentionId.length > 0 && candidate.attentionId.length <= 200; if (candidate.type === "state.get" || candidate.type === "mode.shortcuts.get" || candidate.type === "runtime.approval") return true; if (candidate.type === "visibility.set") return typeof candidate.visible === "boolean"; if (candidate.type === "mode.set" || candidate.type === "mode.shortcut") return ["chill", "focus", "watch"].includes(String(candidate.mode)); if (candidate.type === "opacity.set") return typeof candidate.opacity === "number" && Number.isFinite(candidate.opacity); + if (candidate.type === "dock.position.set") return isDockPosition(candidate.position); return (candidate.type === "runtime.connect" || candidate.type === "runtime.activate") && (candidate.runtimeId === undefined || typeof candidate.runtimeId === "string"); } diff --git a/apps/safari/AgentOnWeb/AgentOnWeb.xcodeproj/project.pbxproj b/apps/safari/AgentOnWeb/AgentOnWeb.xcodeproj/project.pbxproj index ac4ced7..9cb976b 100644 --- a/apps/safari/AgentOnWeb/AgentOnWeb.xcodeproj/project.pbxproj +++ b/apps/safari/AgentOnWeb/AgentOnWeb.xcodeproj/project.pbxproj @@ -467,7 +467,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 11; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_USER_SELECTED_FILES = readonly; @@ -481,7 +481,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.4; - MARKETING_VERSION = 0.4.1; + MARKETING_VERSION = 0.4.2; OTHER_LDFLAGS = ( "-framework", SafariServices, @@ -501,7 +501,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 11; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_USER_SELECTED_FILES = readonly; @@ -515,7 +515,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.4; - MARKETING_VERSION = 0.4.1; + MARKETING_VERSION = 0.4.2; OTHER_LDFLAGS = ( "-framework", SafariServices, @@ -538,7 +538,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 11; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; @@ -553,7 +553,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.4.1; + MARKETING_VERSION = 0.4.2; OTHER_LDFLAGS = ( "-framework", SafariServices, @@ -579,7 +579,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 11; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; @@ -594,7 +594,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.4.1; + MARKETING_VERSION = 0.4.2; OTHER_LDFLAGS = ( "-framework", SafariServices, diff --git a/apps/safari/README.md b/apps/safari/README.md index fc06fa4..f152a70 100644 --- a/apps/safari/README.md +++ b/apps/safari/README.md @@ -5,7 +5,7 @@ The containing app is a macOS-only Safari Web Extension, built with Xcode. It gi - App Store Connect app ID: recorded in the gitignored `docs/verification/local/` notes and intentionally not published - App bundle ID: `dev.agentonweb.extension` - Extension bundle ID: `dev.agentonweb.extension.safari` -- Version: `0.4.1`, build `10` +- Prepared source version: `0.4.2`, build `11`; this Chrome/Firefox release does not submit a Safari store update. - Minimum: macOS 15.4 and Safari 18.4 - Architectures: Apple Silicon and Intel @@ -36,11 +36,11 @@ To archive and export: DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild \ -project apps/safari/AgentOnWeb/AgentOnWeb.xcodeproj \ -scheme AgentOnWeb -configuration Release \ - -archivePath release/safari/AgentOnWeb-0.4.1-10.xcarchive \ + -archivePath release/safari/AgentOnWeb-0.4.2-11.xcarchive \ -derivedDataPath output/safari-derived -allowProvisioningUpdates archive DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild \ - -exportArchive -archivePath release/safari/AgentOnWeb-0.4.1-10.xcarchive \ + -exportArchive -archivePath release/safari/AgentOnWeb-0.4.2-11.xcarchive \ -exportPath release/safari/app-store \ -exportOptionsPlist apps/safari/config/ExportOptions.plist \ -allowProvisioningUpdates diff --git a/docs/releasing.md b/docs/releasing.md index f3dff53..b611bb7 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,16 +1,20 @@ # Release AgentOnWeb -## Terminal host update patch release +## Extension launcher patch release -Terminal host **0.4.3** repairs forced updates when an older host is still running, reinstalls the current stable version on explicit force, and retries transient manual npm checks with longer deadlines. It retains the direct terminal selection improvements from 0.4.2. Extension **0.4.1**, Safari marketing version **0.4.1** / build **10**, DSH **0.4.0** and connector protocol **1** remain unchanged; this release publishes only the terminal host. - -After finishing live shells, update the host with `aow update` from a separate macOS Terminal. If CLI 0.4.1/0.4.2 cannot prepare an update against an older running host, run `aow service uninstall`, then rerun the installer to install and start 0.4.3. The recovery path requires CLI 0.4.3; it cannot change code already running in an older updater. The DSH compatibility baseline remains **0.1.7-rc.2**; `dsh-latest` checks the currently published upstream version. +Extension **0.4.2** adds mouse and touch dragging for the floating launcher, remembers its position across pages and reloads, and keeps the controls within the viewport. This release targets the Chrome Web Store and Firefox Add-ons. Safari source metadata is prepared at **0.4.2** / build **11**, without submitting a Safari update. Terminal host **0.4.3**, DSH **0.4.0** and connector protocol **1** are unchanged. | Artifact | Version field | Delivery | | --- | --- | --- | -| Chrome/Firefox extension ZIPs and source | `extensionVersion` | Remains 0.4.1; no publication | +| Chrome/Firefox extension ZIPs and source | `extensionVersion` | `ext-v0.4.2`, GitHub release and store submissions | | DSH plugin TGZ | `pluginVersion` | Remains 0.4.0; no publication | -| Terminal host TGZ + installer | `terminalHostVersion` | `terminal-v0.4.3`, npm + GitHub release | +| Terminal host TGZ + installer | `terminalHostVersion` | Remains 0.4.3; no publication | + +## Terminal host update patch release + +Terminal host **0.4.3** repairs forced updates when an older host is still running, reinstalls the current stable version on explicit force, and retries transient manual npm checks with longer deadlines. It retains the direct terminal selection improvements from 0.4.2. Extension **0.4.1**, Safari marketing version **0.4.1** / build **10**, DSH **0.4.0** and connector protocol **1** remain unchanged; this release publishes only the terminal host. + +After finishing live shells, update the host with `aow update` from a separate macOS Terminal. If CLI 0.4.1/0.4.2 cannot prepare an update against an older running host, run `aow service uninstall`, then rerun the installer to install and start 0.4.3. The recovery path requires CLI 0.4.3; it cannot change code already running in an older updater. The DSH compatibility baseline remains **0.1.7-rc.2**; `dsh-latest` checks the currently published upstream version. A merged branch or GitHub release does not mean a browser store has approved the update. Live terminal acceptance currently covers macOS/Chrome. Firefox/Safari builds and Linux CI do not establish live terminal support on those platforms. Automatic setup targets macOS + Chrome only. diff --git a/docs/verification/extension-0.4.2.md b/docs/verification/extension-0.4.2.md new file mode 100644 index 0000000..b202f61 --- /dev/null +++ b/docs/verification/extension-0.4.2.md @@ -0,0 +1,24 @@ +# Extension 0.4.2 + +Prepared October 29, 2026 for Chrome and Firefox. The extension adds a draggable +floating launcher with persistent normalized coordinates, click/drag separation, +cancelled-gesture rollback and viewport-aware placement of controls and notices. + +`pnpm release:audit` passed with 427 tests passed and two optional tests skipped +across 57 test files. Privacy, architecture, TypeScript, Chrome/Firefox/Safari +builds, Safari WebKit validation, reproducible archives and isolated terminal +package installation with real shell input/output passed. + +The implementation was also verified in real Chrome on October 22: mouse and +touch dragging across an iframe, exposing a covered page control, no accidental +click after dragging, extension-storage persistence, cross-page and reload +restoration, menu placement at corners and center, and window resizing. + +Chrome and Firefox use extension version 0.4.2. Safari source metadata is +prepared as 0.4.2, build 11, but Safari submission is outside this release. +Terminal host 0.4.3, DSH 0.4.0 and connector protocol 1 remain unchanged. + +Publish the reviewed main commit under `ext-v0.4.2`. Upload the Chrome MV3 ZIP +to the Chrome Web Store and the Firefox MV2 ZIP with its matching source archive +to Firefox Add-ons. Verify each store's processed version and review/public +state independently; a successful upload does not establish public availability. diff --git a/package.json b/package.json index 327d2b2..a7a5749 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "agentonweb", "description": "Your local terminal and DSH workspace on the website you are using.", - "version": "0.4.1", + "version": "0.4.2", "private": true, "license": "MIT", "type": "module", diff --git a/release-contract.json b/release-contract.json index 6841745..23f78d9 100644 --- a/release-contract.json +++ b/release-contract.json @@ -1,13 +1,13 @@ { - "extensionVersion": "0.4.1", + "extensionVersion": "0.4.2", "pluginVersion": "0.4.0", "terminalHostVersion": "0.4.3", "dsh": "0.1.7-rc.2", "connectorProtocol": 1, "artifacts": { - "extension": "agentonweb-extension-0.4.1.zip", - "firefoxExtension": "agentonweb-extension-firefox-0.4.1.zip", - "extensionSource": "agentonweb-extension-source-0.4.1.zip", + "extension": "agentonweb-extension-0.4.2.zip", + "firefoxExtension": "agentonweb-extension-firefox-0.4.2.zip", + "extensionSource": "agentonweb-extension-source-0.4.2.zip", "dshPlugin": "agentonweb-dsh-surface-0.4.0.tgz", "terminalHost": "agentonweb-terminal-host-0.4.3.tgz" }