diff --git a/.env.example b/.env.example index 3a7e819c..e95939ae 100644 --- a/.env.example +++ b/.env.example @@ -17,14 +17,24 @@ PORT=3000 # Root under which project folders are offered in the new-session picker. PROJECTS_DIR=~/Documents/Projects +# OpenCode creates isolated worktrees below this root. The BFF accepts workspace +# paths only beneath PROJECTS_DIR or this directory. +OPENCODE_WORKTREE_ROOT=~/.local/share/opencode/worktree + # ── Mobile / Tailscale (optional) ──────────────────────────────────────────── # 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 +# Ports the read-only mobile preview proxy may reach on 127.0.0.1. Unset means +# disabled. The BFF and OpenCode ports are always removed from this list. +# PREVIEW_ALLOWED_PORTS=5173,4173 + # ── Notifications (optional, Phase 5) ──────────────────────────────────────── # NTFY_SERVER=https://ntfy.sh # NTFY_TOPIC= +# NTFY_TOKEN= +# NOTIFICATION_PREFS_FILE=.state/notification-prefs.json # ── Forge integrations (optional, Phase 3) ─────────────────────────────────── # Used only by the merge-request panel. Agent git operations use your host diff --git a/.gitignore b/.gitignore index a23fe37f..7f45d4c0 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,3 @@ playwright-report/ .DS_Store mcp-servers.json screenshots-out/ -playwright-report/ -test-results/ diff --git a/AGENTS.md b/AGENTS.md index 789446b1..ab44492b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,11 +22,12 @@ several decisions below. - **The API is much larger than the docs.** `GET /doc` on a live server returns OpenAPI 3.1 with 162 paths / 188 operations; the published docs show ~60. When in doubt, curl `/doc`, not the website. -- **Use `dist/v2/gen/` SDK types.** `dist/gen/` and the GitHub `dev` branch are stale - in ways that fail silently — they still say `permission.updated` (actually - `permission.asked`) and still declare `Todo.id`, which was removed in 1.18.19. +- **The live `GET /doc` is the contract.** The SDK's classic query types are narrower + than the 1.18.19 server and its event union is stale, so `server/opencode/client.ts` + owns a small typed fetch seam instead of casting around the SDK. - Tests: `npm test` (vitest, `tests/*.test.ts`, node environment, import with `.js` - suffixes). `npm run typecheck` runs both tsconfigs. Playwright e2e needs the stack up. + suffixes). `npm run typecheck` runs both tsconfigs. Playwright starts deterministic + mock OpenCode and preview servers, so `npm run test:e2e` needs no live stack or keys. ## Non-obvious API contracts (each one cost real debugging) diff --git a/README.md b/README.md index 192f9110..1badb4cc 100644 --- a/README.md +++ b/README.md @@ -21,17 +21,18 @@ the same server and can be attached at the same time, watching the same sessions ## Status -Early. Built in phases — see [`docs/research/opencode-build-plan.html`](docs/research/). +The planned migration waves are implemented. The deterministic verification suite runs +against the production SPA and real BFF with only OpenCode and preview targets mocked. | Phase | | | |---|---|---| | 0 | Foundation — scaffold, permission policy, launchd unit | ✅ | -| 1 | The seam — SDK wrapper + event adapter | 🚧 | -| 2 | Session lifecycle + interrupted-run detection | | -| 3 | Panels — MR, commands, files, changes, preview proxy | | -| 4 | Derived — task list, status bar, tools & health | | -| 5 | Settings, resilience, worktrees | | -| 6 | Mobile polish + cutover | | +| 1 | The seam — typed fetch wrapper + event adapter | ✅ | +| 2 | Session lifecycle + interrupted-run detection | ✅ | +| 3 | Panels — MR links, commands, files, changes, preview proxy | ✅ | +| 4 | Derived — task list, status bar, tools & health | ✅ | +| 5 | Settings, notifications, resilience, worktrees | ✅ | +| 6 | Mobile/PWA polish + deterministic E2E | ✅ | ## Requirements @@ -47,6 +48,14 @@ cp .env.example .env # point OPENCODE_URL at your server npm run dev ``` +Verification requires no live agent or model credentials: + +```bash +npm run typecheck +npm test +npm run test:e2e +``` + ## Architecture ``` @@ -71,6 +80,11 @@ container. The guardrail is the `permission` block in `opencode.json`: per-tool per-command-pattern `allow` / `ask` / `deny`, with `~/.ssh`, `~/.aws` and `.env` files denied outright. +The BFF additionally canonicalizes every browser-provided workspace path beneath +`PROJECTS_DIR` or `OPENCODE_WORKTREE_ROOT`. The preview tunnel is disabled unless +`PREVIEW_ALLOWED_PORTS` explicitly allows a localhost port, and it never forwards +cookies, authorization, host headers or OpenCode credentials. + Note that permission precedence is **last-match-wins**, the opposite of most ACL systems. Broad rules first, specific overrides after. diff --git a/client/components/app-shell.tsx b/client/components/app-shell.tsx new file mode 100644 index 00000000..00c96e67 --- /dev/null +++ b/client/components/app-shell.tsx @@ -0,0 +1,35 @@ +import { NavLink, Outlet } from "react-router-dom"; + +import { useNotifyWatcher } from "../lib/useNotifyWatcher.js"; + +export function AppShell() { + useNotifyWatcher(); + return ( +
+ +
+ +
+
+ ); +} diff --git a/client/components/session-inspector.tsx b/client/components/session-inspector.tsx new file mode 100644 index 00000000..2ddde1e2 --- /dev/null +++ b/client/components/session-inspector.tsx @@ -0,0 +1,193 @@ +import { useEffect, useMemo, useState } from "react"; + +import { Button } from "../ds/button.js"; +import { + extractCommands, + extractMrUrls, + formatClockTime, + type CommandEntry, +} from "../lib/derive.js"; +import type { Todo } from "../lib/api.js"; +import type { TranscriptEvent } from "../lib/transcript.js"; +import { api, type ReviewStatus } from "../lib/api.js"; + +type InspectorTab = "tasks" | "commands" | "links"; + +interface SessionInspectorProps { + events: TranscriptEvent[]; + todos: Todo[]; +} + +function exportCommands(commands: CommandEntry[]): void { + const lines = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "", + ...commands + .filter((command) => command.category === "command") + .flatMap((command) => [`# ${command.status} at ${command.timestamp}`, command.text, ""]), + ]; + const url = URL.createObjectURL(new Blob([lines.join("\n")], { type: "text/x-shellscript" })); + const link = document.createElement("a"); + link.href = url; + link.download = "session-commands.sh"; + link.click(); + URL.revokeObjectURL(url); +} + +function jumpToEvent(id: string): void { + const row = document.querySelector(`[data-event-id="${CSS.escape(id)}"]`); + row?.scrollIntoView({ behavior: "smooth", block: "center" }); + row?.focus({ preventScroll: true }); +} + +function ReviewLink({ url }: { url: string }) { + const [review, setReview] = useState(null); + const [error, setError] = useState(""); + useEffect(() => { + void api.review(url).then((result) => setReview(result.review)).catch((reason: Error) => setError(reason.message)); + }, [url]); + return ( +
+ + {review?.title ?? url} + + {review ? ( +
+ {review.forge}{review.state}{review.author} + {review.pipeline && pipeline {review.pipeline}} + {review.mergeable && review.state === "open" && ( + + )} +
+ ) : error ?

Live status unavailable

:

Loading status...

} +
+ ); +} + +export function SessionInspector({ events, todos }: SessionInspectorProps) { + const commands = useMemo(() => extractCommands(events), [events]); + const links = useMemo(() => extractMrUrls(events), [events]); + const [tab, setTab] = useState("tasks"); + + return ( + + ); +} diff --git a/client/components/workspace-panels.tsx b/client/components/workspace-panels.tsx new file mode 100644 index 00000000..c61bd4d2 --- /dev/null +++ b/client/components/workspace-panels.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from "react"; + +import { Alert } from "../ds/alert.js"; +import { Button } from "../ds/button.js"; +import { + api, + type GitCommit, + type VcsFileDiff, + type WorkspaceFile, + type WorkspaceNode, +} from "../lib/api.js"; + +type Tab = "files" | "changes" | "preview"; + +export function WorkspacePanels({ directory, onClose }: { directory: string; onClose: () => void }) { + const [tab, setTab] = useState("files"); + const [path, setPath] = useState(""); + const [nodes, setNodes] = useState([]); + const [file, setFile] = useState(null); + const [changes, setChanges] = useState([]); + const [selectedChange, setSelectedChange] = useState(0); + const [mode, setMode] = useState<"git" | "branch">("git"); + const [commits, setCommits] = useState([]); + const [port, setPort] = useState("5173"); + const [previewKey, setPreviewKey] = useState(0); + const [error, setError] = useState(""); + + useEffect(() => { + if (tab !== "files") return; + void api.workspaceTree(directory, path).then((tree) => { + setNodes([...tree.dirs, ...tree.files]); + setFile(null); + setError(""); + }).catch((e: Error) => setError(e.message)); + }, [directory, path, tab]); + + useEffect(() => { + if (tab !== "changes") return; + void Promise.all([api.changes(directory, mode), api.commits(directory)]).then(([diffs, history]) => { + setChanges(diffs.changes); + setCommits(history.commits); + setSelectedChange(0); + setError(""); + }).catch((e: Error) => setError(e.message)); + }, [directory, mode, tab]); + + const openNode = (node: WorkspaceNode) => { + if (node.type === "directory") setPath(node.path); + else void api.workspaceFile(directory, node.path).then(setFile).catch((e: Error) => setError(e.message)); + }; + const parent = path.split("/").slice(0, -1).join("/"); + + return ( +
+
+ {(["files", "changes", "preview"] as const).map((name) => ( + + ))} + +
+ {error &&
{error}
} + + {tab === "files" && ( +
+
+ + {nodes.filter((node) => !node.ignored).map((node) => ( + + ))} +
+
+ {!file ?

Select a file.

: file.type === "binary" ?

Binary file ({file.mimeType ?? "unknown type"})

: <>

{file.path}

{file.content}
} +
+
+ )} + + {tab === "changes" && ( +
+
+ {(["git", "branch"] as const).map((value) => )} +
+
+
+ {changes.map((change, index) => )} +

Recent commits

+ {commits.map((commit) =>
{commit.shortSha} {commit.subject}
)} +
+
{changes[selectedChange]?.patch || (changes.length ? "Patch unavailable or capped by OpenCode." : "No changes.")}
+
+
+ )} + + {tab === "preview" && ( +
+
+ + +
+