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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,3 @@ playwright-report/
.DS_Store
mcp-servers.json
screenshots-out/
playwright-report/
test-results/
9 changes: 5 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
28 changes: 21 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

```
Expand All @@ -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.

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

import { useNotifyWatcher } from "../lib/useNotifyWatcher.js";

export function AppShell() {
useNotifyWatcher();
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>
{[
["/tools", "Tools"],
["/settings/notifications", "Notifications"],
["/settings", "Settings"],
].map(([to, label]) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`rounded px-2 py-1 text-xs ${isActive ? "bg-[var(--color-background-surface-neutral-muted)] font-semibold" : "text-[var(--color-text-muted)]"}`
}
data-testid={`opencode-nav-${label.toLowerCase()}`}
>
{label}
</NavLink>
))}
</nav>
<div className="min-h-0 flex-1">
<Outlet />
</div>
</div>
);
}
193 changes: 193 additions & 0 deletions client/components/session-inspector.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(`[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<ReviewStatus | null>(null);
const [error, setError] = useState("");
useEffect(() => {
void api.review(url).then((result) => setReview(result.review)).catch((reason: Error) => setError(reason.message));
}, [url]);
return (
<div className="rounded border border-[var(--color-border-default)] p-2" data-testid="opencode-merge-request-link">
<a href={url} target="_blank" rel="noreferrer" className="block break-all text-xs font-semibold underline">
{review?.title ?? url}
</a>
{review ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-[var(--color-text-muted)]">
<span>{review.forge}</span><span>{review.state}</span><span>{review.author}</span>
{review.pipeline && <span>pipeline {review.pipeline}</span>}
{review.mergeable && review.state === "open" && (
<Button size="sm" variant="secondary" disabled={!review.headSha} onClick={() => { if (window.confirm(`Merge ${review.title} at ${review.headSha.slice(0, 8)}?`)) void api.mergeReview(url, review.headSha).then(() => setReview({ ...review, state: "merged", mergeable: false })).catch((reason: Error) => setError(reason.message)); }} data-testid="opencode-merge-review">Merge</Button>
)}
</div>
) : error ? <p className="mt-1 text-[11px] text-[var(--color-text-muted)]">Live status unavailable</p> : <p className="mt-1 text-[11px] text-[var(--color-text-muted)]">Loading status...</p>}
</div>
);
}

export function SessionInspector({ events, todos }: SessionInspectorProps) {
const commands = useMemo(() => extractCommands(events), [events]);
const links = useMemo(() => extractMrUrls(events), [events]);
const [tab, setTab] = useState<InspectorTab>("tasks");

return (
<aside
className="hidden w-80 shrink-0 overflow-y-auto border-l border-[var(--color-border-default)] lg:block"
aria-label="Session details"
data-testid="opencode-session-inspector"
>
<nav className="sticky top-0 z-10 flex border-b border-[var(--color-border-default)] bg-[var(--color-background-surface)] p-1">
{(["tasks", "commands", "links"] as const).map((name) => (
<button
key={name}
type="button"
className={`flex-1 rounded px-2 py-1.5 text-xs capitalize ${
tab === name
? "bg-[var(--color-background-surface-neutral-muted)] font-semibold"
: "text-[var(--color-text-muted)]"
}`}
onClick={() => setTab(name)}
data-testid={`opencode-inspector-${name}`}
>
{name}
{name === "tasks" && todos.length ? ` ${todos.length}` : ""}
{name === "commands" && commands.length ? ` ${commands.length}` : ""}
{name === "links" && links.length ? ` ${links.length}` : ""}
</button>
))}
</nav>

<div className="p-4">
{tab === "tasks" && (
<section data-testid="opencode-task-list">
<h2 className="mb-2 text-[10px] uppercase tracking-wide text-[var(--color-text-muted)]">
Task list - {todos.filter((todo) => todo.status === "completed").length}/{todos.length} done
</h2>
{todos.length === 0 ? (
<p className="text-sm text-[var(--color-text-muted)]">No tasks reported.</p>
) : (
<ul className="space-y-1.5">
{todos.map((todo, index) => (
<li key={index} className="flex items-start gap-2 text-sm" data-status={todo.status}>
<span aria-hidden className="mt-0.5 shrink-0">
{todo.status === "completed" ? "[x]" : todo.status === "in_progress" ? "[~]" : "[ ]"}
</span>
<span className={todo.status === "completed" ? "text-[var(--color-text-muted)] line-through" : ""}>
{todo.content}
</span>
</li>
))}
</ul>
)}
</section>
)}

{tab === "commands" && (
<section data-testid="opencode-command-list">
<div className="mb-3 flex items-center justify-between gap-2">
<h2 className="text-[10px] uppercase tracking-wide text-[var(--color-text-muted)]">
Tool audit
</h2>
<Button
size="sm"
variant="secondary"
disabled={!commands.some((command) => command.category === "command")}
onClick={() => exportCommands(commands)}
data-testid="opencode-export-commands"
>
Export .sh
</Button>
</div>
{commands.length === 0 ? (
<p className="text-sm text-[var(--color-text-muted)]">No tool calls yet.</p>
) : (
<ol className="space-y-2">
{commands.map((command) => (
<li key={command.id}>
<button
type="button"
className="w-full rounded border border-[var(--color-border-default)] p-2 text-left hover:bg-[var(--hh-row-hover)]"
onClick={() => jumpToEvent(command.id)}
data-testid="opencode-command-row"
>
<span className="flex items-center gap-2 text-[10px] uppercase text-[var(--color-text-muted)]">
<span>{command.category}</span>
<span>{command.status}</span>
<time className="ml-auto">{formatClockTime(command.timestamp)}</time>
</span>
<code className="mt-1 block truncate text-xs">{command.text}</code>
{command.outputPreview && (
<span className="mt-1 block truncate text-[11px] text-[var(--color-text-muted)]">
{command.outputPreview}
</span>
)}
</button>
</li>
))}
</ol>
)}
</section>
)}

{tab === "links" && (
<section data-testid="opencode-merge-request-list">
<h2 className="mb-2 text-[10px] uppercase tracking-wide text-[var(--color-text-muted)]">
Merge requests and pull requests
</h2>
{links.length === 0 ? (
<p className="text-sm text-[var(--color-text-muted)]">No review links mentioned.</p>
) : (
<ul className="space-y-2">
{links.map((url) => (
<li key={url}>
<ReviewLink url={url} />
</li>
))}
</ul>
)}
</section>
)}
</div>
</aside>
);
}
Loading
Loading