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
2 changes: 2 additions & 0 deletions apps/frontend/.oxfmtignore
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/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/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>
57 changes: 57 additions & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"name": "@plannotator/frontend",
"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 --passWithNoTests",
"check": "bun run typecheck && bun run lint && bun run fmt:check && bun run test"
},
"dependencies": {
"@fontsource-variable/geist-mono": "^5.2.7",
"@fontsource-variable/inter": "^5.2.8",
"@plannotator/shared": "workspace:*",
"@plannotator/ui": "workspace:*",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-router": "^1.141.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"immer": "^10.2.0",
"lucide-react": "^1.14.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"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",
"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/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.");
36 changes: 36 additions & 0 deletions apps/frontend/src/app/Layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useCallback, useEffect } from "react";
import { Outlet } from "@tanstack/react-router";
import { Toaster } from "sonner";
import { SidebarProvider } from "@/components/ui/sidebar";
import { AppSidebar } from "../components/sidebar/AppSidebar";
import { AddProjectDialog } from "../components/landing/AddProjectDialog";
import { useDaemonEvents } from "../daemon/events/use-daemon-events";
import { projectStore } from "../stores/project-store";
import { useAppStore } from "../stores/app-store";

export function Layout() {
const addProjectOpen = useAppStore((s) => s.addProjectOpen);
const setAddProjectOpen = useAppStore((s) => s.setAddProjectOpen);

useDaemonEvents();

useEffect(() => {
void projectStore.getState().fetchProjects();
}, []);

const openAddProject = useCallback(() => setAddProjectOpen(true), [setAddProjectOpen]);

return (
<SidebarProvider
defaultOpen={false}
style={{ "--sidebar-width": "16rem" } as React.CSSProperties}
>
<AppSidebar onAddProject={openAddProject} />
<main className="flex-1 overflow-hidden">
<Outlet />
</main>
<AddProjectDialog open={addProjectOpen} onOpenChange={setAddProjectOpen} />
<Toaster position="bottom-right" />
</SidebarProvider>
);
}
23 changes: 23 additions & 0 deletions apps/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>;
}
}
137 changes: 137 additions & 0 deletions apps/frontend/src/components/landing/AddProjectDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useProjectStore } from "../../stores/project-store";

interface AddProjectDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}

export function AddProjectDialog({ open, onOpenChange }: AddProjectDialogProps) {
const [cwd, setCwd] = useState("");
const [name, setName] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const addProject = useProjectStore((s) => s.addProject);
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
if (open) {
setCwd("");
setName("");
setError(undefined);
requestAnimationFrame(() => inputRef.current?.focus());
}
}, [open]);

useEffect(() => {
if (!open) return;
const handle = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onOpenChange(false);
}
};
window.addEventListener("keydown", handle);
return () => window.removeEventListener("keydown", handle);
}, [open, onOpenChange]);

const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!cwd.trim()) return;
setLoading(true);
setError(undefined);
const result = await addProject(cwd.trim(), name.trim() || undefined);
setLoading(false);
if (result) {
onOpenChange(false);
} else {
setError("Failed to add project. Check the path exists.");
}
},
[cwd, name, addProject, onOpenChange],
);

if (!open) return null;

return (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/55 pt-[20vh] backdrop-blur-[2px]"
onClick={() => onOpenChange(false)}
>
<div
className="w-full max-w-md rounded-2xl border border-border/70 bg-popover p-6 text-popover-foreground shadow-[0_24px_80px_-36px_rgba(15,23,42,0.5)]"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-1 flex items-center justify-between">
<h2 className="text-lg font-semibold tracking-tight">Add project</h2>
<button
type="button"
onClick={() => onOpenChange(false)}
className="rounded-md p-1.5 text-muted-foreground/80 hover:bg-accent hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
<p className="mb-5 text-[13px] text-muted-foreground">
Register a project directory to launch sessions from.
</p>

<form onSubmit={handleSubmit}>
<div className="grid gap-4">
<div className="grid gap-1.5">
<label htmlFor="project-cwd" className="text-[12px] font-medium">
Directory path
</label>
<input
ref={inputRef}
id="project-cwd"
type="text"
placeholder="/Users/you/work/project"
value={cwd}
onChange={(e) => setCwd(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-[13px] outline-none placeholder:text-muted-foreground/50 focus:border-ring focus:ring-[3px] focus:ring-ring/50"
/>
</div>
<div className="grid gap-1.5">
<label htmlFor="project-name" className="text-[12px] font-medium">
Name <span className="font-normal text-muted-foreground">(optional)</span>
</label>
<input
id="project-name"
type="text"
placeholder="Defaults to git repo or folder name"
value={name}
onChange={(e) => setName(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-[13px] outline-none placeholder:text-muted-foreground/50 focus:border-ring focus:ring-[3px] focus:ring-ring/50"
/>
</div>
{error && <p className="text-[12px] text-destructive">{error}</p>}
</div>

<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={() => onOpenChange(false)}
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-[13px] font-medium hover:bg-accent hover:text-accent-foreground"
>
Cancel
</button>
<button
type="submit"
disabled={loading || !cwd.trim()}
className={cn(
"inline-flex h-9 items-center rounded-md bg-primary px-4 text-[13px] font-medium text-primary-foreground hover:bg-primary/90",
"disabled:pointer-events-none disabled:opacity-50",
)}
>
{loading ? "Adding..." : "Add project"}
</button>
</div>
</form>
</div>
</div>
);
}
Loading