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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 33 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
<p align="center">
<img src="https://raw.githubusercontent.com/UniverLab/.github/main/assets/banner-canopy-web.svg" alt="Canopy Web"/>
</p>

<p align="center">
<a href="https://github.com/UniverLab/canopy-web/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/UniverLab/canopy-web/ci.yml?style=for-the-badge&label=CI" alt="CI"/></a>
<a href="https://github.com/UniverLab/canopy-web/releases"><img src="https://img.shields.io/github/v/release/UniverLab/canopy-web?style=for-the-badge&color=3CC7BD" alt="Release"/></a>
<img src="https://img.shields.io/badge/runs%20on-Cloudflare%20Workers-F38020?style=for-the-badge&logo=cloudflare&logoColor=white" alt="Cloudflare Workers"/>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-2E8B57?style=for-the-badge" alt="License"/></a>
</p>

<p align="center">
<a href="https://graphs.univerlab.org"><b>graphs.univerlab.org</b></a> ·
<a href="https://remote.univerlab.org"><b>remote.univerlab.org</b></a> ·
<a href="https://github.com/UniverLab/harness-canopy">Canopy</a> ·
<a href="https://univerlab.org/canopy/">Docs</a>
</p>

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

Expand Down
16 changes: 16 additions & 0 deletions apps/remote/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/remote/web/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
94 changes: 86 additions & 8 deletions apps/remote/web/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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();
};

Expand Down Expand Up @@ -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.
Expand All @@ -311,6 +338,8 @@ export function createShell(
const teardownSessionWidgets = () => {
termControls?.dispose();
termControls = null;
copyFloatDispose?.();
copyFloatDispose = null;
terminalView?.dispose();
terminalView = null;
latestButton = null;
Expand All @@ -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();
Expand All @@ -344,7 +393,7 @@ export function createShell(
<div class="term-stage">
<div class="term-pane" id="term-pane">
<p class="term-placeholder">Attaching…</p>
</div>${termLatestHtml()}
</div>${termLatestHtml()}${termCopyFloatHtml()}
</div>
${termControlsHtml()}
<div class="upload-tray" id="upload-tray" hidden></div>
Expand All @@ -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
Expand All @@ -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());
Expand Down Expand Up @@ -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();
Expand All @@ -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<HTMLButtonElement>("#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.
Expand Down Expand Up @@ -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);
},
Expand Down
Loading
Loading