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
6 changes: 5 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ Thank you for improving custom-dca-opencode. This guide covers the repository's
development workflow and the checks that pull requests must pass.

Use the self-contained [visual reading index](docs/contributing/index.html) to choose a
contributor pathway, or continue here for the canonical workflow.
contributor pathway, open `/docs` in the running app for the architecture-focused docs center,
or continue here for the canonical workflow.

## Before you start

Expand Down Expand Up @@ -55,6 +56,9 @@ The request path is:
Browser -> React/Vite SPA -> Express BFF -> opencode serve
```

See [docs/architecture.md](docs/architecture.md) for the detailed request and event flows,
state ownership, safety boundaries, and extension map.

- [`client/`](client/) contains the React SPA and design-system primitives.
- [`server/`](server/) contains API routes, credentials, directory validation, SSE
fan-out, local git operations, notifications, and forge integrations.
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ starts a second OpenCode server; it uses `OPENCODE_URL` from `.env`.
The OpenCode 1.18.21 compatibility check is recorded in
[`docs/opencode-1.18.21-api-audit.md`](docs/opencode-1.18.21-api-audit.md).
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the development and pull request workflow.
The same contributor material has a themed [visual reading index](docs/contributing/index.html).
The running app exposes the architecture and canonical guides at `/docs`; the same contributor
material also has a standalone themed [visual reading index](docs/contributing/index.html).

### Open on a phone

Expand Down Expand Up @@ -195,6 +196,9 @@ The BFF exists because: it holds the server credential, fans one upstream SSE st
to many browser clients, threads `?directory=` per project, and runs the things the
OpenCode API doesn't expose (git history, forge APIs, notification transport).

See [`docs/architecture.md`](docs/architecture.md) for conversation and event flows, state
ownership, safety boundaries, and the extension map.

## Safety

`opencode serve` runs agent tools **directly on the host as your user** — there is no
Expand Down
10 changes: 7 additions & 3 deletions client/components/app-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { Search, Smartphone } from "lucide-react";
import { BookOpen, Search, Smartphone } from "lucide-react";
import { useTheme } from "next-themes";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";

Expand Down Expand Up @@ -93,6 +93,7 @@ export function AppShell() {
navigation: [
{ id: "home", title: "Home", to: scopedPath("/"), keywords: ["sessions"] },
{ id: "tools", title: "Tools", to: scopedPath("/tools"), keywords: ["mcp", "lsp", "permissions"] },
{ id: "docs", title: "Docs", to: scopedPath("/docs"), keywords: ["architecture", "contributing", "internals"] },
{
id: "notifications",
title: "Notifications",
Expand Down Expand Up @@ -135,7 +136,8 @@ export function AppShell() {
<div className="flex h-full min-h-0 flex-col" inert={paletteOpen ? true : undefined}>
<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={scopedPath("/")} className="mr-auto text-sm font-bold tracking-tight" data-testid="opencode-nav-home">
OpenCode
<span className="sm:hidden">OC</span>
<span className="hidden sm:inline">OpenCode</span>
</NavLink>
<Button
aria-label="Search commands"
Expand All @@ -162,6 +164,7 @@ export function AppShell() {
<span className="hidden sm:inline">Phone</span>
</Button>
{[
["/docs", "Docs"],
["/tools", "Tools"],
["/settings/notifications", "Notifications"],
["/settings", "Settings"],
Expand All @@ -179,7 +182,8 @@ export function AppShell() {
}
data-testid={`opencode-nav-${label.toLowerCase()}`}
>
{label}
{label === "Docs" && <BookOpen aria-hidden="true" className="sm:hidden" size={15} />}
<span className={label === "Docs" ? "hidden sm:inline" : undefined}>{label}</span>
{badged && (
<Badge
variant="counter"
Expand Down
20 changes: 16 additions & 4 deletions client/ds/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ export interface MarkdownToHtmlOpts {
* content built from Slack threads.
*/
untrusted?: boolean;
/** Keep root-relative and hash links in the current tab. Useful for
* repository-owned documentation rendered inside the application. */
internalLinksInSameTab?: boolean;
}

/**
Expand Down Expand Up @@ -152,9 +155,13 @@ export function markdownToHtml(md: string, opts?: MarkdownToHtmlOpts): string {
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text: string, url: string) => {
if (opts?.untrusted) {
if (!isSafeHref(url)) return text;
return keep(`<a href="${escapeAttribute(url)}" target="_blank" rel="noreferrer">${text}</a>`);
const sameTab = opts.internalLinksInSameTab && /^(?:\/|#)/u.test(url.trim());
const attributes = sameTab ? "" : ' target="_blank" rel="noreferrer"';
return keep(`<a href="${escapeAttribute(url)}"${attributes}>${text}</a>`);
}
return keep(`<a href="${url}" target="_blank" rel="noreferrer">${text}</a>`);
const sameTab = opts?.internalLinksInSameTab && /^(?:\/|#)/u.test(url.trim());
const attributes = sameTab ? "" : ' target="_blank" rel="noreferrer"';
return keep(`<a href="${url}"${attributes}>${text}</a>`);
})
// Autolink bare URLs in the remaining plain text — URLs inside code
// or already-emitted links are stashed as tokens, so they're immune.
Expand Down Expand Up @@ -199,10 +206,15 @@ interface MarkdownProps {
/** Escape raw HTML + restrict link protocols. Set for content derived
* from external users (see markdownToHtml docs). */
untrusted?: boolean;
/** Keep root-relative and hash links in this tab. */
internalLinksInSameTab?: boolean;
}

export const Markdown = memo(function Markdown({ source, className, untrusted }: MarkdownProps) {
const html = useMemo(() => markdownToHtml(source, { untrusted }), [source, untrusted]);
export const Markdown = memo(function Markdown({ source, className, untrusted, internalLinksInSameTab }: MarkdownProps) {
const html = useMemo(
() => markdownToHtml(source, { untrusted, internalLinksInSameTab }),
[source, untrusted, internalLinksInSameTab],
);
if (!source || !source.trim()) return null;
return (
<div
Expand Down
126 changes: 126 additions & 0 deletions client/lib/docs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
export type DocCategory = "start" | "architecture" | "operations" | "history";

export interface DocDefinition {
slug: string;
title: string;
description: string;
category: DocCategory;
sourcePath: string;
load: () => Promise<string>;
}

export const DOC_CATEGORY_LABELS: Record<DocCategory, string> = {
start: "Start here",
architecture: "Architecture and internals",
operations: "Operations",
history: "Research and evidence",
};

export const DOC_CATEGORY_ORDER: DocCategory[] = ["start", "architecture", "operations", "history"];

export const DOCS: DocDefinition[] = [
{
slug: "architecture",
title: "Architecture",
description: "Topology, request and event flows, state ownership, safety boundaries, and extension seams.",
category: "architecture",
sourcePath: "docs/architecture.md",
load: () => import("../../docs/architecture.md?raw").then((module) => module.default),
},
{
slug: "contributing",
title: "Contributing",
description: "Local setup, repository conventions, verification, pull requests, and security-sensitive changes.",
category: "start",
sourcePath: "CONTRIBUTING.md",
load: () => import("../../CONTRIBUTING.md?raw").then((module) => module.default),
},
{
slug: "project-readme",
title: "Project orientation",
description: "Purpose, features, requirements, deployment entry points, and the public safety model.",
category: "start",
sourcePath: "README.md",
load: () => import("../../README.md?raw").then((module) => module.default),
},
{
slug: "engineering-invariants",
title: "Engineering invariants",
description: "Verified API traps, durable decisions, and client conventions that protect the implementation.",
category: "architecture",
sourcePath: "AGENTS.md",
load: () => import("../../AGENTS.md?raw").then((module) => module.default),
},
{
slug: "opencode-api-audit",
title: "OpenCode API audit",
description: "Measured compatibility evidence for the pinned OpenCode server surface.",
category: "history",
sourcePath: "docs/opencode-1.18.21-api-audit.md",
load: () => import("../../docs/opencode-1.18.21-api-audit.md?raw").then((module) => module.default),
},
{
slug: "architecture-research",
title: "Architecture research",
description: "The investigation, alternatives, and load-bearing conclusions behind the OpenCode migration.",
category: "history",
sourcePath: "docs/research/README.md",
load: () => import("../../docs/research/README.md?raw").then((module) => module.default),
},
{
slug: "deployment",
title: "Deployment operations",
description: "LaunchAgent installation, logs, upgrades, Tailscale access, and process management.",
category: "operations",
sourcePath: "deploy/README.md",
load: () => import("../../deploy/README.md?raw").then((module) => module.default),
},
{
slug: "reminders",
title: "Reminder catalogue",
description: "Runtime reminder format, provenance, update policy, and validation guidance.",
category: "operations",
sourcePath: "reminders/README.md",
load: () => import("../../reminders/README.md?raw").then((module) => module.default),
},
];

const DOCS_BY_SLUG = new Map(DOCS.map((doc) => [doc.slug, doc]));
const DOCS_BY_PATH = new Map(DOCS.map((doc) => [doc.sourcePath, doc]));
const REPOSITORY_BLOB_URL = "https://github.com/leoncheng57/custom-dca-opencode/blob/main";

export function getDoc(slug: string | undefined): DocDefinition | undefined {
return slug ? DOCS_BY_SLUG.get(slug) : undefined;
}

function normalizePath(path: string): string {
const parts: string[] = [];
for (const part of path.split("/")) {
if (!part || part === ".") continue;
if (part === "..") parts.pop();
else parts.push(part);
}
return parts.join("/");
}

function resolveRelativePath(sourcePath: string, href: string): string {
const sourceDirectory = sourcePath.includes("/") ? sourcePath.slice(0, sourcePath.lastIndexOf("/")) : "";
return normalizePath(`${sourceDirectory}/${href}`);
}

export function rewriteDocLinks(markdown: string, sourcePath: string): string {
return markdown.replace(/(?<!!)\[([^\]]+)\]\(([^)]+)\)/gu, (match, label: string, href: string) => {
const trimmedHref = href.trim();
if (/^(?:[a-z][a-z\d+.-]*:|\/|#)/iu.test(trimmedHref)) return match;

const hashIndex = trimmedHref.indexOf("#");
const queryIndex = trimmedHref.indexOf("?");
const suffixIndex = [hashIndex, queryIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0] ?? -1;
const path = suffixIndex >= 0 ? trimmedHref.slice(0, suffixIndex) : trimmedHref;
const suffix = suffixIndex >= 0 ? trimmedHref.slice(suffixIndex) : "";
const resolvedPath = resolveRelativePath(sourcePath, path);
const inAppDoc = DOCS_BY_PATH.get(resolvedPath);
const target = inAppDoc ? `/docs/${inAppDoc.slug}${suffix}` : `${REPOSITORY_BLOB_URL}/${resolvedPath}${suffix}`;
return `[${label}](${target})`;
});
}
4 changes: 4 additions & 0 deletions client/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { ConversationPage } from "./pages/Conversation.js";
import { SettingsPage } from "./pages/Settings.js";
import { NotificationsPage } from "./pages/Notifications.js";
import { ToolsPage } from "./pages/Tools.js";
import { DocsPage } from "./pages/Docs.js";
import { DocPage } from "./pages/DocPage.js";
import { AppShell } from "./components/app-shell.js";
import { ThemeEffects } from "./components/theme-effects.js";
import { NotificationCenterProvider } from "./lib/useNotificationCenter.js";
Expand All @@ -26,6 +28,8 @@ createRoot(document.getElementById("root")!).render(
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/notifications" element={<NotificationsPage />} />
<Route path="/tools" element={<ToolsPage />} />
<Route path="/docs" element={<DocsPage />} />
<Route path="/docs/:slug" element={<DocPage />} />
</Route>
</Routes>
</NotificationCenterProvider>
Expand Down
72 changes: 72 additions & 0 deletions client/pages/DocPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { useEffect, useState } from "react";
import { ArrowLeft, ExternalLink } from "lucide-react";
import { Link, useParams, useSearchParams } from "react-router-dom";

import { Alert } from "../ds/alert.js";
import { Markdown } from "../ds/markdown.js";
import { getDoc, rewriteDocLinks } from "../lib/docs.js";

const REPOSITORY_BLOB_URL = "https://github.com/leoncheng57/custom-dca-opencode/blob/main";

export function DocPage() {
const { slug } = useParams();
const [searchParams] = useSearchParams();
const doc = getDoc(slug);
const [source, setSource] = useState<string | null>(null);
const [error, setError] = useState("");
const docsHref = searchParams.size ? `/docs?${searchParams}` : "/docs";

useEffect(() => {
let active = true;
setSource(null);
setError("");
if (!doc) return () => { active = false; };
void doc.load()
.then((content) => {
if (active) setSource(rewriteDocLinks(content.replace(/^# .+\n+/u, ""), doc.sourcePath));
})
.catch((cause: unknown) => {
if (active) setError(cause instanceof Error ? cause.message : "Unable to load this document.");
});
return () => { active = false; };
}, [doc]);

if (!doc) {
return (
<main className="mx-auto max-w-3xl p-6" data-testid="opencode-doc">
<Alert variant="warning">This document is not in the in-app catalogue.</Alert>
<Link to={docsHref} className="mt-4 inline-flex items-center gap-2 text-sm font-semibold text-[var(--color-text-info)] hover:underline" data-testid="opencode-doc-back">
<ArrowLeft aria-hidden="true" size={15} /> Back to docs
</Link>
</main>
);
}

return (
<main className="h-full overflow-y-auto" data-testid="opencode-doc">
<div className="mx-auto max-w-4xl px-5 py-7 sm:px-8 sm:py-10">
<header className="mb-8 border-b border-[var(--color-border-default)] pb-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<Link to={docsHref} className="inline-flex items-center gap-2 text-sm font-semibold text-[var(--color-text-info)] hover:underline" data-testid="opencode-doc-back">
<ArrowLeft aria-hidden="true" size={15} /> Docs
</Link>
<a
href={`${REPOSITORY_BLOB_URL}/${doc.sourcePath}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 font-mono text-[10px] text-[var(--color-text-muted)] hover:underline"
data-testid="opencode-doc-source"
>
{doc.sourcePath} <ExternalLink aria-hidden="true" size={12} />
</a>
</div>
<h1 className="mt-6 text-3xl font-bold tracking-tight sm:text-4xl">{doc.title}</h1>
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-[var(--color-text-muted)]">{doc.description}</p>
</header>
{error && <Alert variant="danger">{error}</Alert>}
{!error && source === null && <p className="text-sm text-[var(--color-text-muted)]">Loading document...</p>}
{source !== null && <Markdown source={source} internalLinksInSameTab className="docs-markdown" />}
</div>
</main>
);
}
Loading
Loading