diff --git a/README.md b/README.md index 3ca4220..85daa75 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,37 @@ -# canopy-web - -The web side of Canopy, one repository with one app per subdomain: - -- `apps/graphs/` — the graphs catalogue at graphs.univerlab.org: browse, - share and install graph and node designs. -- `apps/remote/` — Canopy remote control at remote.univerlab.org: reach your - canopy sessions from a browser, peer to peer. +

+ Canopy Web +

+ +

+ CI + Release + Cloudflare Workers + License +

+ +

+ graphs.univerlab.org · + remote.univerlab.org · + Canopy · + Docs +

+ +The web side of [Canopy](https://github.com/UniverLab/harness-canopy), the graph +engine for AI coding agents: one repository with one app per subdomain, each its +own Cloudflare Worker with its own secrets and origin. + +| App | Where | What it does | +| --- | --- | --- | +| [Graphs](apps/graphs/README.md) | [graphs.univerlab.org](https://graphs.univerlab.org) | Browse, share and install graph and node designs, each with its plate, its README and every prompt in full. | +| [Remote](apps/remote/README.md) | [remote.univerlab.org](https://remote.univerlab.org) | Reach your Canopy sessions from any browser, over an encrypted peer-to-peer link: drive a terminal, compose or schedule a prompt, copy its text, watch your graphs, invite a guest. | + +```sh +curl -fsSL https://raw.githubusercontent.com/UniverLab/harness-canopy/main/scripts/install.sh | sh +canopy remote login # sign in with GitHub +canopy remote pair # a code and a QR for remote.univerlab.org +``` -Each app is its own Cloudflare Worker with its own secrets and origin. +--- ## Graphs catalogue diff --git a/apps/remote/README.md b/apps/remote/README.md index fb8091e..d5b898e 100644 --- a/apps/remote/README.md +++ b/apps/remote/README.md @@ -6,6 +6,22 @@ or watch its graphs — over an encrypted peer-to-peer link. `web/` is the browser app, `worker/` the blind signaling relay that introduces the two ends (see its own README). +## Working in a session + +The key bar under the terminal carries what a phone keyboard lacks. Ctrl and +Shift are one-shot modifiers: a tap arms the key and opens the keyboard, the +next key goes out with it, and a second tap disarms it. + +**Copy** opens the terminal's text in a sheet where the phone's own long-press +selection works, with Copy selection and Copy all. With a mouse, drag over the +terminal and use the floating Copy button or `Ctrl+Shift+C`; plain `Ctrl+C` +always stays an interrupt for the session. + +The **Prompt Builder** sends now or later: Schedule picks a date and time, and +the sheet lists the session's pending schedules, each with a Cancel. The host +delivers a schedule even with no browser open; it needs the session still +running at that time. Terminals take no schedules, only agent sessions do. + ## Watching graphs The home screen switches between **Sessions** and **Graphs**. Graphs lists diff --git a/apps/remote/web/playwright.config.ts b/apps/remote/web/playwright.config.ts index 36f4cc3..afaa5f4 100644 --- a/apps/remote/web/playwright.config.ts +++ b/apps/remote/web/playwright.config.ts @@ -11,7 +11,7 @@ const port = e2ePort(5199, import.meta.dirname, "E2E_PORT_SMOKE"); export default defineConfig({ testDir: "./test", - testMatch: ["smoke.spec.ts", "terminal-autofit.spec.ts", "terminal-alt-screen.spec.ts", "terminal-dpr1.spec.ts", "terminal-touch-scroll.spec.ts", "graphs.spec.ts", "editor.spec.ts", "signout.spec.ts", "csp.spec.ts", "metrics.spec.ts", "guest.spec.ts", "footer.spec.ts", "fondo.spec.ts", "touch-targets.spec.ts", "account-chip.spec.ts", "upload.spec.ts", "control-size.spec.ts"], + testMatch: ["smoke.spec.ts", "terminal-autofit.spec.ts", "terminal-alt-screen.spec.ts", "terminal-dpr1.spec.ts", "terminal-touch-scroll.spec.ts", "graphs.spec.ts", "editor.spec.ts", "signout.spec.ts", "csp.spec.ts", "metrics.spec.ts", "guest.spec.ts", "footer.spec.ts", "fondo.spec.ts", "touch-targets.spec.ts", "account-chip.spec.ts", "upload.spec.ts", "control-size.spec.ts", "schedule.spec.ts", "latch-keyboard.spec.ts", "copy.spec.ts"], timeout: 60_000, use: { baseURL: `http://127.0.0.1:${port}`, diff --git a/apps/remote/web/src/app.ts b/apps/remote/web/src/app.ts index d39e02c..6f2d36c 100644 --- a/apps/remote/web/src/app.ts +++ b/apps/remote/web/src/app.ts @@ -18,15 +18,21 @@ import { icon, mountMorphSlot, type RemoteIconName, type MorphSlot } from "./ico import { renderKeyBar, updateKeyBarLatches } from "./keys.js"; import { MetricsRoute } from "./metrics-route.js"; import type { MetricsStore } from "./metrics-store.js"; -import { keyBytesWithModifiers, ModifierLatches, textBytesWithModifiers } from "./modifiers.js"; +import { + isTerminalReport, + keyBytesWithModifiers, + ModifierLatches, + textBytesWithModifiers, +} from "./modifiers.js"; import { NewDialog } from "./new-dialog.js"; import { defaultDeviceLabel } from "./pairing.js"; -import type { PromptDraft, SpawnRequest } from "./protocol.js"; +import type { PromptDraft, ScheduledItem, SpawnRequest } from "./protocol.js"; import type { OnlineHost } from "./relay.js"; import type { ShelfHandle } from "./shelf-controller.js"; import { convergeOpenSessionName, type SessionStore } from "./sessions.js"; import { createXtermHandle, TerminalView } from "./terminal.js"; import type { TerminalFactory } from "./terminal.js"; +import { TermCopySheet, termCopyFloatHtml, wireCopyFloat } from "./term-copy.js"; import { latestHidden, termControlsHtml, @@ -68,6 +74,9 @@ export interface ShellCallbacks { onSpawn: (spawn: SpawnRequest, req: string) => boolean; onCompose: (draft: PromptDraft, req: string) => boolean; onSendDraft: (draft: PromptDraft, req: string) => boolean; + onSchedule: (draft: PromptDraft, fireAt: string, req: string) => boolean; + onScheduledList: (req: string) => boolean; + onScheduledCancel: (scheduleId: string, req: string) => boolean; /** Ask the host for the graphs list (the Graphs tab shows and polls it). */ onGraphsRequest: () => void; /** Start watching a graph: the host pushes graph_state until unwatched. */ @@ -105,6 +114,8 @@ export interface Shell { showSessionExited: (code: number) => void; sheetSpawned: (req: string, id: string) => void; sheetComposed: (req: string, text: string) => void; + sheetScheduled: (req: string, item: ScheduledItem) => void; + sheetScheduledList: (req: string, items: ScheduledItem[]) => void; sheetOk: (req: string) => void; sheetError: (message: string, req: string | null, code: string) => void; dispose: () => void; @@ -160,6 +171,7 @@ export function createShell( let sessionRows = 0; let terminalView: TerminalView | null = null; let latestButton: HTMLButtonElement | null = null; + let copyFloatDispose: (() => void) | null = null; let termControls: TermControls | null = null; let uploadView: UploadView | null = null; // The two state-telling slots: the host chip's link icon and the Files @@ -183,10 +195,15 @@ export function createShell( const builder = new Builder(catalog, { onCompose: (draft, req) => callbacks.onCompose(draft, req), onSend: (draft, req) => callbacks.onSendDraft(draft, req), + onSchedule: (draft, fireAt, req) => callbacks.onSchedule(draft, fireAt, req), + onScheduledList: (req) => callbacks.onScheduledList(req), + onScheduledCancel: (scheduleId, req) => callbacks.onScheduledCancel(scheduleId, req), }); + const termCopy = new TermCopySheet(); const closeSheets = () => { newDialog.close(); builder.close(); + termCopy.close(); shelf?.close(); }; @@ -292,9 +309,19 @@ export function createShell( return; } newDialog.close(); + termCopy.close(); builder.open(root); }; + const openCopy = () => { + if (route !== "session") { + return; + } + newDialog.close(); + builder.close(); + termCopy.open(root, { bufferText: (maxLines) => terminalView?.bufferText(maxLines) ?? "" }); + }; + // The local terminal takes a new grid in place (no remount, the // scrollback stays), and the shell remembers it so a snapshot at that // size later does not remount either. @@ -311,6 +338,8 @@ export function createShell( const teardownSessionWidgets = () => { termControls?.dispose(); termControls = null; + copyFloatDispose?.(); + copyFloatDispose = null; terminalView?.dispose(); terminalView = null; latestButton = null; @@ -321,6 +350,26 @@ export function createShell( sessionRows = 0; }; + // Arming a latch focuses the terminal in the same tap: a phone only + // opens its keyboard inside the user gesture. A terminal still behind + // the font gate focuses once it opens, as a pane tap does; releasing a + // latch never touches focus. Harmless with a mouse too. + const focusTerminalForLatch = (): void => { + const view = terminalView; + if (!view) { + return; + } + if (view.fitted) { + view.focus(); + return; + } + void view.ready().then(() => { + if (terminalView === view) { + view.focus(); + } + }); + }; + const renderSession = () => { teardownSessionWidgets(); latches.release(); @@ -344,7 +393,7 @@ export function createShell(

Attaching…

-
${termLatestHtml()} +
${termLatestHtml()}${termCopyFloatHtml()} ${termControlsHtml()} @@ -371,6 +420,7 @@ export function createShell( onControlSize: (cols, rows) => callbacks.onControlSize(cols, rows), onLocalSize: (cols, rows) => resizeLocalTerminal(cols, rows), onBuilder: () => openBuilder(), + onCopy: () => openCopy(), }); latestButton = termControls.latestButton; // In fullscreen the latest button follows the pane's own scroll (see @@ -388,16 +438,23 @@ export function createShell( if (bar) { renderKeyBar(bar, (def) => { // A latch toggles and shows pressed; a second tap releases it - // without sending. Any other key goes out with the latch applied, - // then the latch releases. + // without sending. Arming also focuses the terminal, in the same + // tap, so the phone keyboard opens. Any other key goes out with + // the latch applied, then the latch releases. if (def.id === "ctrl") { - latches.toggleCtrl(); + const armed = latches.toggleCtrl(); updateKeyBarLatches(bar, latches.snapshot()); + if (armed) { + focusTerminalForLatch(); + } return; } if (def.id === "shift") { - latches.toggleShift(); + const armed = latches.toggleShift(); updateKeyBarLatches(bar, latches.snapshot()); + if (armed) { + focusTerminalForLatch(); + } return; } const bytes = keyBytesWithModifiers(def.id, latches.snapshot()); @@ -724,8 +781,16 @@ export function createShell( terminalFactory, { // Phone-keyboard keys honour the latches too: Ctrl+c sends 0x03, - // then the latch releases and the bar shows it. + // then the latch releases and the bar shows it. xterm's own + // reports (focus, cursor position, device attributes, mouse) + // are not typing: they go out unchanged, leaving the latch + // armed and the bar untouched — otherwise the focus-in report + // from tapping the terminal would spend the latch first. onUserInput: (text) => { + if (isTerminalReport(text)) { + callbacks.onTerminalInput(new TextEncoder().encode(text)); + return; + } const bytes = textBytesWithModifiers(text, latches.snapshot()); if (latches.hasLatch) { latches.release(); @@ -742,6 +807,13 @@ export function createShell( }, ); terminalView.mount(pane, cols, rows); + // The selection float follows this mount: a remount rewires it. + copyFloatDispose?.(); + copyFloatDispose = null; + const floatButton = root.querySelector("#term-copy-float"); + if (floatButton) { + copyFloatDispose = wireCopyFloat(floatButton, terminalView); + } // A fine pointer types straight into the session: focus once on // attach, after xterm is open. A touch device never auto-opens its // keyboard — the first tap on the pane focuses instead. @@ -795,6 +867,12 @@ export function createShell( sheetComposed(req: string, text: string): void { builder.handleComposed(req, text); }, + sheetScheduled(req: string, item: ScheduledItem): void { + builder.handleScheduled(req, item); + }, + sheetScheduledList(req: string, items: ScheduledItem[]): void { + builder.handleScheduledList(req, items); + }, sheetOk(req: string): void { builder.handleOk(req); }, diff --git a/apps/remote/web/src/builder.ts b/apps/remote/web/src/builder.ts index 9fba9a8..86e77a3 100644 --- a/apps/remote/web/src/builder.ts +++ b/apps/remote/web/src/builder.ts @@ -32,13 +32,18 @@ import { } from "./draft.js"; import type { DraftStorage, SectionKind } from "./draft.js"; import { newReqId } from "./protocol.js"; -import type { PromptDraft } from "./protocol.js"; +import type { PromptDraft, ScheduledItem } from "./protocol.js"; import { Sheet, esc } from "./sheet.js"; export interface BuilderCallbacks { /** Returns false when the frame could not be sent (no connection). */ onCompose: (draft: PromptDraft, req: string) => boolean; onSend: (draft: PromptDraft, req: string) => boolean; + /** Schedule the draft for fireAt (RFC 3339 UTC) on the open session. */ + onSchedule: (draft: PromptDraft, fireAt: string, req: string) => boolean; + /** List the open session's pending schedules; the shell knows the id. */ + onScheduledList: (req: string) => boolean; + onScheduledCancel: (scheduleId: string, req: string) => boolean; } interface BuilderState { @@ -50,6 +55,13 @@ interface BuilderState { projectError: string | null; previewReq: string | null; sendReq: string | null; + scheduleRow: boolean; + scheduleReq: string | null; + listReq: string | null; + cancelReq: { req: string; scheduleId: string } | null; + /** The session's pending schedules; null until the first list lands. */ + scheduled: ScheduledItem[] | null; + scheduleStatus: string | null; } function freshState(): BuilderState { @@ -62,9 +74,28 @@ function freshState(): BuilderState { projectError: null, previewReq: null, sendReq: null, + scheduleRow: false, + scheduleReq: null, + listReq: null, + cancelReq: null, + scheduled: null, + scheduleStatus: null, }; } +// A datetime-local value in the device's own time zone: YYYY-MM-DDTHH:MM. +function toLocalInputValue(date: Date): string { + const pad = (n: number): string => String(n).padStart(2, "0"); + return ( + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + + `T${pad(date.getHours())}:${pad(date.getMinutes())}` + ); +} + +function formatFireAt(fireAt: string): string { + return new Date(fireAt).toLocaleString(); +} + export class Builder { private readonly catalog: CatalogStore; private readonly callbacks: BuilderCallbacks; @@ -115,12 +146,16 @@ export class Builder { this.render(); } }); + this.requestScheduledList(); } // Closing keeps the draft: memory and sessionStorage both still hold it. close(): void { this.state.previewReq = null; this.state.sendReq = null; + this.state.scheduleReq = null; + this.state.listReq = null; + this.state.cancelReq = null; this.unsubscribe?.(); this.unsubscribe = null; this.body = null; @@ -142,21 +177,66 @@ export class Builder { } handleOk(req: string): boolean { - if (this.state.sendReq === null || this.state.sendReq !== req) { + if (this.state.sendReq !== null && this.state.sendReq === req) { + this.state.sendReq = null; + this.state.draft = emptyDraft(); + clearDraft(this.storage); + this.state.preview = null; + this.close(); + return true; + } + // A cancel answers the same ok: drop the row, then refresh the list. + if (this.state.cancelReq !== null && this.state.cancelReq.req === req) { + const scheduleId = this.state.cancelReq.scheduleId; + this.state.cancelReq = null; + if (this.state.scheduled !== null) { + this.state.scheduled = this.state.scheduled.filter((item) => item.id !== scheduleId); + } + this.render(); + this.requestScheduledList(); + return true; + } + return false; + } + + // The host accepted the schedule: clear the draft like a Send, keep the + // sheet open with a fresh default section, and refresh the list. + handleScheduled(req: string, item: ScheduledItem): boolean { + if (this.state.scheduleReq === null || this.state.scheduleReq !== req) { return false; } - this.state.sendReq = null; - this.state.draft = emptyDraft(); + this.state.scheduleReq = null; + this.state.draft = addSection(emptyDraft(), "instruction"); clearDraft(this.storage); this.state.preview = null; - this.close(); + this.state.scheduleRow = false; + this.state.scheduleStatus = `Scheduled for ${formatFireAt(item.fireAt)}`; + this.render(); + this.requestScheduledList(); + return true; + } + + handleScheduledList(req: string, items: ScheduledItem[]): boolean { + if (this.state.listReq === null || this.state.listReq !== req) { + return false; + } + this.state.listReq = null; + this.state.scheduled = [...items].sort( + (a, b) => Date.parse(a.fireAt) - Date.parse(b.fireAt), + ); + this.render(); return true; } handleError(message: string, req: string | null, code: string): boolean { const previewMatch = this.state.previewReq !== null && (req === null || this.state.previewReq === req); const sendMatch = this.state.sendReq !== null && (req === null || this.state.sendReq === req); - if (!previewMatch && !sendMatch) { + const scheduleMatch = + this.state.scheduleReq !== null && (req === null || this.state.scheduleReq === req); + const listMatch = this.state.listReq !== null && (req === null || this.state.listReq === req); + const cancelMatch = + this.state.cancelReq !== null && (req === null || this.state.cancelReq.req === req); + if (!previewMatch && !sendMatch && !scheduleMatch && !listMatch && !cancelMatch) { return false; } if (previewMatch) { @@ -165,6 +245,17 @@ export class Builder { if (sendMatch) { this.state.sendReq = null; } + // A schedule, list or cancel failure keeps the draft and the rows: the + // message lands on the sheet's own error line, even from an old host + // that answers every new frame with unknown-frame. + if (scheduleMatch || listMatch || cancelMatch) { + this.state.scheduleReq = scheduleMatch ? null : this.state.scheduleReq; + this.state.listReq = listMatch ? null : this.state.listReq; + this.state.cancelReq = cancelMatch ? null : this.state.cancelReq; + this.state.error = message; + this.renderErrors(); + return true; + } const classified = classifyDraftError(code, message); if (classified.field === "preset") { this.state.presetError = classified.message; @@ -200,6 +291,7 @@ export class Builder { + ${this.scheduledHtml()} @@ -218,8 +310,11 @@ export class Builder {
${esc(this.state.preview ?? "")}
+
+ ${this.scheduleRowHtml()} +

${esc(this.state.scheduleStatus ?? "")}

`; this.wire(); this.syncSelects(); @@ -271,6 +366,47 @@ export class Builder { .join(""); } + // The session's pending schedules, once the list has loaded with + // items. Previews are user text: always escaped, never innerHTML raw. + private scheduledHtml(): string { + const scheduled = this.state.scheduled; + if (scheduled === null || scheduled.length === 0) { + return ""; + } + const rows = scheduled + .map( + (item) => ` +
  • + ${esc(formatFireAt(item.fireAt))} + ${esc(item.preview)} + +
  • `, + ) + .join(""); + return ` +
    +

    Scheduled for this session

    + +
    `; + } + + // The inline scheduling row: preset to one hour from now, never earlier + // than the current minute. + private scheduleRowHtml(): string { + const now = new Date(); + const preset = toLocalInputValue(new Date(now.getTime() + 60 * 60 * 1000)); + return ` +
    + +
    + + +
    +
    `; + } + private presetOptions(): string { const presets = this.catalog.presets; const selected = this.state.draft.preset ?? ""; @@ -430,6 +566,19 @@ export class Builder { }); body.querySelector("#builder-preview")?.addEventListener("click", () => this.preview()); body.querySelector("#builder-send")?.addEventListener("click", () => this.send()); + body.querySelector("#builder-schedule")?.addEventListener("click", () => { + this.state.scheduleRow = true; + this.render(); + body.querySelector("#builder-schedule-at")?.focus(); + }); + body.querySelector("#builder-schedule-dismiss")?.addEventListener("click", () => { + this.state.scheduleRow = false; + this.render(); + }); + body.querySelector("#builder-schedule-confirm")?.addEventListener("click", () => this.confirmSchedule()); + for (const button of body.querySelectorAll("button[data-schedule-id]")) { + button.addEventListener("click", () => this.cancelScheduled(button.dataset.scheduleId ?? "")); + } } // The composed text describes the draft it came from; any edit makes it @@ -468,6 +617,58 @@ export class Builder { } } + // Confirm schedules the wire draft for the row's local time, sent as + // UTC. An empty, unreadable or past value is the sheet's plain error + // and sends nothing. + private confirmSchedule(): void { + this.clearErrors(); + const input = this.body?.querySelector("#builder-schedule-at"); + const value = input?.value ?? ""; + const at = value === "" ? Number.NaN : new Date(value).getTime(); + if (value === "") { + this.state.error = "Choose a date and time first."; + this.renderErrors(); + return; + } + if (Number.isNaN(at) || at <= Date.now()) { + this.state.error = "Pick a time in the future."; + this.renderErrors(); + return; + } + const req = newReqId(); + this.state.scheduleReq = req; + if (!this.callbacks.onSchedule(wireDraft(this.state.draft), new Date(value).toISOString(), req)) { + this.state.scheduleReq = null; + this.state.error = "Not connected"; + this.renderErrors(); + } + } + + private cancelScheduled(scheduleId: string): void { + if (scheduleId === "") { + return; + } + this.clearErrors(); + const req = newReqId(); + this.state.cancelReq = { req, scheduleId }; + if (!this.callbacks.onScheduledCancel(scheduleId, req)) { + this.state.cancelReq = null; + this.state.error = "Not connected"; + this.renderErrors(); + } + } + + // No polling: the list loads on open and after each schedule or cancel. + private requestScheduledList(): void { + const req = newReqId(); + this.state.listReq = req; + if (!this.callbacks.onScheduledList(req)) { + this.state.listReq = null; + this.state.error = "Not connected"; + this.renderErrors(); + } + } + private clearErrors(): void { this.state.error = null; this.state.presetError = null; diff --git a/apps/remote/web/src/fake-host.ts b/apps/remote/web/src/fake-host.ts index 8140e7d..4c75dd7 100644 --- a/apps/remote/web/src/fake-host.ts +++ b/apps/remote/web/src/fake-host.ts @@ -137,6 +137,10 @@ function main(): void { }; let nextSpawn = 0; let promptError: { code: string; message: string } | null = null; + // The pending schedules per session: the preview is the composed text's + // first 120 chars, like the real host's. + const scheduled: { id: string; sessionId: string; fire_at: string; preview: string }[] = []; + let scheduleCounter = 0; const sessions: SessionItem[] = [ { @@ -402,6 +406,8 @@ function main(): void { data?: string; cols?: number; rows?: number; + fire_at?: string; + schedule_id?: string; }; sent.push(frame); if (frame.t === "list") { @@ -563,6 +569,41 @@ function main(): void { } else { deliver({ t: "ok", req: frame.req }); } + } else if ( + frame.t === "prompt_schedule" && + typeof frame.req === "string" && + typeof frame.id === "string" && + typeof frame.fire_at === "string" + ) { + scheduleCounter += 1; + const item = { + id: `ss-${scheduleCounter}`, + fire_at: frame.fire_at, + preview: composeText(frame.draft ?? { sections: [], skills: [] }).slice(0, 120), + }; + scheduled.push({ ...item, sessionId: frame.id }); + deliver({ t: "scheduled", req: frame.req, item }); + } else if (frame.t === "scheduled_list" && typeof frame.req === "string" && typeof frame.id === "string") { + const items = scheduled + .filter((entry) => entry.sessionId === frame.id) + .sort((a, b) => Date.parse(a.fire_at) - Date.parse(b.fire_at)) + .map((entry) => ({ id: entry.id, fire_at: entry.fire_at, preview: entry.preview })); + deliver({ t: "scheduled_list", req: frame.req, id: frame.id, items }); + } else if ( + frame.t === "scheduled_cancel" && + typeof frame.req === "string" && + typeof frame.id === "string" && + typeof frame.schedule_id === "string" + ) { + const index = scheduled.findIndex( + (entry) => entry.sessionId === frame.id && entry.id === frame.schedule_id, + ); + if (index < 0) { + deliver({ t: "error", req: frame.req, code: "not_found", message: "no such schedule" }); + } else { + scheduled.splice(index, 1); + deliver({ t: "ok", req: frame.req }); + } } return true; }; @@ -681,6 +722,9 @@ function main(): void { onSpawn: (spawn, req) => send(buildSpawn(req, spawn)), onCompose: (draft, req) => send(JSON.stringify({ t: "compose", req, draft })), onSendDraft: (draft, req) => controller.sendPromptDraft(draft, req), + onSchedule: (draft, fireAt, req) => controller.sendPromptSchedule(draft, fireAt, req), + onScheduledList: (req) => controller.sendScheduledList(req), + onScheduledCancel: (scheduleId, req) => controller.sendScheduledCancel(scheduleId, req), onGraphsRequest: () => { graphs.requestList(); }, diff --git a/apps/remote/web/src/guest-views.ts b/apps/remote/web/src/guest-views.ts index 593c711..88a90c5 100644 --- a/apps/remote/web/src/guest-views.ts +++ b/apps/remote/web/src/guest-views.ts @@ -13,9 +13,16 @@ import { renderTopbar, wireAccount } from "./chrome.js"; import { rememberGuestReturn } from "./entry-path.js"; import { syncHeaderFondo } from "./fondo.js"; import { REMOTE_FOOTER } from "./footer.js"; +import { icon } from "./icons-dom.js"; import { renderKeyBar, updateKeyBarLatches } from "./keys.js"; -import { keyBytesWithModifiers, ModifierLatches, textBytesWithModifiers } from "./modifiers.js"; +import { + isTerminalReport, + keyBytesWithModifiers, + ModifierLatches, + textBytesWithModifiers, +} from "./modifiers.js"; import { SessionStore, renderSessions } from "./sessions.js"; +import { TermCopySheet, termCopyFloatHtml, wireCopyFloat } from "./term-copy.js"; import { TerminalView, createXtermHandle } from "./terminal.js"; import type { TerminalFactory } from "./terminal.js"; import { pointerIsFine } from "./terminal-prefs.js"; @@ -91,6 +98,8 @@ export function createGuestView( let sessionCols = 0; let sessionRows = 0; let terminalView: TerminalView | null = null; + let copyFloatDispose: (() => void) | null = null; + const termCopy = new TermCopySheet(); const latches = new ModifierLatches(); function header(): string { @@ -123,6 +132,9 @@ export function createGuestView( } function teardownSessionWidgets(): void { + copyFloatDispose?.(); + copyFloatDispose = null; + termCopy.close(); terminalView?.dispose(); terminalView = null; sessionCols = 0; @@ -285,6 +297,25 @@ export function createGuestView( } } + // As the owner's: arming a latch focuses the terminal in the same + // tap so the phone keyboard opens; a terminal still behind the font + // gate focuses once it opens, and releasing never touches focus. + function focusTerminalForLatch(): void { + const view = terminalView; + if (!view) { + return; + } + if (view.fitted) { + view.focus(); + return; + } + void view.ready().then(() => { + if (terminalView === view) { + view.focus(); + } + }); + } + function paintSession(): void { route = "session"; document.title = sessionTitle(sessionName === "" ? sessionId : sessionName); @@ -297,26 +328,41 @@ export function createGuestView( ${esc(sessionName || sessionId)} + -
    -

    Attaching…

    +
    +
    +

    Attaching…

    +
    ${termCopyFloatHtml()}
    ${REMOTE_FOOTER}`; wireChrome(); root.querySelector("#session-back")?.addEventListener("click", () => callbacks.onLeaveSession()); + // Read-only viewing copies too: the guest already sees this text. + root.querySelector("#term-copy")?.addEventListener("click", () => { + if (route === "session") { + termCopy.open(root, { bufferText: (maxLines) => terminalView?.bufferText(maxLines) ?? "" }); + } + }); const bar = root.querySelector("#keybar"); if (bar) { renderKeyBar(bar, (def) => { if (def.id === "ctrl") { - latches.toggleCtrl(); + const armed = latches.toggleCtrl(); updateKeyBarLatches(bar, latches.snapshot()); + if (armed) { + focusTerminalForLatch(); + } return; } if (def.id === "shift") { - latches.toggleShift(); + const armed = latches.toggleShift(); updateKeyBarLatches(bar, latches.snapshot()); + if (armed) { + focusTerminalForLatch(); + } return; } const bytes = keyBytesWithModifiers(def.id, latches.snapshot()); @@ -418,7 +464,13 @@ export function createGuestView( } pane.replaceChildren(); terminalView = new TerminalView(terminalFactory, { + // As the owner's: xterm's own reports go out unchanged, leaving + // the latch armed and the bar untouched — they are not typing. onUserInput: (text) => { + if (isTerminalReport(text)) { + callbacks.onInput(new TextEncoder().encode(text)); + return; + } const bytes = textBytesWithModifiers(text, latches.snapshot()); if (latches.hasLatch) { latches.release(); @@ -431,6 +483,13 @@ export function createGuestView( }, }); terminalView.mount(pane, cols, rows); + // As the owner's float: rewired on every mount. + copyFloatDispose?.(); + copyFloatDispose = null; + const floatButton = root.querySelector("#term-copy-float"); + if (floatButton) { + copyFloatDispose = wireCopyFloat(floatButton, terminalView); + } // As the owner's: a fine pointer takes focus once on attach, after // xterm is open; a touch device waits for the first tap instead. if (pointerIsFine()) { diff --git a/apps/remote/web/src/guest.ts b/apps/remote/web/src/guest.ts index 85abaf2..4a7adaf 100644 --- a/apps/remote/web/src/guest.ts +++ b/apps/remote/web/src/guest.ts @@ -537,6 +537,8 @@ export function createGuestSession(root: HTMLElement, deps: GuestSessionDeps): G sessionResized: (id, cols, rows) => view.sessionResized(id, cols, rows), sheetSpawned: () => {}, sheetComposed: () => {}, + sheetScheduled: () => {}, + sheetScheduledList: () => {}, sheetOk: () => {}, sheetError: () => {}, }; diff --git a/apps/remote/web/src/icons-dom.ts b/apps/remote/web/src/icons-dom.ts index 298a85e..5d96dcc 100644 --- a/apps/remote/web/src/icons-dom.ts +++ b/apps/remote/web/src/icons-dom.ts @@ -32,6 +32,7 @@ import { chevronRight, circle, circleDot, + copy, cornerDownLeft, deleteLeft, folder, @@ -68,6 +69,7 @@ const REMOTE_ICONS = { chevronRight, circle, circleDot, + copy, cornerDownLeft, deleteLeft, folder, diff --git a/apps/remote/web/src/keys.ts b/apps/remote/web/src/keys.ts index fb12f8e..79c1f4c 100644 --- a/apps/remote/web/src/keys.ts +++ b/apps/remote/web/src/keys.ts @@ -60,6 +60,11 @@ export function renderKeyBar(bar: HTMLElement, onKey: (def: KeyDef) => void): vo bar.replaceChildren(); bar.setAttribute("role", "toolbar"); bar.setAttribute("aria-label", "Terminal keys"); + // The bar must not steal xterm's focus: a mousedown default moves focus + // off the hidden textarea and closes the phone keyboard between keys. + // touchstart and pointerdown stay untouched — preventing those kills + // the tap's click. + bar.addEventListener("mousedown", (event) => event.preventDefault()); for (const def of KEY_DEFS) { const button = document.createElement("button"); button.type = "button"; diff --git a/apps/remote/web/src/main.ts b/apps/remote/web/src/main.ts index 01b4f20..6165ab5 100644 --- a/apps/remote/web/src/main.ts +++ b/apps/remote/web/src/main.ts @@ -128,6 +128,9 @@ async function main(): Promise { onSpawn: (spawn, req) => manager?.send(buildSpawn(req, spawn)) ?? false, onCompose: (draft, req) => manager?.send(buildCompose(req, draft)) ?? false, onSendDraft: (draft, req) => controller.sendPromptDraft(draft, req), + onSchedule: (draft, fireAt, req) => controller.sendPromptSchedule(draft, fireAt, req), + onScheduledList: (req) => controller.sendScheduledList(req), + onScheduledCancel: (scheduleId, req) => controller.sendScheduledCancel(scheduleId, req), onGraphsRequest: () => { graphs.requestList(); }, diff --git a/apps/remote/web/src/modifiers.ts b/apps/remote/web/src/modifiers.ts index 147aa8e..c594b59 100644 --- a/apps/remote/web/src/modifiers.ts +++ b/apps/remote/web/src/modifiers.ts @@ -86,11 +86,40 @@ export function keyBytesWithModifiers(id: string, mods: ModifierState): Uint8Arr return new Uint8Array([]); } +// The bytes xterm generates by itself, through the same onData as typed +// text: focus reports, cursor position replies, device attribute replies +// and mouse reports. These are never the user typing, so they must never +// apply or consume a latch. A lone ESC, arrows and other keys are input, +// not reports. +export function isTerminalReport(text: string): boolean { + if (text === "\x1b[I" || text === "\x1b[O") { + return true; + } + // X10 mouse: ESC[M followed by three bytes (button, x, y). + if (text.length === 6 && text.startsWith("\x1b[M")) { + return true; + } + if (!text.startsWith("\x1b[")) { + return false; + } + // Past the ESC[ prefix the body is plain ASCII: cursor position + // replies, device attribute replies and SGR mouse reports. + const body = text.slice(2); + return ( + /^\d+;\d+R$/.test(body) || + /^\?[0-9;]*c$/.test(body) || + /^>[0-9;]*c$/.test(body) || + /^<[0-9;]+[Mm]$/.test(body) + ); +} + // Phone-keyboard text with the latch applied. A latched Ctrl turns a -// single letter into its control byte (a→0x01 … z→0x1a, either case) and -// "[" into ESC; anything else goes out unchanged. Shift never alters -// typed text (the keyboard already shifted it). The caller releases the -// latch after any input, modified or not. +// single letter into its control byte (a→0x01 … z→0x1a, either case, +// so an autocapitalized letter still works) and "[" into ESC; anything +// else goes out unchanged. With Shift latched and Ctrl not, a single +// lowercase letter goes out uppercase — the phone keyboard knows nothing +// of our latch. Ctrl keeps precedence. The caller releases the latch +// after any input, modified or not. export function textBytesWithModifiers(text: string, mods: ModifierState): Uint8Array { if (mods.ctrl && text.length === 1) { const ch = text[0] as string; @@ -102,5 +131,11 @@ export function textBytesWithModifiers(text: string, mods: ModifierState): Uint8 return new Uint8Array([lower.charCodeAt(0) - 0x60]); } } + if (!mods.ctrl && mods.shift && text.length === 1) { + const ch = text[0] as string; + if (ch >= "a" && ch <= "z") { + return new Uint8Array([ch.charCodeAt(0) - 0x20]); + } + } return new TextEncoder().encode(text); } diff --git a/apps/remote/web/src/protocol.ts b/apps/remote/web/src/protocol.ts index ad1cee9..2b26e99 100644 --- a/apps/remote/web/src/protocol.ts +++ b/apps/remote/web/src/protocol.ts @@ -365,6 +365,68 @@ export function parseComposed(frame: unknown): { req: string; text: string } | n return { req: frame.req, text: frame.text }; } +// A prompt scheduled for later delivery to one session: the host fires it +// at fire_at (RFC 3339 UTC) and the preview is user text, up to 120 chars. +export interface ScheduledItem { + id: string; + fireAt: string; + preview: string; +} + +function parseScheduledItem(value: unknown): ScheduledItem | null { + if (!isRecord(value)) { + return null; + } + if (typeof value.id !== "string" || value.id === "") { + return null; + } + if (typeof value.fire_at !== "string" || value.fire_at === "") { + return null; + } + if (typeof value.preview !== "string") { + return null; + } + return { id: value.id, fireAt: value.fire_at, preview: value.preview }; +} + +export function parseScheduled(frame: unknown): { req: string; item: ScheduledItem } | null { + if (frameType(frame) !== "scheduled" || !isRecord(frame)) { + return null; + } + if (typeof frame.req !== "string") { + return null; + } + const item = parseScheduledItem(frame.item); + if (!item) { + return null; + } + return { req: frame.req, item }; +} + +// The session's pending schedules, fire_at ascending. Malformed entries +// are dropped like a catalog frame's, so one bad row never empties the list. +export function parseScheduledList( + frame: unknown, +): { req: string; id: string; items: ScheduledItem[] } | null { + if (frameType(frame) !== "scheduled_list" || !isRecord(frame)) { + return null; + } + if (typeof frame.req !== "string" || typeof frame.id !== "string" || frame.id === "") { + return null; + } + if (!Array.isArray(frame.items)) { + return null; + } + const items: ScheduledItem[] = []; + for (const value of frame.items) { + const item = parseScheduledItem(value); + if (item) { + items.push(item); + } + } + return { req: frame.req, id: frame.id, items }; +} + // --- The session shelf (additive to v1) ------------------------------- // The host advertises the shelf in hello.features; without "shelf" in // that list the browser sends no shelf frame and shows no shelf UI. @@ -546,6 +608,18 @@ export function buildPromptDraft(req: string, id: string, draft: PromptDraft): s return JSON.stringify({ t: "prompt", req, id, draft }); } +export function buildPromptSchedule(req: string, id: string, draft: PromptDraft, fireAt: string): string { + return JSON.stringify({ t: "prompt_schedule", req, id, draft, fire_at: fireAt }); +} + +export function buildScheduledList(req: string, id: string): string { + return JSON.stringify({ t: "scheduled_list", req, id }); +} + +export function buildScheduledCancel(req: string, id: string, scheduleId: string): string { + return JSON.stringify({ t: "scheduled_cancel", req, id, schedule_id: scheduleId }); +} + export function buildAttach(id: string): string { return JSON.stringify({ t: "attach", id }); } diff --git a/apps/remote/web/src/router.ts b/apps/remote/web/src/router.ts index 3c5b2ac..a6ea303 100644 --- a/apps/remote/web/src/router.ts +++ b/apps/remote/web/src/router.ts @@ -25,6 +25,8 @@ import { parseOk, parseOut, parseResized, + parseScheduled, + parseScheduledList, parseSessionAdded, parseSessionRemoved, parseSessions, @@ -33,6 +35,7 @@ import { parseShelfRemoved, parseSpawned, } from "./protocol.js"; +import type { ScheduledItem } from "./protocol.js"; import type { SessionController } from "./session.js"; import type { ShelfFrames } from "./shelf-controller.js"; import type { SessionStore } from "./sessions.js"; @@ -53,6 +56,8 @@ export interface RouterShell { sessionResized?: (id: string, cols: number, rows: number) => void; sheetSpawned: (req: string, id: string) => void; sheetComposed: (req: string, text: string) => void; + sheetScheduled: (req: string, item: ScheduledItem) => void; + sheetScheduledList: (req: string, items: ScheduledItem[]) => void; sheetOk: (req: string) => void; sheetError: (message: string, req: string | null, code: string) => void; } @@ -155,6 +160,16 @@ export function createFrameRouter(deps: FrameRouterDeps): FrameRouter { shell.sheetComposed(composed.req, composed.text); return; } + const scheduled = parseScheduled(frame); + if (scheduled) { + shell.sheetScheduled(scheduled.req, scheduled.item); + return; + } + const scheduledList = parseScheduledList(frame); + if (scheduledList) { + shell.sheetScheduledList(scheduledList.req, scheduledList.items); + return; + } // The shelf half of v1: hello gates the whole feature, the rest is // list/push/removal/file-part. Every parser here is additive, so a // host that sends none of them changes nothing. diff --git a/apps/remote/web/src/session.ts b/apps/remote/web/src/session.ts index a5a3356..ee394c9 100644 --- a/apps/remote/web/src/session.ts +++ b/apps/remote/web/src/session.ts @@ -12,7 +12,10 @@ import { buildInput, buildInputText, buildPromptDraft, + buildPromptSchedule, buildResize, + buildScheduledCancel, + buildScheduledList, decodeSnapshotData, } from "./protocol.js"; import type { OutFrame, PromptDraft } from "./protocol.js"; @@ -98,6 +101,28 @@ export class SessionController { return this.callbacks.send(buildPromptDraft(req, this.openId, draft)); } + // The Builder's scheduling path: the same draft, delivered at fireAt. + sendPromptSchedule(draft: PromptDraft, fireAt: string, req: string): boolean { + if (this.openId === null) { + return false; + } + return this.callbacks.send(buildPromptSchedule(req, this.openId, draft, fireAt)); + } + + sendScheduledList(req: string): boolean { + if (this.openId === null) { + return false; + } + return this.callbacks.send(buildScheduledList(req, this.openId)); + } + + sendScheduledCancel(scheduleId: string, req: string): boolean { + if (this.openId === null) { + return false; + } + return this.callbacks.send(buildScheduledCancel(req, this.openId, scheduleId)); + } + // The control-size path: the device asserts its PTY size for the open // session. Only the toggle calls this, and only while it is on. sendResize(cols: number, rows: number): boolean { diff --git a/apps/remote/web/src/styles.css b/apps/remote/web/src/styles.css index b21acf4..2f850d5 100644 --- a/apps/remote/web/src/styles.css +++ b/apps/remote/web/src/styles.css @@ -698,9 +698,9 @@ label > textarea { } /* The row wraps rather than clips as a last resort: the latest button - * floats over the terminal (below), so with fullscreen and both attach - * buttons up the row holds seven 44 px targets and stays one row tall on - * a phone. */ + * floats over the terminal (below) instead of taking a toolbar slot. On a + * phone the row never wraps (a second row would cost the terminal a row): + * it scrolls sideways instead (see the phone breakpoint below). */ .term-controls { display: flex; flex-wrap: wrap; @@ -758,6 +758,36 @@ label > textarea { display: none; } +/* The selection Copy button, floating over the terminal card's top-right + * corner the way #term-latest floats over the bottom-right: the stage is + * the positioning box, so it stays put while the pane scrolls beneath it. + * Icon plus the word Copy on a 44 px pill, slightly see-through until + * pointed at or focused, shown only while xterm holds a selection. */ +#term-copy-float { + position: absolute; + right: 0.5rem; + top: 0.5rem; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + height: 2.75rem; + min-width: 2.75rem; + padding: 0 0.75rem; + border-radius: 999px; + opacity: 0.85; +} + +#term-copy-float:hover, +#term-copy-float:focus-visible { + opacity: 1; +} + +#term-copy-float[hidden] { + display: none; +} + /* The per-session size control, laid out like a settings row: the switch * on the left, its words on the first line beside it and the explainer * (or the takeover notice) on the second. The label spans both lines, so @@ -902,16 +932,19 @@ label > textarea { gap: 0.25rem; } - /* Seven controls share 328 px once the paperclip and the shelf button - * are up: the tight gap keeps all seven 44 px targets on one row, and - * the Builder word steps aside so the blocks icon names it alone. */ + /* Eight 44 px controls no longer fit 328 px once the copy button + * joins the row beside the fullscreen and both attach buttons, and a + * wrapped second row would cost the terminal a row: the row stays one + * row tall and scrolls sideways instead, reaching the last button with + * a swipe. The tight gap keeps the swipe to a stub. */ .term-controls { gap: 0.15rem; + flex-wrap: nowrap; + overflow-x: auto; } /* The labelled controls go icon-only on a phone: the aria-label (and - * the title tooltip) names them, and seven 44 px targets stay inside - * 360 px without a word overflowing its button. */ + * the title tooltip) names them, so no word overflows its button. */ #term-builder span, #term-keys span { display: none; @@ -1212,6 +1245,106 @@ label > textarea { display: none; } +/* The builder's footer holds three buttons on a phone: let them wrap + * instead of pushing the sheet sideways. */ +.builder-form .sheet-actions { + flex-wrap: wrap; +} + +/* The inline scheduling row: the datetime field spans the width and the + * two actions share the row below it, wrapping on the narrowest phones. */ +.builder-schedule-row { + margin-top: 0.75rem; +} + +.builder-schedule-row[hidden] { + display: none; +} + +.builder-schedule-actions { + display: flex; + flex-wrap: wrap; + gap: var(--gap); +} + +.builder-schedule-actions button { + flex: 1 1 10rem; +} + +/* The schedule confirmation: plain text, announced through role=status. */ +.builder-status { + margin: 0.75rem 0 0; + overflow-wrap: anywhere; +} + +.builder-status[hidden] { + display: none; +} + +.builder-status:empty { + display: none; +} + +/* The session's pending schedules: one stacked card per row — the local + * fire time, the preview, and a full-width Cancel — so nothing squeezes + * at 360 px. */ +.builder-scheduled-list { + list-style: none; + margin: 0; + padding: 0; +} + +.scheduled-item { + background: var(--surface-bg-raise); + border: 1px solid var(--line); + border-radius: var(--radius-card); + padding: 0.6rem 0.85rem; + margin-bottom: var(--gap); + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.scheduled-when { + font-weight: 600; +} + +.scheduled-preview { + color: var(--muted); + overflow-wrap: anywhere; +} + +.scheduled-preview:empty::before { + content: "(no preview)"; +} + +/* The Copy sheet's text: the buffer in a native textarea, where the OS's + * own long-press selection works. Monospace, unwrapped (wrap="off" plus + * horizontal scroll, like the terminal), 16 px so iOS never zooms on + * focus. The status line keeps its row so "Copied" never moves the sheet. */ +#term-copy-text { + display: block; + width: 100%; + min-height: 40vh; + margin-top: 0.75rem; + padding: 0.75rem; + font-family: var(--font-mono); + font-size: 1rem; + white-space: pre; + overflow: auto; + resize: vertical; +} + +.term-copy-status { + min-height: 1.5em; + margin: 0.5rem 0 0; + font-size: 0.85rem; +} + +.term-copy-status:empty { + visibility: hidden; +} + /* The Files button in the session bar: hidden until hello.features * carries "shelf", its badge counting files nobody has looked at. */ #session-files { diff --git a/apps/remote/web/src/term-controls.ts b/apps/remote/web/src/term-controls.ts index 79e813f..6a104f4 100644 --- a/apps/remote/web/src/term-controls.ts +++ b/apps/remote/web/src/term-controls.ts @@ -57,7 +57,8 @@ export function termControlsHtml(): string { aria-label="Show terminal keys" title="Show or hide the terminal keys" >${icon("keyboard")}Keys - ${fullscreen} + + ${fullscreen}
    `; } @@ -128,6 +129,8 @@ export interface TermControlsDeps { onLocalSize: (cols: number, rows: number) => void; /** Open the Prompt Builder sheet; the controls row owns its button. */ onBuilder: () => void; + /** Open the Copy sheet; the controls row owns its button. */ + onCopy: () => void; } export interface TermControls { @@ -169,6 +172,7 @@ export function wireTermControls(deps: TermControlsDeps): TermControls { root.querySelector("#term-zoom-in")?.addEventListener("click", () => deps.terminalView()?.zoomIn()); root.querySelector("#term-zoom-out")?.addEventListener("click", () => deps.terminalView()?.zoomOut()); root.querySelector("#term-builder")?.addEventListener("click", () => deps.onBuilder()); + root.querySelector("#term-copy")?.addEventListener("click", () => deps.onCopy()); // The Keys switch hides the ten-key bar and nothing else: the zoom and // the builder stay put. The choice is remembered per device. const keysButton = root.querySelector("#term-keys"); diff --git a/apps/remote/web/src/term-copy.ts b/apps/remote/web/src/term-copy.ts new file mode 100644 index 0000000..a64d49c --- /dev/null +++ b/apps/remote/web/src/term-copy.ts @@ -0,0 +1,258 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copying text out of the remote terminal, on phone and desktop. A phone +// cannot select in the terminal: one finger is the scroll gesture and +// xterm draws through WebGL, so there is no selectable DOM text. The +// phone gets a sheet with the terminal's text in a native textarea, where +// the OS's own long-press selection works; a mouse drags a selection in +// xterm itself and gets a floating Copy button plus Ctrl+Shift+C. + +import { icon } from "./icons-dom.js"; +import { Sheet } from "./sheet.js"; + +/** The sheet shows the last this many logical lines of the buffer. */ +export const TERM_COPY_LINES = 2000; +/** How long "Copied" stays up, on the sheet's status line and the float. */ +export const TERM_COPY_FLASH_MS = 2000; +export const TERM_COPY_FAILURE = "Could not copy. Select the text and use your browser's Copy."; + +// Copy through the async clipboard API, falling back to a temporary +// textarea and execCommand for browsers without clipboard permission. +// True when either path copied, false when both failed; never throws. +export async function copyText(text: string): Promise { + try { + const clipboard = ( + globalThis as unknown as { navigator?: { clipboard?: { writeText?: (text: string) => Promise } } } + ).navigator?.clipboard; + if (clipboard?.writeText) { + try { + await clipboard.writeText(text); + return true; + } catch { + // Fall through to execCommand below. + } + } + } catch { + // A hostile navigator getter still gets the fallback. + } + try { + const area = document.createElement("textarea"); + area.value = text; + area.setAttribute("readonly", ""); + area.style.position = "fixed"; + area.style.top = "0"; + area.style.opacity = "0"; + document.body.appendChild(area); + try { + area.select(); + return document.execCommand("copy") === true; + } finally { + area.remove(); + } + } catch { + return false; + } +} + +// The copy shortcut: Ctrl+Shift+C on the keydown. Plain Ctrl+C is an +// interrupt for the session, never a copy, even with a selection. +export function isCopyKeydown(ev: { type: string; key: string; ctrlKey: boolean; shiftKey: boolean }): boolean { + return ev.type === "keydown" && (ev.key === "c" || ev.key === "C") && ev.ctrlKey && ev.shiftKey; +} + +/** + * xterm's custom key handler for the copy shortcut: with a selection it + * copies and returns false (the key never reaches the session), otherwise + * true (xterm processes the key exactly as without the handler). + */ +export function handleCopyKeydown( + ev: { type: string; key: string; ctrlKey: boolean; shiftKey: boolean; preventDefault?: () => void }, + getSelection: () => string, + copy: (text: string) => Promise = copyText, +): boolean { + if (isCopyKeydown(ev)) { + const selection = getSelection(); + if (selection !== "") { + // Chrome and Firefox bind this chord to their DevTools. + ev.preventDefault?.(); + void copy(selection); + return false; + } + } + return true; +} + +/** The slice of the terminal the sheet reads, once, when it opens. */ +export interface TermCopySource { + bufferText: (maxLines: number) => string; +} + +export class TermCopySheet { + private readonly sheet: Sheet; + private readonly copy: (text: string) => Promise; + private body: HTMLElement | null = null; + private statusTimer: ReturnType | null = null; + private onSelectionChange: (() => void) | null = null; + + constructor(copy: (text: string) => Promise = copyText) { + this.copy = copy; + this.sheet = new Sheet({ kind: "term-copy", title: "Copy from terminal" }, () => this.close()); + } + + get isOpen(): boolean { + return this.sheet.isOpen; + } + + open(parent: HTMLElement, source: TermCopySource): void { + if (this.sheet.isOpen) { + return; + } + // Read once: the sheet is a snapshot, it never live-updates. + const text = source.bufferText(TERM_COPY_LINES); + const body = this.sheet.mount(parent); + this.body = body; + body.innerHTML = ` + +

    +
    + + +
    `; + const area = body.querySelector("#term-copy-text"); + const status = body.querySelector("#term-copy-status"); + const copyAll = body.querySelector("#term-copy-all"); + const copySelection = body.querySelector("#term-copy-selection"); + if (!area || !status || !copyAll || !copySelection) { + return; + } + area.value = text; + // An empty buffer copies nothing. + copyAll.disabled = text === ""; + // The tail is what is wanted: open scrolled to the bottom. + area.scrollTop = area.scrollHeight; + const syncSelection = (): void => { + copySelection.disabled = area.selectionStart === area.selectionEnd; + }; + this.onSelectionChange = syncSelection; + document.addEventListener("selectionchange", syncSelection); + area.addEventListener("select", syncSelection); + const say = (message: string, flash: boolean): void => { + if (this.statusTimer !== null) { + clearTimeout(this.statusTimer); + this.statusTimer = null; + } + status.textContent = message; + if (flash) { + this.statusTimer = setTimeout(() => { + this.statusTimer = null; + if (status.isConnected) { + status.textContent = ""; + } + }, TERM_COPY_FLASH_MS); + } + }; + copyAll.addEventListener("click", () => { + void this.copy(area.value).then((ok) => { + if (status.isConnected) { + say(ok ? "Copied" : TERM_COPY_FAILURE, ok); + } + }); + }); + copySelection.addEventListener("click", () => { + const start = area.selectionStart ?? 0; + const end = area.selectionEnd ?? 0; + void this.copy(area.value.slice(start, end)).then((ok) => { + if (status.isConnected) { + say(ok ? "Copied" : TERM_COPY_FAILURE, ok); + } + }); + }); + } + + close(): void { + if (this.statusTimer !== null) { + clearTimeout(this.statusTimer); + this.statusTimer = null; + } + if (this.onSelectionChange) { + document.removeEventListener("selectionchange", this.onSelectionChange); + this.onSelectionChange = null; + } + this.body = null; + this.sheet.close(); + } +} + +/** + * The floating selection button, in the .term-stage top-right corner the + * way #term-latest floats over the bottom-right: icon plus the word Copy, + * shown only while xterm holds a drag selection. + */ +export function termCopyFloatHtml(): string { + return ` + `; +} + +/** The slice of the terminal the float reads: selection only. */ +export interface CopyFloatView { + getSelection: () => string; + onSelectionChange: (callback: () => void) => () => void; +} + +export function wireCopyFloat( + button: HTMLButtonElement, + view: CopyFloatView, + copy: (text: string) => Promise = copyText, +): () => void { + const label = button.querySelector("span"); + const previous = label?.textContent ?? "Copy"; + let timer: ReturnType | null = null; + const sync = (): void => { + button.hidden = view.getSelection() === ""; + }; + const unsubscribe = view.onSelectionChange(sync); + sync(); + const onClick = (): void => { + const selection = view.getSelection(); + if (selection === "") { + return; + } + void copy(selection).then((ok) => { + // The selection stays: copying clears nothing. + if (ok && label && label.isConnected) { + label.textContent = "Copied"; + if (timer !== null) { + clearTimeout(timer); + } + timer = setTimeout(() => { + timer = null; + if (label.isConnected) { + label.textContent = previous; + } + }, TERM_COPY_FLASH_MS); + } + }); + }; + button.addEventListener("click", onClick); + return () => { + unsubscribe(); + button.removeEventListener("click", onClick); + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + }; +} diff --git a/apps/remote/web/src/terminal.ts b/apps/remote/web/src/terminal.ts index 07a4fea..ec36b84 100644 --- a/apps/remote/web/src/terminal.ts +++ b/apps/remote/web/src/terminal.ts @@ -29,6 +29,7 @@ import "@xterm/xterm/css/xterm.css"; import { Terminal } from "@xterm/xterm"; +import { handleCopyKeydown } from "./term-copy.js"; import { TERMINAL_FONT_FAMILY, TERMINAL_FONT_SIZE, @@ -56,6 +57,12 @@ export interface TerminalHandle { onScroll: (callback: () => void) => () => void; /** Fires once the terminal is open (and the WebGL attempt settled). */ onReady: (callback: () => void) => () => void; + /** The current drag selection, or "" when there is none. */ + getSelection: () => string; + /** Fires when the drag selection changes. */ + onSelectionChange: (callback: () => void) => () => void; + /** The last maxLines logical lines of the active buffer as plain text. */ + bufferText: (maxLines: number) => string; /** Resize the local terminal to cols × rows. Absent means a fixed size. */ resize?: (cols: number, rows: number) => void; /** @@ -258,6 +265,46 @@ export function shouldContinueFling(velocity: number): boolean { return Math.abs(velocity) >= FLING_STOP_PX_PER_MS; } +// The slice of xterm's buffer bufferText reads: structural, so the real +// IBuffer and the unit-test fakes both fit. +export interface BufferLineLike { + readonly isWrapped: boolean; + translateToString(trimRight?: boolean): string; +} + +export interface BufferLike { + readonly length: number; + getLine(y: number): BufferLineLike | undefined; +} + +// The buffer as copyable text: a line whose isWrapped is true continues +// the logical line above it (a wrapped line copies as one line), each row +// is right-trimmed, trailing blank lines are dropped, and only the last +// maxLines logical lines are kept. "" when maxLines is not positive. +export function bufferLinesToText(buffer: BufferLike, maxLines: number): string { + if (!(maxLines > 0)) { + return ""; + } + const logical: string[] = []; + for (let y = 0; y < buffer.length; y += 1) { + const line = buffer.getLine(y); + if (!line) { + continue; + } + const text = line.translateToString(true); + if (line.isWrapped && logical.length > 0) { + logical[logical.length - 1] += text; + } else { + logical.push(text); + } + } + let end = logical.length; + while (end > 0 && logical[end - 1] === "") { + end -= 1; + } + return logical.slice(0, end).slice(-maxLines).join("\n"); +} + export function createXtermHandle( host: HTMLElement, cols: number, @@ -287,6 +334,9 @@ export function createXtermHandle( // After open(): the addon needs the screen's canvas. A failure or a // context loss leaves xterm's DOM renderer running. attachWebgl(terminal); + // Ctrl+Shift+C with a selection copies instead of reaching the session; + // every other key (plain Ctrl+C included) still goes through. + terminal.attachCustomKeyEventHandler((ev) => handleCopyKeydown(ev, () => terminal.getSelection())); // Test hook: the fake host's e2e reads the active buffer through it — // buffer reads work under WebGL too, where the DOM carries no row text. (host as unknown as { __xterm?: Terminal }).__xterm = terminal; @@ -316,6 +366,12 @@ export function createXtermHandle( callback(); return () => {}; }, + getSelection: () => terminal.getSelection(), + onSelectionChange: (callback) => { + const sub = terminal.onSelectionChange(() => callback()); + return () => sub.dispose(); + }, + bufferText: (maxLines) => bufferLinesToText(terminal.buffer.active, maxLines), resize: (nextCols, nextRows) => terminal.resize(nextCols, nextRows), baseScale: TERMINAL_FONT_SIZE / fontSize, dispose: () => terminal.dispose(), @@ -356,6 +412,7 @@ export class TerminalView { private readyPromise: Promise = Promise.resolve(); private resolveReady: (() => void) | null = null; private pendingWrites: Uint8Array[] = []; + private selectionListeners = new Set<() => void>(); constructor(factory: TerminalFactory, callbacks: TerminalViewCallbacks) { this.factory = factory; @@ -487,6 +544,27 @@ export class TerminalView { this.handle?.focus(); } + /** The current drag selection, or "" while closed or unselected. */ + getSelection(): string { + return this.handle?.getSelection() ?? ""; + } + + /** + * Fires when the drag selection changes. Subscribing before xterm opens + * is fine: the view forwards to the handle once it exists. + */ + onSelectionChange(callback: () => void): () => void { + this.selectionListeners.add(callback); + return () => { + this.selectionListeners.delete(callback); + }; + } + + /** The last maxLines logical lines of the active buffer, or "" while closed. */ + bufferText(maxLines: number): string { + return this.handle?.bufferText(maxLines) ?? ""; + } + setZoom(zoom: number): void { this.zoom = clampZoom(zoom); this.refit(); @@ -681,6 +759,7 @@ export class TerminalView { this.following = true; this.alternate = false; this.pendingWrites.length = 0; + this.selectionListeners.clear(); } private openTerminal(): void { @@ -694,6 +773,7 @@ export class TerminalView { this.handle = handle; handle.onUserInput((text) => this.callbacks.onUserInput(text)); handle.onScroll(() => this.refreshFollow()); + handle.onSelectionChange(() => this.emitSelectionChange()); // Wheel and touch scrolling never fire onScroll (xterm suppresses it on // the viewport path), so follow-bottom watches the viewport itself. host.querySelector(".xterm-viewport")?.addEventListener("scroll", () => this.refreshFollow()); @@ -750,6 +830,12 @@ export class TerminalView { this.callbacks.onFollowChange?.({ following }); } + private emitSelectionChange(): void { + for (const listener of [...this.selectionListeners]) { + listener(); + } + } + private onTouchStart(ev: TouchEvent): void { this.stopFling(); if (ev.touches.length === 2) { diff --git a/apps/remote/web/test/app.test.ts b/apps/remote/web/test/app.test.ts index 04cbadc..b97ed3e 100644 --- a/apps/remote/web/test/app.test.ts +++ b/apps/remote/web/test/app.test.ts @@ -66,6 +66,9 @@ class FakeHandle implements TerminalHandle { callback(); return () => {}; }; + getSelection = (): string => ""; + onSelectionChange = (): (() => void) => () => {}; + bufferText = (): string => ""; dispose = (): void => { this.disposed = true; }; @@ -198,6 +201,9 @@ describe("createShell", () => { draftSends.push({ draft, req }); return sendable; }, + onSchedule: () => sendable, + onScheduledList: () => sendable, + onScheduledCancel: () => sendable, onGraphsRequest: () => {}, onWatchGraph: () => {}, onUnwatchGraph: () => {}, @@ -509,6 +515,63 @@ describe("createShell", () => { expect(shift.getAttribute("aria-pressed")).toBe("false"); }); + it("forwards a focus report unchanged, leaving the latch armed", () => { + shell.showSession("s1", "build"); + shell.ensureTerminal("s1", 80, 24); + const ctrl = query('#keybar .key[data-key="ctrl"]'); + ctrl.click(); + expect(ctrl.getAttribute("aria-pressed")).toBe("true"); + // The focus-in report from tapping the terminal is xterm's own, not + // typing: it goes out as-is and spends nothing. + handles[0]?.emitInput("\x1b[I"); + expect(calls.inputs.map((bytes) => [...bytes])).toEqual([[0x1b, 0x5b, 0x49]]); + expect(ctrl.getAttribute("aria-pressed")).toBe("true"); + // The armed latch still applies to the next typed key. + handles[0]?.emitInput("c"); + expect(calls.inputs.map((bytes) => [...bytes])).toEqual([ + [0x1b, 0x5b, 0x49], + [0x03], + ]); + expect(ctrl.getAttribute("aria-pressed")).toBe("false"); + }); + + it("sends typed c as 0x03 with Ctrl latched, then releases", () => { + shell.showSession("s1", "build"); + shell.ensureTerminal("s1", 80, 24); + const ctrl = query('#keybar .key[data-key="ctrl"]'); + ctrl.click(); + handles[0]?.emitInput("c"); + expect(calls.inputs.map((bytes) => [...bytes])).toEqual([[0x03]]); + expect(ctrl.getAttribute("aria-pressed")).toBe("false"); + }); + + it("sends typed a as A with Shift latched, then releases", () => { + shell.showSession("s1", "build"); + shell.ensureTerminal("s1", 80, 24); + const shift = query('#keybar .key[data-key="shift"]'); + shift.click(); + handles[0]?.emitInput("a"); + expect(calls.inputs.map((bytes) => [...bytes])).toEqual([[0x41]]); + expect(shift.getAttribute("aria-pressed")).toBe("false"); + }); + + it("focuses the terminal when arming a latch, never when releasing", () => { + shell.showSession("s1", "build"); + shell.ensureTerminal("s1", 80, 24); + const ctrl = query('#keybar .key[data-key="ctrl"]'); + const shift = query('#keybar .key[data-key="shift"]'); + const focuses = () => handles[0]?.focuses ?? -1; + ctrl.click(); + expect(focuses()).toBe(1); + // Releasing the latch touches nothing. + ctrl.click(); + expect(focuses()).toBe(1); + shift.click(); + expect(focuses()).toBe(2); + shift.click(); + expect(focuses()).toBe(2); + }); + it("sends Ctrl+Backspace as BS and releases the latch", () => { shell.showSession("s1", "build"); const ctrl = query('#keybar .key[data-key="ctrl"]'); diff --git a/apps/remote/web/test/builder.test.ts b/apps/remote/web/test/builder.test.ts index 29c7e51..6fa1192 100644 --- a/apps/remote/web/test/builder.test.ts +++ b/apps/remote/web/test/builder.test.ts @@ -41,6 +41,9 @@ describe("Builder", () => { let storage: FakeStorage; let composed: { draft: PromptDraft; req: string }[]; let sends: { draft: PromptDraft; req: string }[]; + let schedules: { draft: PromptDraft; fireAt: string; req: string }[]; + let lists: string[]; + let cancels: { scheduleId: string; req: string }[]; let connected: boolean; const query = (selector: string): T => { @@ -107,6 +110,9 @@ describe("Builder", () => { storage = new FakeStorage(); composed = []; sends = []; + schedules = []; + lists = []; + cancels = []; connected = true; builder = new Builder( catalog, @@ -119,6 +125,18 @@ describe("Builder", () => { sends.push({ draft, req }); return connected; }, + onSchedule: (draft, fireAt, req) => { + schedules.push({ draft, fireAt, req }); + return connected; + }, + onScheduledList: (req) => { + lists.push(req); + return connected; + }, + onScheduledCancel: (scheduleId, req) => { + cancels.push({ scheduleId, req }); + return connected; + }, }, storage, ); @@ -440,4 +458,175 @@ describe("Builder", () => { select("#builder-preset", ""); expect(query("#builder-preset-preview").textContent).toBe(""); }); + + const localInputValue = (date: Date): string => { + const pad = (n: number): string => String(n).padStart(2, "0"); + return ( + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + + `T${pad(date.getHours())}:${pad(date.getMinutes())}` + ); + }; + + const openScheduleRow = (): void => { + query("#builder-schedule").click(); + }; + + const confirmSchedule = (value: string): void => { + query("#builder-schedule-at").value = value; + query("#builder-schedule-confirm").click(); + }; + + it("loads the pending schedules when the sheet opens", () => { + open(); + expect(lists).toHaveLength(1); + expect(root.querySelector("#builder-scheduled")).toBeNull(); + expect(builder.handleScheduledList("other", [])).toBe(false); + expect( + builder.handleScheduledList(lists[0] ?? "", [ + { id: "ss-1", fireAt: "2030-01-01T09:00:00.000Z", preview: "review the diff" }, + ]), + ).toBe(true); + expect(query("#builder-scheduled h3").textContent).toBe("Scheduled for this session"); + expect(root.querySelectorAll(".scheduled-item")).toHaveLength(1); + }); + + it("reveals the schedule row preset to one hour from now", () => { + open(); + expect(query("#builder-schedule-row").hidden).toBe(true); + openScheduleRow(); + const input = query("#builder-schedule-at"); + expect(query("#builder-schedule-row").hidden).toBe(false); + expect(new Date(input.value).getTime() - Date.now()).toBeGreaterThan(59 * 60 * 1000); + expect(new Date(input.value).getTime() - Date.now()).toBeLessThan(61 * 60 * 1000); + expect(Date.now() - new Date(input.getAttribute("min") ?? "").getTime()).toBeLessThan(120 * 1000); + query("#builder-schedule-dismiss").click(); + expect(query("#builder-schedule-row").hidden).toBe(true); + expect(schedules).toHaveLength(0); + }); + + it("schedules the draft, clears it, and refreshes the list on success", () => { + open(); + setKind(0, "goal"); + setContent(".section-content", "water the plants"); + toggle("tdd"); + openScheduleRow(); + const value = localInputValue(new Date(Date.now() + 2 * 60 * 60 * 1000)); + confirmSchedule(value); + expect(schedules).toHaveLength(1); + expect(schedules[0]?.draft).toEqual({ + sections: [{ title: "goal", content: "water the plants" }], + skills: ["tdd"], + }); + expect(schedules[0]?.fireAt).toBe(new Date(value).toISOString()); + const req = schedules[0]?.req ?? ""; + expect(builder.handleScheduled("other", { id: "ss-1", fireAt: value, preview: "" })).toBe(false); + expect( + builder.handleScheduled(req, { + id: "ss-1", + fireAt: schedules[0]?.fireAt ?? "", + preview: "water the plants", + }), + ).toBe(true); + // The sheet stays open with a fresh default section and a status line. + expect(builder.isOpen).toBe(true); + expect(query("#builder-schedule-row").hidden).toBe(true); + const status = query("#builder-schedule-status"); + expect(status.getAttribute("role")).toBe("status"); + expect(status.textContent).toBe( + `Scheduled for ${new Date(schedules[0]?.fireAt ?? "").toLocaleString()}`, + ); + expect(kinds()).toEqual(["instruction"]); + expect(contents()).toEqual([""]); + expect(storage.getItem(DRAFT_STORAGE_KEY)).toBeNull(); + expect(lists).toHaveLength(2); + }); + + it("refuses an empty or past time without sending a frame", () => { + open(); + setContent(".section-content", "keep me"); + openScheduleRow(); + confirmSchedule(""); + expect(schedules).toHaveLength(0); + expect(query("#builder-error").textContent).toBe("Choose a date and time first."); + confirmSchedule(localInputValue(new Date(Date.now() - 60 * 60 * 1000))); + expect(schedules).toHaveLength(0); + expect(query("#builder-error").textContent).toBe("Pick a time in the future."); + expect(query(".section-content").value).toBe("keep me"); + }); + + it("keeps the draft when scheduling fails", () => { + open(); + setContent(".section-content", "keep me"); + openScheduleRow(); + confirmSchedule(localInputValue(new Date(Date.now() + 60 * 60 * 1000))); + expect(schedules).toHaveLength(1); + expect(builder.handleError("host is gone", schedules[0]?.req ?? "", "delivery_failed")).toBe(true); + expect(query("#builder-error").textContent).toBe("host is gone"); + expect(builder.isOpen).toBe(true); + expect(query(".section-content").value).toBe("keep me"); + expect(stored()?.sections).toHaveLength(1); + }); + + it("shows an old host's list error as the sheet error, never a crash", () => { + open(); + expect(builder.handleError("unknown frame: scheduled_list", lists[0] ?? "", "unknown_frame")).toBe( + true, + ); + expect(query("#builder-error").textContent).toBe("unknown frame: scheduled_list"); + expect(root.querySelector("#builder-scheduled")).toBeNull(); + expect(builder.handleError("late", "no-such-req", "internal")).toBe(false); + }); + + it("renders scheduled previews as text, never markup", () => { + open(); + builder.handleScheduledList(lists[0] ?? "", [ + { id: "ss-9", fireAt: "2030-06-01T12:00:00.000Z", preview: '' }, + ]); + const item = query(".scheduled-item"); + expect(item.querySelector("img")).toBeNull(); + expect(item.querySelector(".scheduled-preview")?.textContent).toBe(''); + expect(item.querySelector(".scheduled-when")?.textContent).toBe( + new Date("2030-06-01T12:00:00.000Z").toLocaleString(), + ); + expect(item.querySelector("button")?.dataset.scheduleId).toBe("ss-9"); + }); + + it("cancels a schedule and drops its row on the ok", () => { + open(); + builder.handleScheduledList(lists[0] ?? "", [ + { id: "ss-1", fireAt: "2030-01-01T09:00:00.000Z", preview: "first" }, + { id: "ss-2", fireAt: "2030-01-02T09:00:00.000Z", preview: "second" }, + ]); + expect(root.querySelectorAll(".scheduled-item")).toHaveLength(2); + query('button[data-schedule-id="ss-1"]').click(); + expect(cancels).toEqual([{ scheduleId: "ss-1", req: cancels[0]?.req ?? "" }]); + expect(builder.handleOk("other")).toBe(false); + expect(root.querySelectorAll(".scheduled-item")).toHaveLength(2); + expect(builder.handleOk(cancels[0]?.req ?? "")).toBe(true); + expect(root.querySelectorAll(".scheduled-item")).toHaveLength(1); + expect(query(".scheduled-preview").textContent).toBe("second"); + expect(lists).toHaveLength(2); + }); + + it("keeps the row when cancelling fails", () => { + open(); + builder.handleScheduledList(lists[0] ?? "", [ + { id: "ss-1", fireAt: "2030-01-01T09:00:00.000Z", preview: "first" }, + ]); + query('button[data-schedule-id="ss-1"]').click(); + expect(builder.handleError("host is gone", cancels[0]?.req ?? "", "delivery_failed")).toBe(true); + expect(query("#builder-error").textContent).toBe("host is gone"); + expect(root.querySelectorAll(".scheduled-item")).toHaveLength(1); + }); + + it("says so when scheduling without a connection", () => { + connected = false; + open(); + expect(query("#builder-error").textContent).toBe("Not connected"); + setContent(".section-content", "words"); + openScheduleRow(); + confirmSchedule(localInputValue(new Date(Date.now() + 60 * 60 * 1000))); + expect(schedules).toHaveLength(1); + expect(query("#builder-error").textContent).toBe("Not connected"); + }); }); diff --git a/apps/remote/web/test/bundle-size.test.ts b/apps/remote/web/test/bundle-size.test.ts index 0978dc4..b7d9d80 100644 --- a/apps/remote/web/test/bundle-size.test.ts +++ b/apps/remote/web/test/bundle-size.test.ts @@ -132,6 +132,12 @@ const ARC_BOUNDS_BUDGET = 512; * Merged, main is 410 B gzip over the sum of those budgets, with no new code. */ const MERGE_BUDGET = 512; +/** + * Allowance for scheduling from the Builder, copying terminal text and the + * latch fixes: measured +2 657 B gzip over the allowance before them, added + * with the features since none of the three fit the headroom left. + */ +const SESSION_TOOLS_BUDGET = 3_072; /** * Allowance for the control-size fix (the panel-fitted size, the owner * scale, the in-place local resize and the debounced re-assert while the @@ -272,6 +278,7 @@ describe("the remote's bundle", () => { SHELF_DELETE_BUDGET + ARC_BOUNDS_BUDGET + MERGE_BUDGET + + SESSION_TOOLS_BUDGET + CONTROL_SIZE_BUDGET + RESIZED_BUDGET + GUEST_RETURN_BUDGET, diff --git a/apps/remote/web/test/copy.spec.ts b/apps/remote/web/test/copy.spec.ts new file mode 100644 index 0000000..f4d9876 --- /dev/null +++ b/apps/remote/web/test/copy.spec.ts @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copying terminal text out on a phone: the fake host prints known lines, +// the Copy sheet shows the buffer in a native textarea, Copy all lands on +// the clipboard, and the controls row still fits one row at 360x640 with +// no sideways scroll. +import { expect, test } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 360, height: 640 }); + await page.goto("/fake.html"); + await page.locator('[data-session="smoke"]').click(); + await expect.poll(() => page.evaluate(() => window.__fake.terminalText())).toContain("hello from the host"); +}); + +test("copies the buffer through the sheet onto the clipboard", async ({ page, context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await page.evaluate(() => window.__fake.pushOut("copy-me one\r\ncopy-me two\r\n")); + // The real xterm buffer holds the lines before the sheet snapshots it. + await page.waitForFunction( + () => (window.__fake.bufferState?.()?.rows.join("\n") ?? "").includes("copy-me two"), + ); + await page.locator("#term-copy").click(); + const text = page.locator("#term-copy-text"); + await expect(text).toBeVisible(); + expect(await text.inputValue()).toContain("copy-me one"); + expect(await text.inputValue()).toContain("copy-me two"); + await page.locator("#term-copy-all").click(); + await expect(page.locator("#term-copy-status")).toHaveText("Copied"); + expect(await page.evaluate(() => navigator.clipboard.readText())).toContain("copy-me two"); + await page.locator("#term-copy-close").click(); + await expect(text).toBeHidden(); + // One controls row at 360x640: the Copy button did not wrap it, and the + // page never scrolls sideways. + const layout = await page.evaluate(() => { + const doc = document.scrollingElement ?? document.documentElement; + const controls = document.querySelector(".term-controls") as HTMLElement; + const tops = new Set( + [...controls.querySelectorAll("button")] + .filter((el) => (el as HTMLElement).offsetParent !== null) + .map((el) => Math.round(el.getBoundingClientRect().top)), + ); + return { pageOverflow: doc.scrollWidth - doc.clientWidth, rows: tops.size }; + }); + expect(layout.pageOverflow).toBeLessThanOrEqual(1); + expect(layout.rows).toBe(1); +}); diff --git a/apps/remote/web/test/guest-views.test.ts b/apps/remote/web/test/guest-views.test.ts index 08b9899..c3310d0 100644 --- a/apps/remote/web/test/guest-views.test.ts +++ b/apps/remote/web/test/guest-views.test.ts @@ -36,6 +36,9 @@ class FakeHandle implements TerminalHandle { callback(); return () => {}; }; + getSelection = (): string => ""; + onSelectionChange = (): (() => void) => () => {}; + bufferText = (): string => ""; resizes: { cols: number; rows: number }[] = []; resize = (cols: number, rows: number): void => { this.resizes.push({ cols, rows }); @@ -300,6 +303,46 @@ describe("guest session view", () => { ); }); + it("forwards a focus report unchanged, leaving the latch armed", () => { + const { root, view, handles, inputs } = setup(); + view.showSession("s1", "build"); + view.ensureTerminal("s1", 80, 24); + root.querySelector('#keybar button[data-key="ctrl"]')?.click(); + handles[0]?.emitInput("\x1b[I"); + expect(Array.from(inputs, (bytes) => Array.from(bytes))).toEqual([[0x1b, 0x5b, 0x49]]); + expect(root.querySelector('#keybar button[data-key="ctrl"]')?.getAttribute("aria-pressed")).toBe( + "true", + ); + handles[0]?.emitInput("c"); + expect(Array.from(inputs, (bytes) => Array.from(bytes))).toEqual([ + [0x1b, 0x5b, 0x49], + [0x03], + ]); + }); + + it("sends typed a as A with Shift latched, then releases", () => { + const { root, view, handles, inputs } = setup(); + view.showSession("s1", "build"); + view.ensureTerminal("s1", 80, 24); + root.querySelector('#keybar button[data-key="shift"]')?.click(); + handles[0]?.emitInput("a"); + expect(Array.from(inputs, (bytes) => Array.from(bytes))).toEqual([[0x41]]); + expect(root.querySelector('#keybar button[data-key="shift"]')?.getAttribute("aria-pressed")).toBe( + "false", + ); + }); + + it("focuses the terminal when arming a latch, never when releasing", () => { + const { root, view, handles } = setup(); + view.showSession("s1", "build"); + view.ensureTerminal("s1", 80, 24); + const ctrl = root.querySelector('#keybar button[data-key="ctrl"]'); + ctrl?.click(); + expect(handles[0]?.focuses).toBe(1); + ctrl?.click(); + expect(handles[0]?.focuses).toBe(1); + }); + it("focuses the terminal once on attach under a fine pointer", async () => { const { view, handles } = setup(); const realMatchMedia = globalThis.matchMedia; diff --git a/apps/remote/web/test/guest.test.ts b/apps/remote/web/test/guest.test.ts index fe31c5e..1967963 100644 --- a/apps/remote/web/test/guest.test.ts +++ b/apps/remote/web/test/guest.test.ts @@ -408,6 +408,9 @@ class FakeGuestHandle implements TerminalHandle { callback(); return () => {}; }; + getSelection = (): string => ""; + onSelectionChange = (): (() => void) => () => {}; + bufferText = (): string => ""; dispose = (): void => {}; emitInput(text: string): void { diff --git a/apps/remote/web/test/keys.test.ts b/apps/remote/web/test/keys.test.ts index 082e86e..e4e61c8 100644 --- a/apps/remote/web/test/keys.test.ts +++ b/apps/remote/web/test/keys.test.ts @@ -101,6 +101,20 @@ describe("renderKeyBar", () => { } }); + it("keeps mousedown from stealing xterm's focus, without touching the click", () => { + const bar = document.createElement("div"); + document.body.appendChild(bar); + const pressed: KeyDef[] = []; + renderKeyBar(bar, (def) => pressed.push(def)); + const tab = bar.querySelector('button.key[data-key="tab"]'); + const mousedown = new MouseEvent("mousedown", { bubbles: true, cancelable: true }); + tab?.dispatchEvent(mousedown); + expect(mousedown.defaultPrevented).toBe(true); + // The click still lands: touchstart and pointerdown are untouched. + tab?.click(); + expect(pressed.map((def) => def.id)).toEqual(["tab"]); + }); + it("marks the latches unpressed until the shell updates them", () => { const bar = document.createElement("div"); document.body.appendChild(bar); diff --git a/apps/remote/web/test/latch-keyboard.spec.ts b/apps/remote/web/test/latch-keyboard.spec.ts new file mode 100644 index 0000000..55a77d1 --- /dev/null +++ b/apps/remote/web/test/latch-keyboard.spec.ts @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +// The Ctrl/Shift latch keys on a phone, against the fake host: tapping +// Ctrl arms the latch and focuses the terminal (the keyboard opens inside +// the tap gesture), the focus reports from that focus never spend the +// latch, and the next typed key goes out with the modifier applied. +import { expect, test } from "@playwright/test"; + +test.use({ hasTouch: true }); + +async function terminalText(page: import("@playwright/test").Page): Promise { + return page.evaluate(() => window.__fake.terminalText()); +} + +// Every input frame the host received, decoded to bytes. +async function inputBytes(page: import("@playwright/test").Page): Promise { + return page.evaluate(() => + window.__fake + .sent() + .filter((frame) => (frame as { t?: string }).t === "input") + .map((frame) => { + const bin = atob((frame as { data: string }).data); + return Array.from(bin, (ch) => ch.charCodeAt(0)); + }), + ); +} + +async function xtermTextareaIsFocused(page: import("@playwright/test").Page): Promise { + return page.evaluate(() => { + const active = document.activeElement; + return active instanceof HTMLTextAreaElement && active.classList.contains("xterm-helper-textarea"); + }); +} + +test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 360, height: 640 }); + await page.goto("/fake.html"); + await page.locator('[data-session="smoke"]').click(); + await expect.poll(() => terminalText(page)).toContain("hello from the host"); + // A fine pointer hides the key bar by default; pin it visible the way a + // touch device shows it. + if (!(await page.locator("#keybar").isVisible())) { + await page.locator("#term-keys").click(); + } +}); + +test("ctrl latch survives the focus report and applies to the next key", async ({ page }) => { + const ctrl = page.locator('#keybar .key[data-key="ctrl"]'); + await ctrl.tap(); + await expect(ctrl).toHaveAttribute("aria-pressed", "true"); + // The tap focused xterm's hidden textarea: the phone keyboard opens. + await expect.poll(() => xtermTextareaIsFocused(page)).toBe(true); + // A TUI with focus tracking turns every blur/focus into a report + // through the same onData as typed text. + await page.evaluate(() => window.__fake.pushOut("\x1b[?1004h")); + await expect.poll(() => terminalText(page)).toContain("\x1b[?1004h"); + // xterm applies the mode off its write queue; the blur below must land + // after that, or no report is generated at all. + await page.waitForTimeout(300); + await page.evaluate(() => window.__fake.clearSent()); + await page.evaluate(() => { + const area = document.querySelector(".xterm-helper-textarea") as HTMLTextAreaElement; + area.blur(); + area.focus(); + }); + // Both reports reached the host unchanged, and the latch stayed armed. + await expect.poll(() => inputBytes(page)).toEqual([ + [0x1b, 0x5b, 0x4f], + [0x1b, 0x5b, 0x49], + ]); + await expect(ctrl).toHaveAttribute("aria-pressed", "true"); + // The armed latch still applies to the next typed key. + await page.keyboard.type("c"); + await expect.poll(() => inputBytes(page)).toEqual([ + [0x1b, 0x5b, 0x4f], + [0x1b, 0x5b, 0x49], + [0x03], + ]); + await expect(ctrl).toHaveAttribute("aria-pressed", "false"); +}); diff --git a/apps/remote/web/test/modifiers.test.ts b/apps/remote/web/test/modifiers.test.ts index 6547b9a..5884022 100644 --- a/apps/remote/web/test/modifiers.test.ts +++ b/apps/remote/web/test/modifiers.test.ts @@ -1,6 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from "vitest"; -import { keyBytesWithModifiers, ModifierLatches, textBytesWithModifiers } from "../src/modifiers.js"; +import { + isTerminalReport, + keyBytesWithModifiers, + ModifierLatches, + textBytesWithModifiers, +} from "../src/modifiers.js"; const CTRL = { ctrl: true, shift: false }; const SHIFT = { ctrl: false, shift: true }; @@ -103,9 +108,62 @@ describe("textBytesWithModifiers", () => { expect([...textBytesWithModifiers("[", BOTH)]).toEqual([0x1b]); }); + it("uppercases a single lowercase letter with Shift latched", () => { + expect([...textBytesWithModifiers("a", SHIFT)]).toEqual([0x41]); + expect([...textBytesWithModifiers("z", SHIFT)]).toEqual([0x5a]); + // Already shifted, longer text and non-letters go out unchanged. + expect([...textBytesWithModifiers("A", SHIFT)]).toEqual([0x41]); + expect([...textBytesWithModifiers("hi", SHIFT)]).toEqual([0x68, 0x69]); + expect([...textBytesWithModifiers("5", SHIFT)]).toEqual([0x35]); + }); + + it("keeps Ctrl's precedence over Shift for typed text", () => { + expect([...textBytesWithModifiers("c", BOTH)]).toEqual([0x03]); + expect([...textBytesWithModifiers("[", SHIFT)]).toEqual([0x5b]); + }); + it("leaves other latched input unchanged for the caller to release", () => { expect([...textBytesWithModifiers("5", CTRL)]).toEqual([0x35]); - expect([...textBytesWithModifiers("c", SHIFT)]).toEqual([0x63]); expect([...textBytesWithModifiers("hi", CTRL)]).toEqual([0x68, 0x69]); }); }); + +describe("isTerminalReport", () => { + it("matches the focus reports", () => { + expect(isTerminalReport("\x1b[I")).toBe(true); + expect(isTerminalReport("\x1b[O")).toBe(true); + }); + + it("matches cursor position replies", () => { + expect(isTerminalReport("\x1b[24;80R")).toBe(true); + expect(isTerminalReport("\x1b[1;1R")).toBe(true); + }); + + it("matches device attribute replies", () => { + expect(isTerminalReport("\x1b[?1;2c")).toBe(true); + expect(isTerminalReport("\x1b[?62c")).toBe(true); + expect(isTerminalReport("\x1b[>0;95;0c")).toBe(true); + }); + + it("matches SGR mouse reports, press and release", () => { + expect(isTerminalReport("\x1b[<0;10;20M")).toBe(true); + expect(isTerminalReport("\x1b[<0;10;20m")).toBe(true); + expect(isTerminalReport("\x1b[<64;5;7M")).toBe(true); + }); + + it("matches X10 mouse reports, three bytes after ESC[M", () => { + expect(isTerminalReport("\x1b[M !\"")).toBe(true); + expect(isTerminalReport("\x1b[M#(+")).toBe(true); + }); + + it("rejects typed input: a lone ESC, arrows and plain text", () => { + expect(isTerminalReport("\x1b")).toBe(false); + expect(isTerminalReport("\x1b[A")).toBe(false); + expect(isTerminalReport("\x1b[1;5A")).toBe(false); + expect(isTerminalReport("\x1b[Z")).toBe(false); + expect(isTerminalReport("c")).toBe(false); + expect(isTerminalReport("hello")).toBe(false); + expect(isTerminalReport("")).toBe(false); + expect(isTerminalReport("\x1b[M !")).toBe(false); + }); +}); diff --git a/apps/remote/web/test/primary-buttons.test.ts b/apps/remote/web/test/primary-buttons.test.ts index 6bfe2db..f825a46 100644 --- a/apps/remote/web/test/primary-buttons.test.ts +++ b/apps/remote/web/test/primary-buttons.test.ts @@ -80,7 +80,13 @@ describe("primary buttons", () => { builderCatalog.replace(CATALOG); const builder = new Builder( builderCatalog, - { onCompose: () => true, onSend: () => true }, + { + onCompose: () => true, + onSend: () => true, + onSchedule: () => true, + onScheduledList: () => true, + onScheduledCancel: () => true, + }, new FakeStorage(), ); builder.open(builderRoot); diff --git a/apps/remote/web/test/protocol.test.ts b/apps/remote/web/test/protocol.test.ts index 037d707..03b4e23 100644 --- a/apps/remote/web/test/protocol.test.ts +++ b/apps/remote/web/test/protocol.test.ts @@ -13,7 +13,10 @@ import { buildInputText, buildList, buildPromptDraft, + buildPromptSchedule, buildResize, + buildScheduledCancel, + buildScheduledList, buildShelfDelete, buildShelfGet, buildShelfList, @@ -34,6 +37,8 @@ import { parseOk, parseOut, parseResized, + parseScheduled, + parseScheduledList, parseSessionAdded, parseSessionItem, parseSessionRemoved, @@ -115,6 +120,24 @@ describe("builders", () => { id: "s1", draft, }); + expect(JSON.parse(buildPromptSchedule("r10", "s1", draft, "2030-01-01T09:00:00.000Z"))).toEqual({ + t: "prompt_schedule", + req: "r10", + id: "s1", + draft, + fire_at: "2030-01-01T09:00:00.000Z", + }); + expect(JSON.parse(buildScheduledList("r11", "s1"))).toEqual({ + t: "scheduled_list", + req: "r11", + id: "s1", + }); + expect(JSON.parse(buildScheduledCancel("r12", "s1", "ss-3"))).toEqual({ + t: "scheduled_cancel", + req: "r12", + id: "s1", + schedule_id: "ss-3", + }); }); it("base64-encodes input bytes", () => { @@ -296,6 +319,37 @@ describe("frame parsers", () => { expect(parseComposed({ t: "ok", req: "r2" })).toBeNull(); }); + it("parses scheduled and scheduled_list", () => { + const item = { id: "ss-1", fire_at: "2030-01-01T09:00:00.000Z", preview: "review it" }; + expect(parseScheduled({ t: "scheduled", req: "r1", item })).toEqual({ + req: "r1", + item: { id: "ss-1", fireAt: "2030-01-01T09:00:00.000Z", preview: "review it" }, + }); + expect(parseScheduled({ t: "scheduled", req: "r1" })).toBeNull(); + expect(parseScheduled({ t: "scheduled", req: "r1", item: { id: "", fire_at: "", preview: 7 } })).toBeNull(); + expect(parseScheduled({ t: "ok", req: "r1" })).toBeNull(); + expect( + parseScheduledList({ t: "scheduled_list", req: "r2", id: "s1", items: [item] }), + ).toEqual({ + req: "r2", + id: "s1", + items: [{ id: "ss-1", fireAt: "2030-01-01T09:00:00.000Z", preview: "review it" }], + }); + // Malformed entries are dropped, like a catalog frame's. + expect( + parseScheduledList({ + t: "scheduled_list", + req: "r2", + id: "s1", + items: [item, { id: "", fire_at: "x", preview: "bad" }, null], + })?.items, + ).toHaveLength(1); + expect(parseScheduledList({ t: "scheduled_list", req: "r2", id: "s1" })).toBeNull(); + expect(parseScheduledList({ t: "scheduled_list", req: "r2", id: "", items: [] })).toBeNull(); + expect(parseScheduledList({ t: "scheduled_list", req: "r2", id: "s1", items: {} })).toBeNull(); + expect(parseScheduledList({ t: "shelf", req: "r2", items: [] })).toBeNull(); + }); + it("reads the harness yolo flag defensively", () => { expect( parseCatalog({ diff --git a/apps/remote/web/test/router.test.ts b/apps/remote/web/test/router.test.ts index 9f05ba7..0308555 100644 --- a/apps/remote/web/test/router.test.ts +++ b/apps/remote/web/test/router.test.ts @@ -28,6 +28,8 @@ interface Harness { exits: number[]; spawned: { req: string; id: string }[]; composed: { req: string; text: string }[]; + scheduled: { req: string; item: unknown }[]; + scheduledLists: { req: string; items: unknown[] }[]; sheetOks: string[]; sheetErrors: { message: string; req: string | null; code: string }[]; hostSend: (frame: unknown) => void; @@ -43,6 +45,8 @@ function setup(onForbidden?: (id: string | null) => void, shelf?: ShelfFrames): const exits: number[] = []; const spawned: Harness["spawned"] = []; const composed: Harness["composed"] = []; + const scheduled: Harness["scheduled"] = []; + const scheduledLists: Harness["scheduledLists"] = []; const sheetOks: string[] = []; const sheetErrors: Harness["sheetErrors"] = []; // Only the methods the router calls; main.ts wires the real shell. @@ -62,6 +66,12 @@ function setup(onForbidden?: (id: string | null) => void, shelf?: ShelfFrames): sheetComposed: (req: string, text: string) => { composed.push({ req, text }); }, + sheetScheduled: (req: string, item: unknown) => { + scheduled.push({ req, item }); + }, + sheetScheduledList: (req: string, items: unknown[]) => { + scheduledLists.push({ req, items }); + }, sheetOk: (req: string) => { sheetOks.push(req); }, @@ -101,6 +111,8 @@ function setup(onForbidden?: (id: string | null) => void, shelf?: ShelfFrames): exits, spawned, composed, + scheduled, + scheduledLists, sheetOks, sheetErrors, hostSend: (frame: unknown) => channel.deliver(JSON.stringify(frame)), @@ -240,6 +252,30 @@ describe("createFrameRouter", () => { expect(h.composed).toEqual([{ req: "r3", text: "# Goal" }]); }); + it("routes scheduled and scheduled_list to the shell", () => { + const h = setup(); + const item = { id: "ss-1", fire_at: "2030-01-01T09:00:00.000Z", preview: "review it" }; + h.hostSend({ t: "scheduled", req: "r4", item }); + expect(h.scheduled).toEqual([ + { + req: "r4", + item: { id: "ss-1", fireAt: "2030-01-01T09:00:00.000Z", preview: "review it" }, + }, + ]); + h.hostSend({ t: "scheduled_list", req: "r5", id: "s1", items: [item] }); + expect(h.scheduledLists).toEqual([ + { + req: "r5", + items: [{ id: "ss-1", fireAt: "2030-01-01T09:00:00.000Z", preview: "review it" }], + }, + ]); + // Malformed schedule frames land nowhere, like any unknown frame. + h.hostSend({ t: "scheduled", req: "r6" }); + h.hostSend({ t: "scheduled_list", req: "r7", id: "s1", items: {} }); + expect(h.scheduled).toHaveLength(1); + expect(h.scheduledLists).toHaveLength(1); + }); + it("routes hello harnesses to the catalog's installed set", () => { const h = setup(); h.hostSend({ @@ -286,6 +322,8 @@ describe("createFrameRouter", () => { ensureTerminal: () => {}, sheetSpawned: () => {}, sheetComposed: () => {}, + sheetScheduled: () => {}, + sheetScheduledList: () => {}, sheetOk: () => {}, sheetError: () => {}, }, diff --git a/apps/remote/web/test/schedule.spec.ts b/apps/remote/web/test/schedule.spec.ts new file mode 100644 index 0000000..31aa789 --- /dev/null +++ b/apps/remote/web/test/schedule.spec.ts @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 +// Scheduling a Prompt Builder draft against the fake host: open the +// builder, schedule the draft for later, see it listed under "Scheduled +// for this session", cancel it, see it gone — at a 360x640 phone viewport. +import { expect, test } from "@playwright/test"; + +function futureLocalValue(): string { + const at = new Date(Date.now() + 2 * 60 * 60 * 1000); + const pad = (n: number): string => String(n).padStart(2, "0"); + return ( + `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}` + + `T${pad(at.getHours())}:${pad(at.getMinutes())}` + ); +} + +test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 360, height: 640 }); + await page.goto("/fake.html"); + await page.locator('[data-session="smoke"]').click(); + await expect.poll(() => page.evaluate(() => window.__fake.terminalText())).toContain("hello from the host"); +}); + +test("schedule a draft, see it listed, cancel it", async ({ page }) => { + await page.locator("#term-builder").click(); + await expect(page.locator("#builder-form")).toBeVisible(); + await page.locator(".section-content").fill("water the plants"); + // A fresh host holds no schedules, so the block is absent, not empty. + await expect(page.locator("#builder-scheduled")).toHaveCount(0); + + await page.locator("#builder-schedule").click(); + await expect(page.locator("#builder-schedule-row")).toBeVisible(); + // Every new target clears 44 px. + for (const selector of ["#builder-schedule", "#builder-schedule-confirm", "#builder-schedule-dismiss"]) { + const box = await page.locator(selector).boundingBox(); + expect(box?.height ?? 0).toBeGreaterThanOrEqual(44); + } + await page.locator("#builder-schedule-at").fill(futureLocalValue()); + await page.evaluate(() => window.__fake.clearSent()); + await page.locator("#builder-schedule-confirm").click(); + + // The sheet stays open with a status line and a cleared draft. + await expect(page.locator("#builder-schedule-status")).toContainText("Scheduled for"); + await expect(page.locator("#builder-form")).toBeVisible(); + await expect(page.locator(".section-content")).toHaveValue(""); + const sent = await page.evaluate(() => window.__fake.sent()); + const scheduled = (sent as Record[]).find((frame) => frame.t === "prompt_schedule"); + expect(scheduled).toMatchObject({ t: "prompt_schedule", id: "smoke" }); + expect(typeof scheduled?.fire_at).toBe("string"); + expect(Number.isNaN(Date.parse(scheduled?.fire_at as string))).toBe(false); + + // The new schedule lists with the local fire time and its preview. + const row = page.locator(".scheduled-item"); + await expect(row).toHaveCount(1); + await expect(row).toContainText("water the plants"); + const cancelBox = await row.locator("button[data-schedule-id]").boundingBox(); + expect(cancelBox?.height ?? 0).toBeGreaterThanOrEqual(44); + const overflow = await page.evaluate(() => { + const el = document.scrollingElement ?? document.documentElement; + return el.scrollWidth - el.clientWidth; + }); + expect(overflow).toBeLessThanOrEqual(1); + + // Cancelling drops the row once the host's ok lands. + await row.locator("button[data-schedule-id]").click(); + await expect(page.locator(".scheduled-item")).toHaveCount(0); + await expect(page.locator("#builder-scheduled")).toHaveCount(0); +}); diff --git a/apps/remote/web/test/send-draft.test.ts b/apps/remote/web/test/send-draft.test.ts index d3df2a9..0f07e52 100644 --- a/apps/remote/web/test/send-draft.test.ts +++ b/apps/remote/web/test/send-draft.test.ts @@ -65,6 +65,47 @@ describe("SessionController.sendPromptDraft", () => { }); }); +describe("SessionController scheduled prompts", () => { + const open = (): { controller: SessionController; sent: string[] } => { + const sent: string[] = []; + const controller = new SessionController({ + send: (data) => { + sent.push(data); + return true; + }, + terminal: () => null, + }); + controller.open("s1"); + sent.length = 0; + return { controller, sent }; + }; + + it("refuses without an open session", () => { + const controller = new SessionController({ send: () => true, terminal: () => null }); + expect(controller.sendPromptSchedule(DRAFT, "2030-01-01T09:00:00.000Z", "r1")).toBe(false); + expect(controller.sendScheduledList("r2")).toBe(false); + expect(controller.sendScheduledCancel("ss-1", "r3")).toBe(false); + }); + + it("sends the exact schedule frames for the open session", () => { + const { controller, sent } = open(); + expect(controller.sendPromptSchedule(DRAFT, "2030-01-01T09:00:00.000Z", "r4")).toBe(true); + expect(controller.sendScheduledList("r5")).toBe(true); + expect(controller.sendScheduledCancel("ss-1", "r6")).toBe(true); + expect(sent.map((raw) => JSON.parse(raw))).toEqual([ + { + t: "prompt_schedule", + req: "r4", + id: "s1", + draft: DRAFT, + fire_at: "2030-01-01T09:00:00.000Z", + }, + { t: "scheduled_list", req: "r5", id: "s1" }, + { t: "scheduled_cancel", req: "r6", id: "s1", schedule_id: "ss-1" }, + ]); + }); +}); + describe("Prompt Builder send through the shell", () => { let root: HTMLDivElement | null = null; let shell: Shell | null = null; @@ -110,6 +151,9 @@ describe("Prompt Builder send through the shell", () => { onSpawn: () => true, onCompose: () => true, onSendDraft: (draft, req) => controller?.sendPromptDraft(draft, req) ?? false, + onSchedule: (draft, fireAt, req) => controller?.sendPromptSchedule(draft, fireAt, req) ?? false, + onScheduledList: (req) => controller?.sendScheduledList(req) ?? false, + onScheduledCancel: (scheduleId, req) => controller?.sendScheduledCancel(scheduleId, req) ?? false, onGraphsRequest: () => {}, onWatchGraph: () => {}, onUnwatchGraph: () => {}, @@ -136,9 +180,15 @@ describe("Prompt Builder send through the shell", () => { shell.showSession("s1", "build"); controller.open("s1"); - channel.sent.length = 0; shell.showBuilder(); + // Opening the sheet lists the session's schedules; Send is what this + // test counts, so drop the list frame before clicking it. + expect(channel.sent.map((raw) => (JSON.parse(raw) as { t: string }).t)).toEqual([ + "attach", + "scheduled_list", + ]); + channel.sent.length = 0; const kind = root.querySelector(".section-kind"); if (!kind) { throw new Error("missing section kind"); diff --git a/apps/remote/web/test/shelf-pick.test.ts b/apps/remote/web/test/shelf-pick.test.ts index d709139..ec126e2 100644 --- a/apps/remote/web/test/shelf-pick.test.ts +++ b/apps/remote/web/test/shelf-pick.test.ts @@ -53,6 +53,9 @@ const CALLBACKS: ShellCallbacks = { onSpawn: () => true, onCompose: () => true, onSendDraft: () => true, + onSchedule: () => true, + onScheduledList: () => true, + onScheduledCancel: () => true, onGraphsRequest: () => {}, onWatchGraph: () => {}, onUnwatchGraph: () => {}, diff --git a/apps/remote/web/test/shelf.test.ts b/apps/remote/web/test/shelf.test.ts index 06d359b..99a6e9c 100644 --- a/apps/remote/web/test/shelf.test.ts +++ b/apps/remote/web/test/shelf.test.ts @@ -29,6 +29,9 @@ const CALLBACKS: ShellCallbacks = { onSpawn: () => true, onCompose: () => true, onSendDraft: () => true, + onSchedule: () => true, + onScheduledList: () => true, + onScheduledCancel: () => true, onGraphsRequest: () => {}, onWatchGraph: () => {}, onUnwatchGraph: () => {}, diff --git a/apps/remote/web/test/signout.test.ts b/apps/remote/web/test/signout.test.ts index b81ea38..514f314 100644 --- a/apps/remote/web/test/signout.test.ts +++ b/apps/remote/web/test/signout.test.ts @@ -26,6 +26,9 @@ class StubTerminal implements TerminalHandle { callback(); return () => {}; }; + getSelection = (): string => ""; + onSelectionChange = (): (() => void) => () => {}; + bufferText = (): string => ""; dispose = (): void => {}; } @@ -73,6 +76,9 @@ describe("sign out", () => { onSpawn: () => true, onCompose: () => true, onSendDraft: () => true, + onSchedule: () => true, + onScheduledList: () => true, + onScheduledCancel: () => true, onGraphsRequest: () => {}, onWatchGraph: () => {}, onUnwatchGraph: () => {}, diff --git a/apps/remote/web/test/term-controls.test.ts b/apps/remote/web/test/term-controls.test.ts index d7906f4..082ba41 100644 --- a/apps/remote/web/test/term-controls.test.ts +++ b/apps/remote/web/test/term-controls.test.ts @@ -35,6 +35,7 @@ function deps(root: HTMLElement): TermControlsDeps { onControlSize: () => {}, onLocalSize: () => {}, onBuilder: () => {}, + onCopy: () => {}, }; } diff --git a/apps/remote/web/test/term-copy.test.ts b/apps/remote/web/test/term-copy.test.ts new file mode 100644 index 0000000..0b1951b --- /dev/null +++ b/apps/remote/web/test/term-copy.test.ts @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copying terminal text out: the buffer as plain text (wrapped rows join, +// trailing blanks go, only the last maxLines stay), copyText's clipboard +// then execCommand fallback, the sheet's Copy all / Copy selection, the +// floating selection button, and the Ctrl+Shift+C key handler. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + TERM_COPY_FAILURE, + copyText, + handleCopyKeydown, + isCopyKeydown, + termCopyFloatHtml, + wireCopyFloat, + TermCopySheet, +} from "../src/term-copy.js"; +import type { CopyFloatView } from "../src/term-copy.js"; +import { bufferLinesToText, TerminalView } from "../src/terminal.js"; +import type { BufferLike } from "../src/terminal.js"; + +function fakeBuffer(rows: ({ text: string; wrapped: boolean } | null)[]): BufferLike { + return { + length: rows.length, + getLine: (y: number) => { + const row = rows[y]; + if (!row) { + return undefined; + } + return { + isWrapped: row.wrapped, + translateToString: () => row.text, + }; + }, + }; +} + +describe("bufferLinesToText", () => { + it("joins a wrapped line into one line", () => { + const buffer = fakeBuffer([ + { text: "hel", wrapped: false }, + { text: "lo", wrapped: true }, + { text: "next", wrapped: false }, + ]); + expect(bufferLinesToText(buffer, 2000)).toBe("hello\nnext"); + }); + + it("right-trims every row", () => { + const seen: (boolean | undefined)[] = []; + const buffer: BufferLike = { + length: 1, + getLine: () => ({ isWrapped: false, translateToString: (trim) => (seen.push(trim), "ab") }), + }; + expect(bufferLinesToText(buffer, 2000)).toBe("ab"); + expect(seen).toEqual([true]); + }); + + it("drops trailing blank lines but keeps interior ones", () => { + const buffer = fakeBuffer([ + { text: "a", wrapped: false }, + { text: "", wrapped: false }, + { text: "b", wrapped: false }, + { text: "", wrapped: false }, + { text: "", wrapped: false }, + ]); + expect(bufferLinesToText(buffer, 2000)).toBe("a\n\nb"); + }); + + it("keeps the last maxLines lines", () => { + const buffer = fakeBuffer([ + { text: "one", wrapped: false }, + { text: "two", wrapped: false }, + { text: "three", wrapped: false }, + ]); + expect(bufferLinesToText(buffer, 2)).toBe("two\nthree"); + expect(bufferLinesToText(buffer, 99)).toBe("one\ntwo\nthree"); + }); + + it("skips missing rows and refuses a non-positive maxLines", () => { + const buffer = fakeBuffer([{ text: "a", wrapped: false }, null, { text: "b", wrapped: false }]); + expect(bufferLinesToText(buffer, 2000)).toBe("a\nb"); + expect(bufferLinesToText(buffer, 0)).toBe(""); + expect(bufferLinesToText(buffer, -3)).toBe(""); + }); +}); + +describe("copyText", () => { + let hadClipboard: boolean; + let prevClipboard: unknown; + let prevExecCommand: typeof document.execCommand | undefined; + + beforeEach(() => { + hadClipboard = "clipboard" in navigator; + prevClipboard = (navigator as unknown as Record)["clipboard"]; + prevExecCommand = document.execCommand; + }); + + afterEach(() => { + if (hadClipboard) { + Object.defineProperty(navigator, "clipboard", { + value: prevClipboard, + configurable: true, + writable: true, + }); + } else { + delete (navigator as unknown as Record)["clipboard"]; + } + if (prevExecCommand === undefined) { + delete (document as unknown as Record)["execCommand"]; + } else { + document.execCommand = prevExecCommand; + } + document.body.innerHTML = ""; + }); + + const stubClipboard = (clipboard: unknown): void => { + Object.defineProperty(navigator, "clipboard", { value: clipboard, configurable: true, writable: true }); + }; + + it("copies through the async clipboard when it is there", async () => { + const writeText = vi.fn(async (): Promise => {}); + stubClipboard({ writeText }); + await expect(copyText("hello")).resolves.toBe(true); + expect(writeText).toHaveBeenCalledWith("hello"); + }); + + it("falls back to a temporary textarea and execCommand", async () => { + stubClipboard(undefined); + const execCommand = vi.fn((): boolean => true); + document.execCommand = execCommand as typeof document.execCommand; + await expect(copyText("hello")).resolves.toBe(true); + expect(execCommand).toHaveBeenCalledWith("copy"); + // The temporary textarea is gone again. + expect(document.body.querySelector("textarea")).toBeNull(); + }); + + it("falls back when the clipboard write rejects", async () => { + stubClipboard({ + writeText: vi.fn(async (): Promise => { + throw new Error("denied"); + }), + }); + document.execCommand = (() => true) as typeof document.execCommand; + await expect(copyText("hello")).resolves.toBe(true); + }); + + it("is false without throwing when both paths fail", async () => { + stubClipboard({ + writeText: vi.fn(async (): Promise => { + throw new Error("denied"); + }), + }); + document.execCommand = (() => false) as typeof document.execCommand; + await expect(copyText("hello")).resolves.toBe(false); + document.execCommand = (() => { + throw new Error("no copy"); + }) as typeof document.execCommand; + await expect(copyText("hello")).resolves.toBe(false); + }); +}); + +describe("TermCopySheet", () => { + let parent: HTMLDivElement; + let sheet: TermCopySheet; + + beforeEach(() => { + vi.useFakeTimers(); + parent = document.createElement("div"); + document.body.appendChild(parent); + }); + + afterEach(() => { + sheet?.close(); + vi.useRealTimers(); + document.body.innerHTML = ""; + }); + + const area = (): HTMLTextAreaElement => { + const el = parent.querySelector("#term-copy-text"); + if (!el) { + throw new Error("missing #term-copy-text"); + } + return el; + }; + const status = (): HTMLElement => { + const el = parent.querySelector("#term-copy-status"); + if (!el) { + throw new Error("missing #term-copy-status"); + } + return el; + }; + + it("shows the buffer once, scrolled to the bottom", () => { + const reads: number[] = []; + sheet = new TermCopySheet(async () => true); + sheet.open(parent, { + bufferText: (maxLines) => { + reads.push(maxLines); + return "one\ntwo"; + }, + }); + expect(sheet.isOpen).toBe(true); + expect(parent.querySelector(".sheet")?.getAttribute("aria-label")).toBe("Copy from terminal"); + expect(area().value).toBe("one\ntwo"); + expect(area().readOnly).toBe(true); + expect(area().getAttribute("wrap")).toBe("off"); + expect(area().getAttribute("spellcheck")).toBe("false"); + expect(area().scrollTop).toBe(area().scrollHeight); + // Read once on open: no live update. + expect(reads).toEqual([2000]); + }); + + it("copies everything on Copy all and flashes Copied", async () => { + const copied: string[] = []; + sheet = new TermCopySheet(async (text) => { + copied.push(text); + return true; + }); + sheet.open(parent, { bufferText: () => "one\ntwo" }); + parent.querySelector("#term-copy-all")?.click(); + await Promise.resolve(); + await Promise.resolve(); + expect(copied).toEqual(["one\ntwo"]); + expect(status().textContent).toBe("Copied"); + expect(status().getAttribute("role")).toBe("status"); + vi.advanceTimersByTime(2000); + expect(status().textContent).toBe(""); + }); + + it("enables Copy selection through selectionchange and copies only it", async () => { + const copied: string[] = []; + sheet = new TermCopySheet(async (text) => { + copied.push(text); + return true; + }); + sheet.open(parent, { bufferText: () => "one\ntwo" }); + const button = parent.querySelector("#term-copy-selection"); + expect(button?.disabled).toBe(true); + area().selectionStart = 0; + area().selectionEnd = 3; + document.dispatchEvent(new Event("selectionchange")); + expect(button?.disabled).toBe(false); + button?.click(); + await Promise.resolve(); + await Promise.resolve(); + expect(copied).toEqual(["one"]); + expect(status().textContent).toBe("Copied"); + }); + + it("says how to copy by hand when the copy fails", async () => { + sheet = new TermCopySheet(async () => false); + sheet.open(parent, { bufferText: () => "one" }); + parent.querySelector("#term-copy-all")?.click(); + await Promise.resolve(); + await Promise.resolve(); + expect(status().textContent).toBe(TERM_COPY_FAILURE); + expect(TERM_COPY_FAILURE).toBe("Could not copy. Select the text and use your browser's Copy."); + }); + + it("disables Copy all on an empty buffer", () => { + sheet = new TermCopySheet(async () => true); + sheet.open(parent, { bufferText: () => "" }); + expect(area().value).toBe(""); + expect(parent.querySelector("#term-copy-all")?.disabled).toBe(true); + expect(parent.querySelector("#term-copy-selection")?.disabled).toBe(true); + }); +}); + +describe("the floating selection button", () => { + let button: HTMLButtonElement; + let selection = ""; + let listeners: (() => void)[] = []; + let copied: string[] = []; + let dispose: (() => void) | null = null; + + const view: CopyFloatView = { + getSelection: () => selection, + onSelectionChange: (callback: () => void) => { + listeners.push(callback); + return () => {}; + }, + }; + + beforeEach(() => { + vi.useFakeTimers(); + selection = ""; + listeners = []; + copied = []; + const host = document.createElement("div"); + host.innerHTML = termCopyFloatHtml(); + document.body.appendChild(host); + button = document.querySelector("#term-copy-float") as HTMLButtonElement; + dispose = wireCopyFloat(button, view, async (text) => { + copied.push(text); + return true; + }); + }); + + afterEach(() => { + dispose?.(); + dispose = null; + vi.useRealTimers(); + document.body.innerHTML = ""; + }); + + const emit = (next: string): void => { + selection = next; + for (const listener of listeners) { + listener(); + } + }; + + it("shows only while a selection exists", () => { + expect(button.hidden).toBe(true); + expect(button.getAttribute("aria-label")).toBe("Copy selection"); + emit("picked"); + expect(button.hidden).toBe(false); + emit(""); + expect(button.hidden).toBe(true); + }); + + it("copies the selection, flashes Copied and clears nothing", async () => { + emit("picked"); + button.click(); + await Promise.resolve(); + await Promise.resolve(); + expect(copied).toEqual(["picked"]); + expect(button.querySelector("span")?.textContent).toBe("Copied"); + expect(view.getSelection()).toBe("picked"); + vi.advanceTimersByTime(2000); + expect(button.querySelector("span")?.textContent).toBe("Copy"); + }); +}); + +describe("the copy shortcut", () => { + const key = (overrides: Partial<{ type: string; key: string; ctrlKey: boolean; shiftKey: boolean }>) => ({ + type: "keydown", + key: "c", + ctrlKey: true, + shiftKey: true, + ...overrides, + }); + + it("names Ctrl+Shift+C on the keydown", () => { + expect(isCopyKeydown(key({}))).toBe(true); + expect(isCopyKeydown(key({ key: "C" }))).toBe(true); + expect(isCopyKeydown(key({ shiftKey: false }))).toBe(false); + expect(isCopyKeydown(key({ type: "keyup" }))).toBe(false); + expect(isCopyKeydown(key({ key: "x" }))).toBe(false); + }); + + it("copies and is not forwarded with a selection", async () => { + const copied: string[] = []; + const copy = async (text: string): Promise => { + copied.push(text); + return true; + }; + expect(handleCopyKeydown(key({}), () => "picked", copy)).toBe(false); + await Promise.resolve(); + expect(copied).toEqual(["picked"]); + }); + + it("leaves plain Ctrl+C an interrupt even with a selection", () => { + const copy = vi.fn(async (): Promise => true); + expect(handleCopyKeydown(key({ shiftKey: false }), () => "picked", copy)).toBe(true); + expect(handleCopyKeydown(key({}), () => "", copy)).toBe(true); + expect(handleCopyKeydown(key({ key: "x" }), () => "picked", copy)).toBe(true); + expect(copy).not.toHaveBeenCalled(); + }); +}); + +describe("TerminalView selection and buffer", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("forwards getSelection, onSelectionChange and bufferText", () => { + const parent = document.createElement("div"); + document.body.appendChild(parent); + let selection = ""; + const listeners: (() => void)[] = []; + const view = new TerminalView( + () => ({ + write: () => {}, + clear: () => {}, + focus: () => {}, + scrollToBottom: () => {}, + isAtBottom: () => true, + onUserInput: () => () => {}, + onScroll: () => () => {}, + onReady: (callback: () => void) => { + callback(); + return () => {}; + }, + getSelection: () => selection, + onSelectionChange: (callback: () => void) => { + listeners.push(callback); + return () => {}; + }, + bufferText: (maxLines: number) => `last ${maxLines}`, + dispose: () => {}, + }), + { onUserInput: () => {} }, + ); + // Subscribing before the mount still hears the handle. + const heard: string[] = []; + view.mount(parent, 80, 24); + const stop = view.onSelectionChange(() => heard.push(view.getSelection())); + selection = "picked"; + for (const listener of listeners) { + listener(); + } + expect(heard).toEqual(["picked"]); + expect(view.getSelection()).toBe("picked"); + expect(view.bufferText(2000)).toBe("last 2000"); + stop(); + selection = "again"; + for (const listener of listeners) { + listener(); + } + expect(heard).toEqual(["picked"]); + view.dispose(); + expect(view.getSelection()).toBe(""); + expect(view.bufferText(2000)).toBe(""); + }); +}); diff --git a/apps/remote/web/test/terminal-view.test.ts b/apps/remote/web/test/terminal-view.test.ts index 370d650..04613cd 100644 --- a/apps/remote/web/test/terminal-view.test.ts +++ b/apps/remote/web/test/terminal-view.test.ts @@ -39,6 +39,7 @@ class FakeHandle implements TerminalHandle { private inputListeners: ((text: string) => void)[] = []; private scrollListeners: (() => void)[] = []; private bufferListeners: (() => void)[] = []; + private selectionListeners: (() => void)[] = []; constructor(cols: number, rows: number) { this.cols = cols; @@ -76,6 +77,14 @@ class FakeHandle implements TerminalHandle { callback(); return () => {}; }; + selection = ""; + bufferLines = ""; + getSelection = (): string => this.selection; + onSelectionChange = (callback: () => void): (() => void) => { + this.selectionListeners.push(callback); + return () => {}; + }; + bufferText = (): string => this.bufferLines; resize = (cols: number, rows: number): void => { this.cols = cols; this.rows = rows; @@ -91,6 +100,13 @@ class FakeHandle implements TerminalHandle { } } + emitSelectionChange(selection: string): void { + this.selection = selection; + for (const listener of this.selectionListeners) { + listener(); + } + } + emitScroll(atBottom: boolean): void { this.atBottom = atBottom; for (const listener of this.scrollListeners) { diff --git a/apps/remote/web/test/titles.test.ts b/apps/remote/web/test/titles.test.ts index 4024eb9..f88e874 100644 --- a/apps/remote/web/test/titles.test.ts +++ b/apps/remote/web/test/titles.test.ts @@ -85,6 +85,9 @@ describe("shell titles", () => { onSpawn: () => true, onCompose: () => true, onSendDraft: () => true, + onSchedule: () => true, + onScheduledList: () => true, + onScheduledCancel: () => true, onGraphsRequest: () => {}, onWatchGraph: () => {}, onUnwatchGraph: () => {}, diff --git a/apps/remote/web/test/touch-targets.spec.ts b/apps/remote/web/test/touch-targets.spec.ts index eabc72a..38edf64 100644 --- a/apps/remote/web/test/touch-targets.spec.ts +++ b/apps/remote/web/test/touch-targets.spec.ts @@ -108,10 +108,10 @@ test("touch_targets_meet_44px", async ({ page }) => { }), ); process.stdout.write(`session bar boxes at 390x844: ${JSON.stringify(barBoxes, null, 2)}\n`); - // Zoom out, zoom in, Keys, Builder, Attach, Attach from shelf, + // Zoom out, zoom in, Keys, Builder, Copy, Attach, Attach from shelf, // fullscreen, and the ten keys; latest stays hidden until the user // scrolls up. - expect(barBoxes).toHaveLength(17); + expect(barBoxes).toHaveLength(18); for (const control of barBoxes) { expect(control.width, `${control.label} width`).toBeGreaterThanOrEqual(44); expect(control.height, `${control.label} height`).toBeGreaterThanOrEqual(44); @@ -121,8 +121,9 @@ test("touch_targets_meet_44px", async ({ page }) => { // Audit (2026-11-04): the terminal toolbar at phone widths. Keys goes // icon-only under 640 px (aria-label and title name it), so no label runs // past its button; the latest button floats over the terminal card's -// bottom-right corner instead of wrapping onto a second toolbar row (which -// cost the terminal a row), so the toolbar stays one 44 px row; and the +// bottom-right corner instead of taking a toolbar slot; the toolbar +// itself never wraps onto a second row (which cost the terminal a row) +// but scrolls sideways instead, so it stays one 44 px row; and the // size switch's label is a 44 px target. for (const viewport of [ { width: 360, height: 640 }, @@ -162,9 +163,13 @@ for (const viewport of [ const latest = latestEl?.getBoundingClientRect(); const pane = document.querySelector("#term-pane")?.getBoundingClientRect(); const rowBox = controls.getBoundingClientRect(); + const rowStyle = getComputedStyle(controls); return { pageOverflow: doc.scrollWidth - doc.clientWidth, - rowOverflow: controls.scrollWidth - controls.clientWidth, + rowLeft: rowBox.left, + rowScrollWidth: controls.scrollWidth, + rowWrap: rowStyle.flexWrap, + rowScroll: rowStyle.overflowX, rowHeight: rowBox.height, buttons, toggle: toggle ? { width: toggle.width, height: toggle.height } : null, @@ -179,8 +184,10 @@ for (const viewport of [ process.stdout.write(`toolbar at ${viewport.width}px: ${JSON.stringify(report)}\n`); await page.screenshot({ path: testInfo.outputPath(`toolbar-${viewport.width}.png`) }); expect(report.pageOverflow).toBeLessThanOrEqual(1); - expect(report.rowOverflow).toBeLessThanOrEqual(1); - // One toolbar row: every control at the 44 px floor, none wrapped. + // One toolbar row: it never wraps (nowrap), it scrolls sideways + // instead, so every control stays at the 44 px floor on one row. + expect(report.rowWrap).toBe("nowrap"); + expect(report.rowScroll).toBe("auto"); expect(report.rowHeight).toBeLessThan(2 * 44); expect(new Set(report.buttons.map((button) => Math.round(button.top))).size).toBe(1); // latest floats over the card's bottom-right corner: a 44 px named @@ -206,7 +213,12 @@ for (const viewport of [ expect(button.width, `${button.id} width`).toBeGreaterThanOrEqual(44); expect(button.height, `${button.id} height`).toBeGreaterThanOrEqual(44); expect(button.left, `${button.id} left`).toBeGreaterThanOrEqual(0); - expect(button.right, `${button.id} right`).toBeLessThanOrEqual(viewport.width); + // Past the viewport edge a button still sits inside the scrollable + // row: one swipe reaches it. + expect( + button.right - report.rowLeft, + `${button.id} inside the scrollable row`, + ).toBeLessThanOrEqual(report.rowScrollWidth + 1); } expect(report.toggle?.width ?? 0).toBeGreaterThanOrEqual(44); expect(report.toggle?.height ?? 0).toBeGreaterThanOrEqual(44);