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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ PROJECTS_DIR=~/Documents/Projects
OPENCODE_WORKTREE_ROOT=~/.local/share/opencode/worktree

# ── Mobile / Tailscale (optional) ────────────────────────────────────────────
# Public HTTP(S) origin encoded by the "Open on phone" QR action. Set this to
# the Tailscale Serve URL that reaches the app; unset falls back to the browser
# origin (which is usually localhost on desktop and therefore not phone-safe).
# PUBLIC_APP_URL=https://your-device.your-tailnet.ts.net

# Vite blocks non-localhost Host headers by default (DNS-rebinding protection).
# Set to "all", or a comma-separated allowlist, to reach the dev UI from a phone.
# VITE_ALLOWED_HOSTS=all
Expand Down
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ several decisions below.
only. **Never raw hex.**
- Every interactive element carries a `data-testid`.
- No new runtime dependencies without a reason recorded here.
- `qrcode-generator@2.0.4` is the sole QR runtime dependency: it creates the
phone-transfer matrix entirely in the browser, avoiding URL disclosure to an
external image service. The app reads its matrix API and renders a React SVG
path rather than injecting the package's generated markup.
- The transcript renderer consumes a backend-neutral `TranscriptEvent`. Row components
must never touch raw OpenCode `Part` shapes — that mapping lives in exactly one place
(`client/lib/events.ts`), which is what made this migration a ~363-line adapter
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,27 @@ cp .env.example .env # point OPENCODE_URL at your server
npm run dev
```

### Open on a phone

Expose the app with Tailscale Serve (or another private HTTP(S) endpoint), then set its
origin in `.env` and restart the app:

```bash
PUBLIC_APP_URL=https://your-device.your-tailnet.ts.net
```

Use **Phone** in the global navigation to open a scannable QR code, copy the link, or
close the panel without leaving the current page. The QR is generated locally in the
browser; its URL is never sent to an image or QR service. `PUBLIC_APP_URL` must be an
HTTP(S) origin with no path, query, fragment, or credentials. If it is unset, the QR
uses the current browser origin, which is only useful when that origin is phone-reachable.

Verification requires no live agent or model credentials:

```bash
npm run typecheck
npm test
npm run build
npm run test:e2e
```

Expand Down
30 changes: 30 additions & 0 deletions client/components/app-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,44 @@
import { useState } from "react";
import { Smartphone } from "lucide-react";
import { NavLink, Outlet } from "react-router-dom";

import { Button } from "../ds/button.js";
import { api } from "../lib/api.js";
import { selectPhoneUrl } from "../lib/phoneTransfer.js";
import { useNotifyWatcher } from "../lib/useNotifyWatcher.js";
import { PhoneTransferDialog } from "./phone-transfer-dialog.js";

export function AppShell() {
useNotifyWatcher();
const [phoneUrl, setPhoneUrl] = useState<string | null>(null);

const openPhoneTransfer = async () => {
let configuredUrl: string | null = null;
try {
configuredUrl = (await api.appConfig()).publicAppUrl;
} catch {
// The browser origin is still useful when the optional config route is unavailable.
}
setPhoneUrl(selectPhoneUrl(configuredUrl, window.location.origin));
};

return (
<div className="flex h-full min-h-0 flex-col">
<nav className="flex h-11 shrink-0 items-center gap-1 border-b border-[var(--color-border-default)] px-3" aria-label="Main">
<NavLink to="/" className="mr-auto text-sm font-bold tracking-tight" data-testid="opencode-nav-home">
OpenCode
</NavLink>
<Button
aria-label="Open on phone"
className="gap-1.5 px-2"
size="sm"
variant="ghost"
onClick={() => void openPhoneTransfer()}
data-testid="opencode-phone-transfer-open"
>
<Smartphone aria-hidden="true" size={15} />
<span className="hidden sm:inline">Phone</span>
</Button>
{[
["/tools", "Tools"],
["/settings/notifications", "Notifications"],
Expand All @@ -30,6 +59,7 @@ export function AppShell() {
<div className="min-h-0 flex-1">
<Outlet />
</div>
{phoneUrl && <PhoneTransferDialog targetUrl={phoneUrl} onClose={() => setPhoneUrl(null)} />}
</div>
);
}
96 changes: 96 additions & 0 deletions client/components/phone-transfer-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useEffect, useRef, useState } from "react";
import qrcode from "qrcode-generator";

import { Button } from "../ds/button.js";

function QrCode({ value }: { value: string }) {
const qr = qrcode(0, "M");
qr.addData(value, "Byte");
qr.make();

const quietZone = 4;
const moduleCount = qr.getModuleCount();
const size = moduleCount + quietZone * 2;
const modules: string[] = [];
for (let row = 0; row < moduleCount; row += 1) {
for (let column = 0; column < moduleCount; column += 1) {
if (qr.isDark(row, column)) modules.push(`M${column + quietZone} ${row + quietZone}h1v1h-1z`);
}
}

return (
<svg
aria-label={`QR code for ${value}`}
className="aspect-square h-auto w-full max-w-64 bg-[var(--color-background-qr)] text-[var(--color-foreground-qr)]"
role="img"
shapeRendering="crispEdges"
viewBox={`0 0 ${size} ${size}`}
>
<path d={modules.join("")} fill="currentColor" />
</svg>
);
}

export function PhoneTransferDialog({ targetUrl, onClose }: { targetUrl: string; onClose: () => void }) {
const dialogRef = useRef<HTMLDialogElement>(null);
const [copyStatus, setCopyStatus] = useState("");

useEffect(() => {
const dialog = dialogRef.current;
if (dialog && !dialog.open) dialog.showModal();
}, []);

const close = () => dialogRef.current?.close();
const copy = async () => {
try {
await navigator.clipboard.writeText(targetUrl);
setCopyStatus("Copied");
} catch {
setCopyStatus("Copy failed");
}
};

return (
<dialog
ref={dialogRef}
aria-describedby="phone-transfer-description"
aria-labelledby="phone-transfer-title"
className="m-auto w-[calc(100%-2rem)] max-w-sm rounded-xl border border-[var(--color-border-default)] bg-[var(--color-background-surface)] p-0 text-[var(--color-text-default)] shadow-xl backdrop:bg-[var(--color-background-overlay)]"
data-testid="opencode-phone-transfer-dialog"
onCancel={(event) => {
event.preventDefault();
close();
}}
onClose={onClose}
onClick={(event) => {
if (event.target === event.currentTarget) close();
}}
>
<div className="space-y-4 p-5 sm:p-6">
<div>
<h2 id="phone-transfer-title" className="text-lg font-semibold">Open on your phone</h2>
<p id="phone-transfer-description" className="mt-1 text-sm text-[var(--color-text-muted)]">
Scan this code with a phone that can reach this address.
</p>
</div>
<div className="mx-auto w-full max-w-64 rounded-lg border border-[var(--color-border-default)] bg-[var(--color-background-qr)] p-2">
<QrCode value={targetUrl} />
</div>
<p className="break-all rounded-md bg-[var(--color-background-surface-neutral-muted)] p-3 font-mono text-xs" data-testid="opencode-phone-transfer-url">
{targetUrl}
</p>
<div className="flex flex-wrap items-center justify-end gap-2">
<span aria-live="polite" className="mr-auto text-xs text-[var(--color-text-muted)]" data-testid="opencode-phone-transfer-copy-status">
{copyStatus}
</span>
<Button variant="secondary" onClick={() => void copy()} data-testid="opencode-phone-transfer-copy">
Copy Link
</Button>
<Button onClick={close} data-testid="opencode-phone-transfer-close">
Close
</Button>
</div>
</div>
</dialog>
);
}
1 change: 1 addition & 0 deletions client/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ function scoped(path: string, directory: string, extra: Record<string, string> =

export const api = {
health: () => fetch("/api/health").then((r) => json<HealthResponse>(r)),
appConfig: () => fetch("/api/app-config").then((r) => json<{ publicAppUrl: string | null }>(r)),

sessions: (directory: string, limit = 100) =>
fetch(scoped("/sessions", directory, { limit: String(limit) })).then((r) =>
Expand Down
3 changes: 3 additions & 0 deletions client/lib/phoneTransfer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function selectPhoneUrl(configuredUrl: string | null, browserOrigin: string): string {
return configuredUrl ?? new URL(browserOrigin).origin;
}
6 changes: 6 additions & 0 deletions client/theme/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
--color-background-surface-success-muted: #ecfdf5;
--color-background-surface-warning-muted: #fffbeb;
--color-background-surface-danger-muted: #fef2f2;
--color-background-overlay: rgb(17 24 39 / 0.65);
--color-background-qr: #ffffff;
--color-foreground-qr: #000000;

/* ── Actions */
--color-background-action-primary: #16a34a;
Expand Down Expand Up @@ -72,6 +75,9 @@
--color-background-surface-success-muted: #052e1d;
--color-background-surface-warning-muted: #3b2a06;
--color-background-surface-danger-muted: #3f1414;
--color-background-overlay: rgb(0 0 0 / 0.75);
--color-background-qr: #ffffff;
--color-foreground-qr: #000000;

--color-background-action-primary: #22c55e;
--color-background-action-primary-hover: #16a34a;
Expand Down
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"next-themes": "^0.4.6",
"pino": "^9.0.0",
"pino-pretty": "^13.0.0",
"qrcode-generator": "2.0.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0",
Expand Down
1 change: 1 addition & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export default defineConfig({
OPENCODE_WORKTREE_ROOT: "/tmp",
NOTIFICATION_PREFS_FILE: "/tmp/custom-dca-opencode-e2e-notifications.json",
PREVIEW_ALLOWED_PORTS: String(PREVIEW_PORT),
PUBLIC_APP_URL: "https://ide.e2e.example.test:8443",
GITHUB_API_URL: `http://127.0.0.1:${PREVIEW_PORT}`,
},
},
Expand Down
4 changes: 4 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,15 @@ import { PreferenceStore } from "./notifications/preferences.js";
import { NotificationService } from "./notifications/service.js";
import { forgeRoutes } from "./routes/forge.js";
import { reminderRoutes } from "./routes/reminders.js";
import { appConfigRoutes } from "./routes/appConfig.js";
import { parsePublicAppUrl } from "./publicAppUrl.js";

dotenv.config();

const app = express();
const PORT = Number(process.env.PORT || 3000);
const opencode = readOpencodeConfig();
const publicAppUrl = parsePublicAppUrl(process.env.PUBLIC_APP_URL);

app.use(express.json({ limit: "20mb" }));

Expand All @@ -56,6 +59,7 @@ app.use("/api", worktreeRoutes(opencode, bus));
app.use("/api", notificationRoutes(notificationStore));
app.use("/api", forgeRoutes());
app.use("/api", reminderRoutes());
app.use("/api", appConfigRoutes(publicAppUrl));
const opencodePort = Number(new URL(opencode.baseUrl).port || 80);
app.use("/api", previewRoutes(parseAllowedPorts(process.env.PREVIEW_ALLOWED_PORTS, [PORT, opencodePort])));

Expand Down
18 changes: 18 additions & 0 deletions server/publicAppUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export function parsePublicAppUrl(value: string | undefined): string | null {
const candidate = value?.trim();
if (!candidate) return null;

let url: URL;
try {
url = new URL(candidate);
} catch {
throw new Error("PUBLIC_APP_URL must be a valid HTTP(S) origin");
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("PUBLIC_APP_URL must use http or https");
}
if (url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
throw new Error("PUBLIC_APP_URL must be an origin without credentials, a path, query, or fragment");
}
return url.origin;
}
9 changes: 9 additions & 0 deletions server/routes/appConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Router } from "express";

export function appConfigRoutes(publicAppUrl: string | null): Router {
const router = Router();
router.get("/app-config", (_req, res) => {
res.json({ publicAppUrl });
});
return router;
}
8 changes: 8 additions & 0 deletions tests/e2e/smoke.api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ test.describe("health", () => {
});
});

test.describe("public app config", () => {
test("exposes only the configured phone origin", async ({ request }) => {
const response = await request.get("/api/app-config");
expect(response.ok()).toBe(true);
expect(await response.json()).toEqual({ publicAppUrl: "https://ide.e2e.example.test:8443" });
});
});

test.describe("directory scoping", () => {
// One OpenCode server hosts every project. A missing scope would silently
// target whatever directory the server started in, so it must be rejected.
Expand Down
29 changes: 29 additions & 0 deletions tests/e2e/smoke.ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,35 @@ test.describe("hub", () => {
});
});

test.describe("phone transfer", () => {
test("opens with the configured URL, copies it, and closes", async ({ page, context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page.goto(hub);
await page.getByTestId("opencode-phone-transfer-open").click();

const dialog = page.getByTestId("opencode-phone-transfer-dialog");
await expect(dialog).toBeVisible();
await expect(page.getByTestId("opencode-phone-transfer-url")).toHaveText("https://ide.e2e.example.test:8443");
await expect(dialog.getByRole("img")).toBeVisible();

await page.getByTestId("opencode-phone-transfer-copy").click();
await expect(page.getByTestId("opencode-phone-transfer-copy-status")).toHaveText("Copied");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("https://ide.e2e.example.test:8443");

await page.getByTestId("opencode-phone-transfer-close").click();
await expect(dialog).toHaveCount(0);
});

test("dialog fits without horizontal overflow at 390px", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 740 });
await page.goto(hub);
await page.getByTestId("opencode-phone-transfer-open").click();
await expect(page.getByTestId("opencode-phone-transfer-dialog")).toBeVisible();
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow).toBeLessThanOrEqual(1);
});
});

test.describe("transcript", () => {
const conversation = `/sessions/ses_mock_done?directory=${encodeURIComponent(DIR)}`;

Expand Down
Loading
Loading