Skip to content
Open
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
4 changes: 4 additions & 0 deletions apps/debug-frontend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
dist
.vitest-attachments
src/**/__screenshots__
2 changes: 2 additions & 0 deletions apps/debug-frontend/.oxfmtignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist
src/routeTree.gen.ts
2 changes: 2 additions & 0 deletions apps/debug-frontend/.oxlintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist
src/routeTree.gen.ts
34 changes: 34 additions & 0 deletions apps/debug-frontend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# @plannotator/debug-frontend

Debug/development harness UI for the Plannotator daemon runtime. **Not production code** — this is a
testbed for exercising daemon sessions, verifying event streams, and testing session lifecycle actions.

## Shape

- `src/routes` is only TanStack Router wiring.
- `src/daemon` owns the typed daemon API client and contracts.
- `src/sessions` owns session ids, session state, the dashboard, and mode dispatch.
- `src/plan`, `src/review`, `src/annotate`, `src/archive`, and `src/setup-goal` own product views.
- `src/testing` owns contract fixtures and browser helpers.

The shell talks to session APIs through `/s/:sessionId/api`, never root `/api`.

The build is intentionally single-file HTML for daemon serving. Separate static asset
routes are deferred until the full UI migration needs code splitting or cacheable chunks.

## Commands

```bash
bun run --cwd apps/debug-frontend dev
bun run --cwd apps/debug-frontend build
bun run --cwd apps/debug-frontend check
bun run --cwd apps/debug-frontend test:browser
```

Or from the repo root:

```bash
bun run dev:debug-frontend
bun run build:debug-frontend
bun run check:debug-frontend
```
12 changes: 12 additions & 0 deletions apps/debug-frontend/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Plannotator</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
44 changes: 44 additions & 0 deletions apps/debug-frontend/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "@plannotator/debug-frontend",
"description": "Debug/development harness UI for the Plannotator daemon runtime. Not production code.",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build && bun run scripts/verify-single-file-build.ts",
"typecheck": "tsc --noEmit -p tsconfig.json",
"lint": "oxlint .",
"lint:fix": "oxlint . --fix",
"fmt": "oxfmt --ignore-path .oxfmtignore --write .",
"fmt:check": "oxfmt --ignore-path .oxfmtignore --check .",
"test": "vitest run",
"test:browser": "vitest run --config vitest.browser.config.ts",
"check": "bun run typecheck && bun run lint && bun run fmt:check && bun run test"
},
"dependencies": {
"@plannotator/shared": "workspace:*",
"@plannotator/ui": "workspace:*",
"@tanstack/react-router": "^1.141.0",
"immer": "^10.2.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"zustand": "^5.0.13"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@tanstack/router-plugin": "^1.141.0",
"@types/node": "^22.14.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"@vitest/browser-playwright": "^4.0.16",
"oxfmt": "^0.17.0",
"oxlint": "^1.31.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.8.2",
"vite": "^6.2.0",
"vite-plugin-singlefile": "^2.0.3",
"vitest": "^4.0.16"
}
}
56 changes: 56 additions & 0 deletions apps/debug-frontend/scripts/verify-single-file-build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { existsSync, readdirSync, readFileSync } from "fs";
import { join, relative } from "path";

const distDir = join(import.meta.dirname, "..", "dist");
const indexPath = join(distDir, "index.html");

function listFiles(dir: string): string[] {
if (!existsSync(dir)) return [];

const files: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const entryPath = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...listFiles(entryPath));
} else if (entry.isFile()) {
files.push(entryPath);
}
}
return files;
}

if (!existsSync(indexPath)) {
throw new Error("Expected apps/debug-frontend/dist/index.html to exist after build.");
}

const html = readFileSync(indexPath, "utf-8");

const outputFiles = listFiles(distDir)
.map((file) => relative(distDir, file))
.sort();
const extraFiles = outputFiles.filter((file) => file !== "index.html");

if (extraFiles.length > 0) {
throw new Error(
`Frontend daemon shell build must be single-file; found outputs: ${extraFiles.join(", ")}`,
);
}

const htmlWithoutInlineCode = html
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "<script></script>")
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "<style></style>");

const externalScriptPattern = /<script\b[^>]*\bsrc=["'][^"']+["']/i;
const externalLinkPatterns = [
/<link\b[^>]*\brel=["'](?:stylesheet|modulepreload|preload)["'][^>]*\bhref=["'][^"']+["']/i,
/<link\b[^>]*\bhref=["'][^"']+["'][^>]*\brel=["'](?:stylesheet|modulepreload|preload)["']/i,
];

if (
externalScriptPattern.test(html) ||
externalLinkPatterns.some((pattern) => pattern.test(htmlWithoutInlineCode))
) {
throw new Error("Frontend daemon shell build must inline scripts and styles.");
}

console.log("Verified single-file frontend shell build.");
21 changes: 21 additions & 0 deletions apps/debug-frontend/src/annotate/AnnotateSessionView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { apiGroupsForMode, sharedApiGroups } from "../sessions/session-api-groups";
import { ApiGroupList } from "../shared/ui/ApiGroupList";
import { SessionFacts } from "../shared/ui/SessionFacts";
import type { SessionViewComponentProps } from "../sessions/session-view-registry";

export function AnnotateSessionView({ bootstrap }: SessionViewComponentProps) {
return (
<section className="session-panel" aria-label="Annotate session">
<header>
<p className="eyebrow">Annotate</p>
<h2>{bootstrap.session.label}</h2>
<p>
Skeleton for markdown, folder, last-message, raw HTML, URL annotation, review-gate
approval, linked docs, image attachments, drafts, and external annotations.
</p>
</header>
<SessionFacts bootstrap={bootstrap} />
<ApiGroupList groups={[...apiGroupsForMode("annotate"), ...sharedApiGroups()]} />
</section>
);
}
22 changes: 22 additions & 0 deletions apps/debug-frontend/src/app/layout/ShellLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Link, Outlet } from "@tanstack/react-router";

export function ShellLayout() {
return (
<div className="app-shell">
<header className="app-header">
<div>
<p className="eyebrow">Local runtime shell</p>
<h1>Plannotator</h1>
</div>
<nav aria-label="Primary">
<Link to="/" activeProps={{ "aria-current": "page" }}>
Sessions
</Link>
</nav>
</header>
<main className="app-main">
<Outlet />
</main>
</div>
);
}
23 changes: 23 additions & 0 deletions apps/debug-frontend/src/app/router.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createRouter } from "@tanstack/react-router";
import { createDaemonApiClient, type DaemonApiClient } from "../daemon/api/client";
import { routeTree } from "../routeTree.gen";

export interface AppRouterContext {
daemonClient: DaemonApiClient;
}

export function createAppRouter(
context: AppRouterContext = { daemonClient: createDaemonApiClient() },
) {
return createRouter({
routeTree,
context,
defaultPreload: "intent",
});
}

declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof createAppRouter>;
}
}
16 changes: 16 additions & 0 deletions apps/debug-frontend/src/app/state/shell-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, test } from "vitest";
import { createShellStore } from "./shell-store";

describe("shell store", () => {
test("tracks shell panel and diagnostics state", () => {
const store = createShellStore();

store.getState().setActivePanel("diagnostics");
store.getState().setCompactDensity(true);
store.getState().toggleDiagnostics();

expect(store.getState().activePanel).toBe("diagnostics");
expect(store.getState().compactDensity).toBe(true);
expect(store.getState().diagnosticsOpen).toBe(true);
});
});
55 changes: 55 additions & 0 deletions apps/debug-frontend/src/app/state/shell-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { createStore } from "zustand/vanilla";
import { useStore } from "zustand";
import { immer } from "zustand/middleware/immer";

export type ShellPanel = "sessions" | "details" | "diagnostics";

export interface ShellStoreState {
activePanel: ShellPanel;
compactDensity: boolean;
diagnosticsOpen: boolean;
}

export interface ShellStoreActions {
setActivePanel(panel: ShellPanel): void;
setCompactDensity(value: boolean): void;
toggleDiagnostics(): void;
}

export type ShellStore = ShellStoreState & ShellStoreActions;

const initialShellState: ShellStoreState = {
activePanel: "sessions",
compactDensity: false,
diagnosticsOpen: false,
};

export function createShellStore(initial: Partial<ShellStoreState> = {}) {
return createStore<ShellStore>()(
immer((set) => ({
...initialShellState,
...initial,
setActivePanel(panel) {
set((state) => {
state.activePanel = panel;
});
},
setCompactDensity(value) {
set((state) => {
state.compactDensity = value;
});
},
toggleDiagnostics() {
set((state) => {
state.diagnosticsOpen = !state.diagnosticsOpen;
});
},
})),
);
}

export const shellStore = createShellStore();

export function useShellStore<T>(selector: (state: ShellStore) => T): T {
return useStore(shellStore, selector);
}
21 changes: 21 additions & 0 deletions apps/debug-frontend/src/archive/ArchiveSessionView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { apiGroupsForMode } from "../sessions/session-api-groups";
import { ApiGroupList } from "../shared/ui/ApiGroupList";
import { SessionFacts } from "../shared/ui/SessionFacts";
import type { SessionViewComponentProps } from "../sessions/session-view-registry";

export function ArchiveSessionView({ bootstrap }: SessionViewComponentProps) {
return (
<section className="session-panel" aria-label="Archive session">
<header>
<p className="eyebrow">Archive</p>
<h2>{bootstrap.session.label}</h2>
<p>
Skeleton for browsing saved plan decisions, inspecting approved and denied plans, and
closing the read-only archive session.
</p>
</header>
<SessionFacts bootstrap={bootstrap} />
<ApiGroupList groups={apiGroupsForMode("archive")} />
</section>
);
}
Loading