+
docs/architecture.mdRuntime topology, conversation and event flows, state ownership, safety boundaries, and extension seams.
CONTRIBUTING.mdSetup, architecture boundaries, conventions, tests, pull requests, screenshots, and security-sensitive changes.
README.mdPurpose, requirements, quick start, feature behavior, architecture, safety model, and PR screenshot contract.
AGENTS.mdVerified OpenCode API traps, accepted design decisions, client conventions, and automation constraints.
diff --git a/scripts/pr-screenshots.ts b/scripts/pr-screenshots.ts
index 0b193741..177b74c5 100644
--- a/scripts/pr-screenshots.ts
+++ b/scripts/pr-screenshots.ts
@@ -100,7 +100,7 @@ function validateRoute(route: string): void {
const url = new URL(route, "http://screenshot.invalid");
if (url.origin !== "http://screenshot.invalid") throw new Error(`route ${JSON.stringify(route)} may not specify a scheme or host`);
- if (![/^\/$/, /^\/settings$/, /^\/settings\/notifications$/, /^\/tools$/, /^\/sessions\/[A-Za-z0-9_-]+$/].some((pattern) => pattern.test(url.pathname))) {
+ if (![/^\/$/, /^\/settings$/, /^\/settings\/notifications$/, /^\/tools$/, /^\/docs(?:\/[A-Za-z0-9_-]+)?$/, /^\/sessions\/[A-Za-z0-9_-]+$/].some((pattern) => pattern.test(url.pathname))) {
throw new Error(`route ${JSON.stringify(route)} is not a known UI route`);
}
}
diff --git a/tests/docs.test.ts b/tests/docs.test.ts
new file mode 100644
index 00000000..b0125b58
--- /dev/null
+++ b/tests/docs.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest";
+
+import { markdownToHtml } from "../client/ds/markdown.js";
+import { DOCS, getDoc, rewriteDocLinks } from "../client/lib/docs.js";
+
+describe("documentation catalogue", () => {
+ it("uses unique slugs and source paths", () => {
+ expect(new Set(DOCS.map((doc) => doc.slug)).size).toBe(DOCS.length);
+ expect(new Set(DOCS.map((doc) => doc.sourcePath)).size).toBe(DOCS.length);
+ expect(getDoc("architecture")?.sourcePath).toBe("docs/architecture.md");
+ expect(getDoc("missing")).toBeUndefined();
+ });
+
+ it("routes catalogued relative links in-app and unknown files to GitHub", () => {
+ const source = [
+ "[Contributing](../CONTRIBUTING.md)",
+ "[Audit](opencode-1.18.21-api-audit.md#result)",
+ "[Implementation](internal/detail.md)",
+ "[External](https://example.com/docs)",
+ ].join("\n");
+
+ expect(rewriteDocLinks(source, "docs/architecture.md")).toBe([
+ "[Contributing](/docs/contributing)",
+ "[Audit](/docs/opencode-api-audit#result)",
+ "[Implementation](https://github.com/leoncheng57/custom-dca-opencode/blob/main/docs/internal/detail.md)",
+ "[External](https://example.com/docs)",
+ ].join("\n"));
+ });
+});
+
+describe("documentation markdown links", () => {
+ it("keeps app links in this tab without changing external link behavior", () => {
+ const html = markdownToHtml("[Architecture](/docs/architecture) [Source](https://example.com)", {
+ internalLinksInSameTab: true,
+ });
+ expect(html).toContain('
Architecture');
+ expect(html).toContain('
Source');
+ });
+});
diff --git a/tests/e2e/palette.ui.spec.ts b/tests/e2e/palette.ui.spec.ts
index 8e857363..22c212b6 100644
--- a/tests/e2e/palette.ui.spec.ts
+++ b/tests/e2e/palette.ui.spec.ts
@@ -72,10 +72,18 @@ test.describe("command palette", () => {
await page.goto("/settings");
await page.keyboard.press(shortcut);
await expect(page.getByRole("option", { name: /Home/ })).toBeVisible();
+ await expect(page.getByRole("option", { name: /Docs/ })).toBeVisible();
await expect(page.getByTestId("opencode-palette-status")).toContainText("Set a project directory");
await expect(page.locator('[role="option"][data-kind="conversation"]')).toHaveCount(0);
});
+ test("opens the docs center from global navigation", async ({ page }) => {
+ await page.goto(settings);
+ await page.getByTestId("opencode-nav-docs").click();
+ await expect(page).toHaveURL(new RegExp(`/docs\\?directory=${encodeURIComponent(DIR)}`));
+ await expect(page.getByTestId("opencode-docs")).toBeVisible();
+ });
+
test("runs existing actions", async ({ page }) => {
await page.goto(settings);
await page.keyboard.press(shortcut);
diff --git a/tests/e2e/screenshots.ui.spec.ts b/tests/e2e/screenshots.ui.spec.ts
index 478d11d6..7870b413 100644
--- a/tests/e2e/screenshots.ui.spec.ts
+++ b/tests/e2e/screenshots.ui.spec.ts
@@ -33,7 +33,11 @@ test.describe("requested PR screenshots", () => {
? "opencode-settings"
: pathname === "/tools"
? "opencode-tools"
- : "opencode-hub";
+ : pathname === "/docs"
+ ? "opencode-docs"
+ : pathname.startsWith("/docs/")
+ ? "opencode-doc"
+ : "opencode-hub";
await expect(page.getByTestId(stableRoot)).toBeVisible();
await expect(page.locator("html")).toHaveClass(/dark/);
await page.addStyleTag({ content: "*, *::before, *::after { animation: none !important; transition: none !important; caret-color: transparent !important; }" });
diff --git a/tests/e2e/smoke.ui.spec.ts b/tests/e2e/smoke.ui.spec.ts
index bba84559..fc9303a7 100644
--- a/tests/e2e/smoke.ui.spec.ts
+++ b/tests/e2e/smoke.ui.spec.ts
@@ -1207,6 +1207,30 @@ test.describe("settings and tools UI", () => {
});
});
+test.describe("engineering docs UI", () => {
+ test("opens the architecture guide from the visual docs center", async ({ page }) => {
+ await page.goto(`/docs?directory=${encodeURIComponent(DIR)}`);
+ await expect(page.getByTestId("opencode-docs")).toContainText("One server, every project");
+ await page.getByTestId("opencode-docs-open-architecture").click();
+ await expect(page).toHaveURL(new RegExp(`/docs/architecture\\?directory=${encodeURIComponent(DIR)}`));
+ await expect(page.getByTestId("opencode-doc")).toContainText("Conversation lifecycle");
+ await expect(page.getByTestId("opencode-doc-source")).toContainText("docs/architecture.md");
+ });
+
+ test("renders an unknown document state", async ({ page }) => {
+ await page.goto("/docs/not-in-the-catalogue");
+ await expect(page.getByTestId("opencode-doc")).toContainText("not in the in-app catalogue");
+ });
+
+ test("fits the docs center without horizontal overflow at 390px", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 740 });
+ await page.goto("/docs");
+ await expect(page.getByTestId("opencode-docs")).toBeVisible();
+ const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
+ expect(overflow).toBeLessThanOrEqual(1);
+ });
+});
+
test.describe("workspace UI", () => {
const conversation = `/sessions/ses_mock_done?directory=${encodeURIComponent(DIR)}`;
@@ -1251,10 +1275,10 @@ test.describe("workspace UI", () => {
await fetch(`${FORGE_URL}/test/forge-reset`, { method: "POST" });
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto(conversation);
- await page.getByTestId("opencode-inspector-reviews").click();
+ await page.getByTestId("opencode-inspector-reviews").dispatchEvent("click");
await expect(page.getByTestId("opencode-merge-review")).toBeVisible();
page.once("dialog", (dialog) => dialog.accept());
- await page.getByTestId("opencode-merge-review").click();
+ await page.getByTestId("opencode-merge-review").dispatchEvent("click");
await expect(page.getByTestId("opencode-review-card")).toHaveAttribute("data-state", "merged");
await expect.poll(async () => (await (await fetch(`${FORGE_URL}/test/forge-state`)).json()).mergeBody).toEqual({ sha: "abc123" });
});
@@ -1262,7 +1286,7 @@ test.describe("workspace UI", () => {
test("review card remains width-safe in a mobile cockpit host", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto(conversation);
- await page.getByTestId("opencode-inspector-reviews").click();
+ await page.getByTestId("opencode-inspector-reviews").dispatchEvent("click");
await expect(page.getByTestId("opencode-review-card")).toBeVisible();
await page.setViewportSize({ width: 390, height: 740 });
await page.getByTestId("opencode-session-inspector").evaluate((element) => {
diff --git a/tests/fixtures/pr-screenshots.json b/tests/fixtures/pr-screenshots.json
index 004d8279..d22d559a 100644
--- a/tests/fixtures/pr-screenshots.json
+++ b/tests/fixtures/pr-screenshots.json
@@ -6,5 +6,9 @@
{
"requestedRoute": "/sessions/ses_mock_done?directory=/tmp/mock-project",
"fullPage": true
+ },
+ {
+ "requestedRoute": "/docs?directory=/tmp/mock-project",
+ "fullPage": true
}
]
diff --git a/tests/pr-screenshots.test.ts b/tests/pr-screenshots.test.ts
index 5cd579ec..3e1d2d14 100644
--- a/tests/pr-screenshots.test.ts
+++ b/tests/pr-screenshots.test.ts
@@ -78,6 +78,11 @@ describe("PR screenshot requests", () => {
expect(desktop).not.toBe(mobile);
});
+ it("accepts the documentation index and fixed-slug readers", () => {
+ expect(parseScreenshotBlock("```screenshots\n/docs\n/docs/architecture\n```").requests)
+ .toHaveLength(2);
+ });
+
it.each([
"https://evil.example/path",
"//evil.example/path",
diff --git a/vite.config.ts b/vite.config.ts
index 724c995b..69930b99 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -33,7 +33,16 @@ export default defineConfig(() => {
fs: {
// Explicit allowlist — never the repo root, which would expose .env
// through /@fs/. server/opencode/ holds isomorphic client+server code.
- allow: [resolve(process.cwd(), "client"), resolve(process.cwd(), "server")],
+ allow: [
+ resolve(process.cwd(), "client"),
+ resolve(process.cwd(), "server"),
+ resolve(process.cwd(), "docs"),
+ resolve(process.cwd(), "deploy/README.md"),
+ resolve(process.cwd(), "reminders/README.md"),
+ resolve(process.cwd(), "README.md"),
+ resolve(process.cwd(), "CONTRIBUTING.md"),
+ resolve(process.cwd(), "AGENTS.md"),
+ ],
},
proxy: {
"/api": { target: apiTarget, changeOrigin: true },