diff --git a/docs/todos/2026-06-19-issue-148-viewer-vue-vite-frontend/plan.md b/docs/todos/2026-06-19-issue-148-viewer-vue-vite-frontend/plan.md new file mode 100644 index 000000000..33ef63ba8 --- /dev/null +++ b/docs/todos/2026-06-19-issue-148-viewer-vue-vite-frontend/plan.md @@ -0,0 +1,1822 @@ +# Viewer Vue/Vite Frontend Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a typed Vue 3 + Vite hidden-preview viewer slice that preserves the current shipped viewer/server/proxy contract while establishing route, API, Dashboard, fixture, and build boundaries for issue #148. + +**Architecture:** Keep `src/viewer/server.ts`, `src/viewer/document.ts`, and `src/auth.ts` as the production trust boundary. Add Vue/Vite source under `src/viewer/app/` and build it into a non-default preview artifact under `dist/viewer-next/`; the existing `dist/viewer/index.html` remains the shipped viewer in this slice. Use local browser validators/type guards, hash routing, and fixture-backed Dashboard tests before any production cutover. + +**Tech Stack:** TypeScript ESM, Vue 3, Vite, `@vitejs/plugin-vue`, Vitest, Node `http/fs/path`, existing viewer server/document/security tests, pnpm with repo hardening. + +--- + +## Context And Boundaries + +Spec: `docs/todos/2026-06-19-issue-148-viewer-vue-vite-frontend/spec.md` + +Task record: `docs/todos/2026-06-19-issue-148-viewer-vue-vite-frontend/todo.md` + +Working branch: `issue/148-viewer-vue-vite-frontend` + +Remote target: `origin` (`https://github.com/wbugitlab1/agentmemory.git`) only. + +Remote, credentialed, and terminal actions still requiring explicit current-turn approval: + +- fetch, pull, push, PR creation, PR merge, issue closure, thread archival, deployment, publishing, destructive cleanup, migrations, credentialed GitHub reads, and remote/project/account state changes. + +Full GitHub feature-loop local PR-prep authorization applies only inside the later `$github-push-prepare` phase and only to task-owned local branch-prep surfaces. + +## Candidate Dependency Intake + +Public npm registry metadata was checked on 2026-06-19. Direct dependency candidates for the first slice: + +| Package | Candidate version | Role | Decision | +| --- | --- | --- | --- | +| `vue` | `3.5.38` | Vue runtime/compiler peer for SFC app source | Accept as devDependency only if install/OSV/lifecycle review passes | +| `vite` | `8.0.16` | Viewer build/dev tooling | Accept if install/OSV/lifecycle review passes | +| `@vitejs/plugin-vue` | `6.0.7` | Official Vue SFC support for Vite | Accept if install/OSV/lifecycle review passes | +| `vue-tsc` | `3.3.5` | Vue SFC type checking | Accept if install/OSV/lifecycle review passes | + +Rejected/deferred for first slice: + +- `vue-router`: defer; hash-route adapter is enough. +- `pinia`: defer; module/composable state is enough. +- browser-bundled `zod`: defer; local validators first. +- Playwright as a package dependency: defer; use existing browser tooling/manual smoke for this slice unless a later review approves heavier browser test dependencies. +- UI component libraries, icon libraries, graph libraries, and single-file Vite plugins: reject for this slice. + +Dependency rules for implementation: + +- Pin direct dependency versions exactly in `package.json`. +- Keep Vue/Vite/SFC tooling in `devDependencies`; the preview artifact is inlined into `dist/` and does not require Vue as an installed runtime dependency. +- Preserve `pnpm-workspace.yaml` hardening. +- Do not approve lifecycle builds without separate recorded review. +- Run dependency setup commands from a sanitized environment where practical by unsetting token variables such as `NPM_TOKEN`, `NODE_AUTH_TOKEN`, `GITHUB_TOKEN`, `GH_TOKEN`, `GITLAB_TOKEN`, and `CI_JOB_TOKEN`. +- After dependency/lockfile changes, run OSV and manually inspect lockfile churn. + +## File Map + +Create: + +- `src/viewer/app/index.html`: Vite preview HTML shell with viewer placeholders. +- `src/viewer/app/main.ts`: Vue mount entry. +- `src/viewer/app/App.vue`: Vue app shell. +- `src/viewer/app/vue-shim.d.ts`: TypeScript shim for Vue SFC imports. +- `src/viewer/app/env.ts`: typed bootstrap and URL resolution. +- `src/viewer/app/routes.ts`: hash tab routing. +- `src/viewer/app/api/client.ts`: typed fetch wrapper. +- `src/viewer/app/api/validators.ts`: local validators/type guards. +- `src/viewer/app/api/types.ts`: viewer API types. +- `src/viewer/app/i18n/index.ts`: typed locale helper. +- `src/viewer/app/realtime/events.ts`: typed realtime event placeholders. +- `src/viewer/app/realtime/client.ts`: realtime client stub boundary. +- `src/viewer/app/state/viewer.ts`: shell state helpers. +- `src/viewer/app/state/dashboard.ts`: Dashboard state loader. +- `src/viewer/app/components/TabBar.vue`: tab navigation. +- `src/viewer/app/components/ToastHost.vue`: toast UI. +- `src/viewer/app/components/ViewerAuthPrompt.vue`: auth prompt UI. +- `src/viewer/app/components/FlagBanners.vue`: flag banner placeholder. +- `src/viewer/app/components/EmptyState.vue`: reusable empty state. +- `src/viewer/app/components/ViewerFooter.vue`: footer/status chrome. +- `src/viewer/app/pages/DashboardPage.vue`: first migrated page. +- `src/viewer/app/pages/PlaceholderPage.vue`: unmigrated tab placeholder. +- `src/viewer/app/styles/tokens.css`: extracted current tokens/theme. +- `src/viewer/app/styles/chrome.css`: shell layout. +- `src/viewer/app/styles/primitives.css`: shared primitives. +- `src/viewer/vite.config.ts`: viewer-local Vite config. +- `scripts/build-viewer.ts`: build helper that produces `dist/viewer-next/index.html`. +- `test/fixtures/viewer/dashboard.json`: deterministic Dashboard fixture. +- `test/fixtures/viewer/dashboard-partial-failure.json`: partial failure fixture. +- `test/viewer-app-routes.test.ts`: route tests. +- `test/viewer-app-api-client.test.ts`: API client tests. +- `test/viewer-app-dashboard.test.ts`: Dashboard state/validator tests. +- `test/viewer-vite-build.test.ts`: preview artifact tests. + +Modify: + +- `package.json`: add exact dependency pins and viewer scripts. +- `pnpm-lock.yaml`: update after intentional dependency install. +- `test/build-package-contract.test.ts`: keep shipped viewer contract and add preview contract. +- `docs/todos/2026-06-19-issue-148-viewer-vue-vite-frontend/todo.md`: progress, dependency intake, verification evidence. + +Do not modify in this slice except to fix a directly failing contract: + +- `src/viewer/server.ts` +- `src/viewer/document.ts` +- `src/auth.ts` +- `src/viewer/index.html` + +## Task 1: Dependency Manifest And Package Contract + +**Files:** +- Modify: `package.json` +- Modify: `pnpm-lock.yaml` +- Modify: `test/build-package-contract.test.ts` +- Modify: `docs/todos/2026-06-19-issue-148-viewer-vue-vite-frontend/todo.md` + +- [ ] **Step 1: Write failing package contract assertions** + +In `test/build-package-contract.test.ts`, add assertions inside `runtime assets copied by the build are included in the npm package files allowlist`: + +```ts + expect(buildScript).toContain("corepack pnpm run viewer:build"); + expect(buildScript).toContain("cp src/viewer/index.html dist/viewer/"); + expect(pkg.scripts?.["viewer:build"]).toBe("tsx scripts/build-viewer.ts"); + expect(pkg.scripts?.["viewer:test"]).toBe( + "vitest run test/viewer-app-routes.test.ts test/viewer-app-api-client.test.ts test/viewer-app-dashboard.test.ts test/viewer-vite-build.test.ts", + ); + expect(pkg.scripts?.["viewer:typecheck"]).toBe("vue-tsc --noEmit -p src/viewer/app/tsconfig.json"); + expect(pkg.dependencies?.vue).toBeUndefined(); + expect(pkg.devDependencies?.vue).toBe("3.5.38"); +``` + +Keep the existing `cp src/viewer/index.html dist/viewer/` assertion because the first slice is preview-only. + +- [ ] **Step 2: Run the focused test and confirm red** + +Run: + +```bash +corepack pnpm exec vitest run test/build-package-contract.test.ts +``` + +Expected: FAIL because `viewer:build`, `viewer:test`, and `corepack pnpm run viewer:build` do not exist yet. If pnpm ignored-build hardening blocks execution before Vitest runs, run: + +```bash +env -u NPM_TOKEN -u NODE_AUTH_TOKEN -u GITHUB_TOKEN -u GH_TOKEN -u GITLAB_TOKEN -u CI_JOB_TOKEN corepack pnpm install --frozen-lockfile --ignore-scripts +``` + +Then rerun the focused command. + +- [ ] **Step 3: Update package scripts and dependency pins** + +In `package.json`, keep existing production dependencies unchanged and add exact Vue/Vite/SFC tooling under `devDependencies`: + +```json + "@vitejs/plugin-vue": "6.0.7", + "vite": "8.0.16", + "vue": "3.5.38", + "vue-tsc": "3.3.5", +``` + +Update scripts: + +```json + "build": "tsdown && corepack pnpm run viewer:build && (cp iii-config.yaml dist/ 2>/dev/null || true) && (cp iii-config.docker.yaml dist/ 2>/dev/null || true) && (cp docker-compose.yml dist/ 2>/dev/null || true) && (cp .env.example dist/ 2>/dev/null || true) && mkdir -p dist/viewer/locales && cp src/viewer/index.html dist/viewer/ && cp src/viewer/favicon.svg dist/viewer/ && cp src/viewer/locales/*.json dist/viewer/locales/", + "viewer:build": "tsx scripts/build-viewer.ts", + "viewer:test": "vitest run test/viewer-app-routes.test.ts test/viewer-app-api-client.test.ts test/viewer-app-dashboard.test.ts test/viewer-vite-build.test.ts", + "viewer:typecheck": "vue-tsc --noEmit -p src/viewer/app/tsconfig.json", +``` + +Keep JSON ordering compatible with the existing style: scripts stay grouped with other scripts, Vue/Vite tooling stays under `devDependencies`, and build remains a single script string. + +- [ ] **Step 4: Materialize lockfile without lifecycle scripts** + +Run: + +```bash +env -u NPM_TOKEN -u NODE_AUTH_TOKEN -u GITHUB_TOKEN -u GH_TOKEN -u GITLAB_TOKEN -u CI_JOB_TOKEN corepack pnpm install --frozen-lockfile --ignore-scripts +``` + +Expected: FAIL because the manifest changed and the lockfile is stale. + +Then run the intentional lockfile update: + +```bash +env -u NPM_TOKEN -u NODE_AUTH_TOKEN -u GITHUB_TOKEN -u GH_TOKEN -u GITLAB_TOKEN -u CI_JOB_TOKEN corepack pnpm install --lockfile-only --ignore-scripts +``` + +Expected: `pnpm-lock.yaml` updates; no lifecycle builds are approved. + +Then run: + +```bash +env -u NPM_TOKEN -u NODE_AUTH_TOKEN -u GITHUB_TOKEN -u GH_TOKEN -u GITLAB_TOKEN -u CI_JOB_TOKEN corepack pnpm install --frozen-lockfile --ignore-scripts +``` + +Expected: PASS or a recorded hardening blocker. + +- [ ] **Step 5: Review dependency churn** + +Run: + +```bash +git diff -- package.json pnpm-lock.yaml +``` + +Expected: direct additions are limited to `vue`, `vite`, `@vitejs/plugin-vue`, and `vue-tsc`; transitive additions are registry packages required by those tools. Record any lifecycle-script or non-registry surprise in `todo.md` before proceeding. + +- [ ] **Step 6: Run package contract green** + +Run: + +```bash +corepack pnpm exec vitest run test/build-package-contract.test.ts +``` + +Expected: PASS after `scripts/build-viewer.ts` exists in a later task; at this point it may still fail on missing script path. If it fails only because `scripts/build-viewer.ts` is not created yet, record that as expected until Task 2. + +- [ ] **Step 7: Update task record** + +In `todo.md`, add a progress note: + +```markdown +- 2026-06-19: Dependency manifest plan accepted candidate devDependency versions `vue@3.5.38`, `vite@8.0.16`, `@vitejs/plugin-vue@6.0.7`, and `vue-tsc@3.3.5`; lockfile materialization used sanitized `env -u ... corepack pnpm install ... --ignore-scripts`; lifecycle build approvals were not granted. +``` + +## Task 2: Viewer Build Helper And Preview Artifact Contract + +**Files:** +- Create: `src/viewer/app/index.html` +- Create: `src/viewer/app/main.ts` +- Create: `src/viewer/app/App.vue` +- Create: `src/viewer/app/vue-shim.d.ts` +- Create: `src/viewer/vite.config.ts` +- Create: `scripts/build-viewer.ts` +- Create: `test/viewer-vite-build.test.ts` +- Modify: `test/build-package-contract.test.ts` + +- [ ] **Step 1: Write failing preview artifact test** + +Create `test/viewer-vite-build.test.ts`: + +```ts +import { mkdirSync, readFileSync, rmSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildViewerPreview } from "../scripts/build-viewer.js"; + +const repoRoot = resolve(__dirname, ".."); +const outDir = join(repoRoot, "dist", "viewer-next-test"); + +describe("viewer Vite preview build", () => { + afterEach(() => { + rmSync(outDir, { recursive: true, force: true }); + }); + + it("emits a preview HTML artifact without replacing the shipped viewer", async () => { + mkdirSync(outDir, { recursive: true }); + await buildViewerPreview({ outDir }); + + const html = readFileSync(join(outDir, "index.html"), "utf8"); + expect(html).toContain("__AGENTMEMORY_VIEWER_NONCE__"); + expect(html).toContain("__AGENTMEMORY_VERSION__"); + expect(html).toContain("__AGENTMEMORY_LOCALE__"); + expect(html).toContain('id="app"'); + expect(html).not.toContain(" + + + +``` + +- [ ] **Step 4: Add minimal Vue entry and shell** + +Create `src/viewer/app/main.ts`: + +```ts +import { createApp } from "vue"; +import App from "./App.vue"; +import "./styles/tokens.css"; +import "./styles/chrome.css"; +import "./styles/primitives.css"; + +createApp(App).mount("#app"); +``` + +Create `src/viewer/app/vue-shim.d.ts`: + +```ts +declare module "*.vue" { + import type { DefineComponent } from "vue"; + + const component: DefineComponent, Record, unknown>; + export default component; +} +``` + +Create `src/viewer/app/App.vue`: + +```vue + + + +``` + +Create the initial style files: + +```css +/* src/viewer/app/styles/tokens.css */ +:root { + --bg: #F9F9F7; + --bg-alt: #F0F0EC; + --border-heavy: #111111; + --ink: #111111; + --ink-secondary: #333333; + --ink-muted: #666666; + --accent: #CC0000; + --font-display: Georgia, 'Times New Roman', serif; + --font-body: Georgia, serif; + --font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} +html, +body, +#app { + margin: 0; + min-height: 100%; +} +body { + background: var(--bg); + color: var(--ink-secondary); + font-family: var(--font-body); +} +``` + +```css +/* src/viewer/app/styles/chrome.css */ +.viewer-shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} +.viewer-header { + border-bottom: 4px solid var(--border-heavy); + padding: 10px 24px; + background: var(--bg); +} +.viewer-brand { + display: flex; + align-items: baseline; + gap: 10px; +} +.viewer-brand h1 { + margin: 0; + color: var(--ink); + font-family: var(--font-display); + font-size: 24px; +} +.viewer-brand span { + color: var(--ink-muted); + font-family: var(--font-ui); + font-size: 11px; + text-transform: uppercase; +} +.viewer-content { + flex: 1; + padding: 24px; +} +``` + +```css +/* src/viewer/app/styles/primitives.css */ +.viewer-button { + border: 2px solid var(--border-heavy); + background: var(--bg); + color: var(--ink); + font-family: var(--font-ui); +} +``` + +- [ ] **Step 5: Add viewer-local Vite config** + +Create `src/viewer/vite.config.ts`: + +```ts +import { resolve } from "node:path"; +import vue from "@vitejs/plugin-vue"; +import { defineConfig } from "vite"; + +const viewerRoot = resolve(__dirname, "app"); + +export default defineConfig({ + root: viewerRoot, + plugins: [vue()], + build: { + outDir: resolve(__dirname, "..", "..", "dist", "viewer-next"), + emptyOutDir: true, + sourcemap: false, + cssCodeSplit: false, + rollupOptions: { + input: resolve(viewerRoot, "index.html"), + output: { + inlineDynamicImports: true, + entryFileNames: "viewer.js", + assetFileNames: "viewer.[ext]", + }, + }, + }, +}); +``` + +- [ ] **Step 6: Add build helper** + +Create `scripts/build-viewer.ts`: + +```ts +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "vite"; + +type BuildOptions = { + outDir?: string; +}; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(scriptDir, ".."); +const defaultOutDir = join(repoRoot, "dist", "viewer-next"); + +export async function buildViewerPreview(options: BuildOptions = {}): Promise { + const outDir = options.outDir ?? defaultOutDir; + rmSync(outDir, { recursive: true, force: true }); + mkdirSync(outDir, { recursive: true }); + + await build({ + configFile: resolve(repoRoot, "src", "viewer", "vite.config.ts"), + build: { outDir }, + }); + + const htmlPath = join(outDir, "index.html"); + let html = readFileSync(htmlPath, "utf8"); + html = html.replace(/]*>\s*/g, ""); + html = inlineStyles(outDir, html); + html = inlineScripts(outDir, html); + html = html.replace(/\/\/# sourceMappingURL=.*$/gm, ""); + writeFileSync(htmlPath, html); +} + +function inlineStyles(outDir: string, html: string): string { + return html.replace( + /]*href="([^"]+)"[^>]*>\s*/g, + (_tag, href: string) => ``, + ); +} + +function inlineScripts(outDir: string, html: string): string { + return html.replace( + /`, + ); +} + +function readBuiltAsset(outDir: string, href: string): string { + return readFileSync(join(outDir, href.replace(/^\//, "")), "utf8"); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await buildViewerPreview(); +} +``` + +- [ ] **Step 7: Run preview build test** + +Run: + +```bash +corepack pnpm exec vitest run test/viewer-vite-build.test.ts +``` + +Expected: PASS. The build helper inlines emitted JavaScript and CSS so the preview artifact remains a single HTML file compatible with the current CSP placeholder contract. + +- [ ] **Step 8: Run package contract test** + +Run: + +```bash +corepack pnpm exec vitest run test/build-package-contract.test.ts +``` + +Expected: PASS. + +## Task 3: Typed Routing And Environment Boundaries + +**Files:** +- Create: `src/viewer/app/routes.ts` +- Create: `src/viewer/app/env.ts` +- Create: `test/viewer-app-routes.test.ts` + +- [ ] **Step 1: Write route and environment tests** + +Create `test/viewer-app-routes.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { + buildHash, + parseHash, + TAB_DEFINITIONS, + type ViewerTab, +} from "../src/viewer/app/routes.js"; +import { resolveViewerUrls } from "../src/viewer/app/env.js"; + +describe("viewer app routes", () => { + it("keeps the current tab ids route-backed", () => { + expect(TAB_DEFINITIONS.map((tab) => tab.id)).toEqual([ + "dashboard", + "graph", + "memories", + "timeline", + "sessions", + "lessons", + "actions", + "crystals", + "audit", + "activity", + "profile", + "replay", + ]); + }); + + it.each([ + ["", "dashboard"], + ["#", "dashboard"], + ["#dashboard", "dashboard"], + ["#memories", "memories"], + ["#MEMORIES", "memories"], + ["#unknown", "dashboard"], + ] satisfies Array<[string, ViewerTab]>)("normalizes %s", (hash, tab) => { + expect(parseHash(hash).tab).toBe(tab); + }); + + it("round-trips query values for migrated tab filters", () => { + const route = parseHash("#memories?q=auth%20bug&type=pattern"); + expect(route).toEqual({ tab: "memories", query: { q: "auth bug", type: "pattern" } }); + expect(buildHash(route)).toBe("#memories?q=auth+bug&type=pattern"); + }); + + it("resolves REST and websocket URLs from a viewer location", () => { + const urls = resolveViewerUrls( + new URL("http://localhost:3113/#dashboard"), + ); + expect(urls.restBase).toBe("http://localhost:3113"); + expect(urls.wsBase).toBe("ws://localhost:3112"); + }); + + it("preserves legacy port query compatibility through the viewer proxy", () => { + const urls = resolveViewerUrls( + new URL("http://localhost:3137/?port=3111#dashboard"), + ); + expect(urls.restBase).toBe("http://localhost:3113"); + expect(urls.wsBase).toBe("ws://localhost:3112"); + }); + + it("honors explicit viewer port and wsPort query overrides", () => { + const urls = resolveViewerUrls( + new URL("http://localhost:3137/?port=43113&wsPort=43112#dashboard"), + ); + expect(urls.restBase).toBe("http://localhost:43113"); + expect(urls.wsBase).toBe("ws://localhost:43112"); + }); +}); +``` + +- [ ] **Step 2: Run and confirm red** + +Run: + +```bash +corepack pnpm exec vitest run test/viewer-app-routes.test.ts +``` + +Expected: FAIL because `routes.ts` and `env.ts` do not exist. + +- [ ] **Step 3: Implement route module** + +Create `src/viewer/app/routes.ts`: + +```ts +export const TAB_DEFINITIONS = [ + { id: "dashboard", labelKey: "nav.dashboard" }, + { id: "graph", labelKey: "nav.graph" }, + { id: "memories", labelKey: "nav.memories" }, + { id: "timeline", labelKey: "nav.timeline" }, + { id: "sessions", labelKey: "nav.sessions" }, + { id: "lessons", labelKey: "nav.lessons" }, + { id: "actions", labelKey: "nav.actions" }, + { id: "crystals", labelKey: "nav.crystals" }, + { id: "audit", labelKey: "nav.audit" }, + { id: "activity", labelKey: "nav.activity" }, + { id: "profile", labelKey: "nav.profile" }, + { id: "replay", labelKey: "nav.replay" }, +] as const; + +export type ViewerTab = (typeof TAB_DEFINITIONS)[number]["id"]; + +export type ViewerRoute = { + tab: ViewerTab; + query?: Record; +}; + +const TABS = new Set(TAB_DEFINITIONS.map((tab) => tab.id)); + +export function normalizeTab(value: string | null | undefined): ViewerTab { + const tab = String(value ?? "").replace(/^#/, "").split("?")[0]!.toLowerCase(); + return TABS.has(tab) ? (tab as ViewerTab) : "dashboard"; +} + +export function parseHash(hash: string): ViewerRoute { + const raw = hash.replace(/^#/, ""); + const [tabPart, queryPart] = raw.split("?"); + const tab = normalizeTab(tabPart); + const query: Record = {}; + if (queryPart) { + const params = new URLSearchParams(queryPart); + for (const [key, value] of params) { + if (value) query[key] = value; + } + } + return Object.keys(query).length > 0 ? { tab, query } : { tab }; +} + +export function buildHash(route: ViewerRoute): string { + const params = new URLSearchParams(route.query ?? {}); + const suffix = params.size > 0 ? `?${params.toString()}` : ""; + return `#${route.tab}${suffix}`; +} +``` + +- [ ] **Step 4: Implement environment module** + +Create `src/viewer/app/env.ts`: + +```ts +export type ViewerUrls = { + restBase: string; + wsBase: string; +}; + +function numericPort(value: string | null): number | null { + if (!value) return null; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +export function resolveViewerUrls(location: URL): ViewerUrls { + const params = location.searchParams; + const explicitViewerPort = numericPort(params.get("port")); + const explicitWsPort = numericPort(params.get("wsPort")); + const currentPort = numericPort(location.port); + const resolvedViewerPort = + explicitViewerPort === 3111 + ? 3113 + : explicitViewerPort ?? (currentPort === 3111 ? 3113 : currentPort); + const restBase = resolvedViewerPort + ? `${location.protocol}//${location.hostname}:${resolvedViewerPort}` + : location.origin; + const wsPort = explicitWsPort ?? (resolvedViewerPort ? resolvedViewerPort - 1 : null); + const wsProtocol = location.protocol === "https:" ? "wss:" : "ws:"; + const wsBase = wsPort + ? `${wsProtocol}//${location.hostname}:${wsPort}` + : `${wsProtocol}//${location.host}`; + return { restBase, wsBase }; +} + +export function viewerVersion(): string { + return typeof window !== "undefined" && typeof window.__AM_VERSION__ === "string" + ? window.__AM_VERSION__ + : "development"; +} + +declare global { + interface Window { + __AM_VERSION__?: string; + __AM_LOCALE__?: Record; + } +} +``` + +- [ ] **Step 5: Run route tests green** + +Run: + +```bash +corepack pnpm exec vitest run test/viewer-app-routes.test.ts +``` + +Expected: PASS. + +## Task 4: API Client And Validators + +**Files:** +- Create: `src/viewer/app/api/types.ts` +- Create: `src/viewer/app/api/validators.ts` +- Create: `src/viewer/app/api/client.ts` +- Create: `test/viewer-app-api-client.test.ts` + +- [ ] **Step 1: Write API client tests** + +Create `test/viewer-app-api-client.test.ts`: + +```ts +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createViewerApiClient, + type ViewerApiToast, +} from "../src/viewer/app/api/client.js"; + +describe("viewer app API client", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.useRealTimers(); + }); + + it("attaches the saved bearer without overriding explicit authorization", async () => { + const calls: Array = []; + globalThis.fetch = vi.fn(async (_url, init) => { + calls.push(init); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }) as typeof fetch; + const storage = new Map([["agentmemory-viewer-token", "viewer-secret"]]); + const client = createViewerApiClient({ + restBase: "http://localhost:3111", + storage, + onAuthRequired: () => {}, + onToast: () => {}, + }); + + await client.getJson("health"); + await client.getJson("health", { headers: { Authorization: "Bearer explicit" } }); + + expect((calls[0]!.headers as Record).Authorization).toBe("Bearer viewer-secret"); + expect((calls[1]!.headers as Record).Authorization).toBe("Bearer explicit"); + }); + + it("signals auth prompt on 401", async () => { + globalThis.fetch = vi.fn(async () => new Response("{}", { status: 401 })) as typeof fetch; + const authRequired = vi.fn(); + const client = createViewerApiClient({ + restBase: "http://localhost:3111", + storage: new Map(), + onAuthRequired: authRequired, + onToast: () => {}, + }); + + await expect(client.getJson("health")).rejects.toMatchObject({ status: 401 }); + expect(authRequired).toHaveBeenCalledTimes(1); + }); + + it("emits escaped toast-facing errors on HTTP failure", async () => { + globalThis.fetch = vi.fn(async () => new Response("", { status: 500 })) as typeof fetch; + const toasts: ViewerApiToast[] = []; + const client = createViewerApiClient({ + restBase: "http://localhost:3111", + storage: new Map(), + onAuthRequired: () => {}, + onToast: (toast) => toasts.push(toast), + }); + + await expect(client.getJson("memories")).rejects.toMatchObject({ status: 500 }); + expect(toasts[0]!.message).toContain("HTTP 500"); + expect(toasts[0]!.message).not.toContain(" + + +``` + +Create `src/viewer/app/components/ToastHost.vue`: + +```vue + + + +``` + +Create `src/viewer/app/components/ViewerAuthPrompt.vue`: + +```vue + + + +``` + +Create `src/viewer/app/components/FlagBanners.vue`: + +```vue + + + +``` + +Create `src/viewer/app/components/EmptyState.vue`: + +```vue + + + +``` + +Create `src/viewer/app/components/ViewerFooter.vue`: + +```vue + + + +``` + +- [ ] **Step 6: Implement Dashboard page and placeholder** + +Create `src/viewer/app/pages/DashboardPage.vue`: + +```vue + + + +``` + +Create `src/viewer/app/pages/PlaceholderPage.vue`: + +```vue + + + +``` + +- [ ] **Step 7: Wire App shell to routes, shell state, and Dashboard** + +Replace `src/viewer/app/App.vue` with: + +```vue + + + +``` + +- [ ] **Step 8: Add primitive styles for Dashboard and shell components** + +Append to `src/viewer/app/styles/primitives.css`: + +```css +.tab-bar { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px 24px; + border-bottom: 2px solid var(--border-heavy); + background: var(--bg-alt); +} +.tab-bar a { + color: var(--ink-muted); + font-family: var(--font-ui); + font-size: 11px; + text-decoration: none; + text-transform: uppercase; +} +.tab-bar a.active { + color: var(--accent); +} +.viewer-warning { + border: 2px solid var(--accent); + padding: 10px 12px; + margin-bottom: 16px; + font-family: var(--font-ui); +} +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; +} +.stat-card { + border: 2px solid var(--border-heavy); + padding: 12px; + background: var(--bg); +} +.stat-label { + display: block; + color: var(--ink-muted); + font-family: var(--font-ui); + font-size: 11px; + text-transform: uppercase; +} +.viewer-list { + padding-left: 18px; +} +.toast-host { + position: fixed; + right: 16px; + bottom: 16px; + display: grid; + gap: 8px; + max-width: min(360px, calc(100vw - 32px)); +} +.viewer-toast, +.auth-prompt, +.flag-banners, +.empty-state { + border: 2px solid var(--border-heavy); + background: var(--bg); + color: var(--ink-secondary); + font-family: var(--font-ui); + padding: 10px 12px; +} +.flag-banners { + border-width: 0 0 2px; +} +.viewer-footer { + display: flex; + justify-content: space-between; + border-top: 2px solid var(--border-heavy); + padding: 8px 24px; + font-family: var(--font-ui); + font-size: 11px; + text-transform: uppercase; +} +``` + +- [ ] **Step 9: Run Dashboard tests green** + +Run: + +```bash +corepack pnpm exec vitest run test/viewer-app-dashboard.test.ts +``` + +Expected: PASS. + +- [ ] **Step 10: Run viewer build test after app changes** + +Run: + +```bash +corepack pnpm exec vitest run test/viewer-vite-build.test.ts +``` + +Expected: PASS. + +## Task 6: Frontend Type/Lint Coverage And Build Scripts + +**Files:** +- Modify: `package.json` +- Create: `src/viewer/app/tsconfig.json` +- Modify: `docs/todos/2026-06-19-issue-148-viewer-vue-vite-frontend/todo.md` + +- [ ] **Step 1: Run focused frontend tests** + +Run: + +```bash +corepack pnpm run viewer:test +``` + +Expected: PASS for route, API client, Dashboard, and build preview tests. + +- [ ] **Step 2: Add and run Vue SFC typecheck** + +Create `src/viewer/app/tsconfig.json`: + +```json +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "types": ["node"] + }, + "include": ["./**/*.ts", "./**/*.vue"] +} +``` + +Run: + +```bash +corepack pnpm run viewer:typecheck +``` + +Expected: PASS and covers `.vue` script/template type checking through `vue-tsc`. + +- [ ] **Step 3: Run root build** + +Run: + +```bash +corepack pnpm run build +``` + +Expected: PASS. The command should create `dist/viewer-next/index.html` and still create the shipped `dist/viewer/index.html` from legacy `src/viewer/index.html`. + +- [ ] **Step 4: Run focused existing viewer contract tests** + +Run: + +```bash +corepack pnpm exec vitest run test/viewer-security.test.ts test/viewer-host.test.ts test/viewer-server-routing.test.ts test/viewer-i18n.test.ts test/build-package-contract.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Run lint** + +Run: + +```bash +corepack pnpm run lint +``` + +Expected: PASS for repo-supported JS/TS lint coverage. `.vue` SFC coverage is handled by `viewer:typecheck` in this slice; adding Vue ESLint parsing is deferred to a later lint-specific change. + +- [ ] **Step 6: Update task record matrix** + +Update `todo.md` Feature / Verification Matrix rows: + +```markdown +| Implementation plan | Saved `plan.md` with file paths, tests, dependency intake, and PR-prep boundaries | Done | Plan saved and self-reviewed. | +| Viewer migration implementation | TDD and focused viewer build/runtime tests | In progress | Vue/Vite preview slice implemented; production viewer remains legacy default. | +``` + +## Task 7: Browser Smoke Verification + +**Files:** +- Modify: `docs/todos/2026-06-19-issue-148-viewer-vue-vite-frontend/todo.md` + +Automated browser fixture tests are a valid follow-up requirement for production cutover, but are not added as a package dependency in this slice because the repo currently has no Playwright/browser test dependency and adding one would broaden supply-chain and CI requirements. This slice must still perform local browser-tool smoke verification, fail the handoff on observed uncaught JavaScript/CSP console issues, and record the automation gap as residual risk. + +- [ ] **Step 1: Start local static server for preview artifact** + +Run: + +```bash +python3 -m http.server 8137 +``` + +from repository root after `corepack pnpm run viewer:build`. + +Expected: local server serves `dist/viewer-next/index.html` at `http://127.0.0.1:8137/dist/viewer-next/index.html`. + +- [ ] **Step 2: Open preview with browser tooling** + +Use the available Browser/IAB or Playwright CLI tooling to open: + +```text +http://127.0.0.1:8137/dist/viewer-next/index.html#dashboard +``` + +Expected: + +- `agentmemory viewer preview` visible. +- Dashboard tab is active. +- Dashboard stats render. +- No uncaught JavaScript errors. +- No CSP console violation relevant to preview artifact. + +- [ ] **Step 3: Capture screenshot evidence** + +Capture a browser screenshot of the preview and record the path in `todo.md`. + +Use `view_image` on the preview screenshot before handoff. Since no visual redesign is approved, compare against the existing viewer visual direction rather than an Image Gen concept: + +- paper/off-white background; +- black structural border; +- red accent active tab; +- compact uppercase tabs; +- readable dashboard stats. + +- [ ] **Step 4: Stop local server** + +Terminate only the server process started in Step 1. Do not kill unrelated user processes. + +- [ ] **Step 5: Record browser evidence** + +Add to `todo.md`: + +```markdown +- 2026-06-19: Browser smoke verified Vue/Vite preview at ``; screenshot `` inspected with `view_image`; no uncaught JS/CSP console issues observed. +- 2026-06-19: Residual risk: automated browser fixture CI is deferred; no Playwright/browser package dependency was added in this slice. +``` + +## Task 8: Full Verification And Security Gates + +**Files:** +- Modify: `docs/todos/2026-06-19-issue-148-viewer-vue-vite-frontend/todo.md` + +- [ ] **Step 1: Run whitespace check** + +Run: + +```bash +git diff --check +``` + +Expected: PASS. + +- [ ] **Step 2: Run focused viewer checks** + +Run: + +```bash +corepack pnpm run viewer:test +corepack pnpm exec vitest run test/viewer-security.test.ts test/viewer-host.test.ts test/viewer-server-routing.test.ts test/viewer-i18n.test.ts test/build-package-contract.test.ts +``` + +Expected: PASS. + +- [ ] **Step 3: Run build and full tests** + +Run: + +```bash +corepack pnpm run build +corepack pnpm test +``` + +Expected: PASS. If full tests are too slow or blocked by pnpm hardening, record exact blocker and the closest targeted substitute. + +- [ ] **Step 4: Run Semgrep** + +Run: + +```bash +semgrep scan --config p/default --error --metrics=off . +``` + +Expected: PASS with 0 blocking findings. If Semgrep is unavailable or network-blocked, request escalation only if needed under the sandbox policy; otherwise record blocker. + +- [ ] **Step 5: Run OSV** + +Run: + +```bash +osv-scanner scan source . +``` + +Expected: PASS or only accepted existing findings. Dependency and lockfile surfaces changed, so OSV is mandatory. + +- [ ] **Step 6: Stage task-owned files** + +Run: + +```bash +git add package.json pnpm-lock.yaml scripts/build-viewer.ts src/viewer/vite.config.ts src/viewer/app test/fixtures/viewer test/viewer-app-routes.test.ts test/viewer-app-api-client.test.ts test/viewer-app-dashboard.test.ts test/viewer-vite-build.test.ts test/build-package-contract.test.ts docs/todos/2026-06-19-issue-148-viewer-vue-vite-frontend +``` + +Expected: only task-owned files are staged. + +- [ ] **Step 7: Run staged Gitleaks** + +Run: + +```bash +gitleaks protect --staged --redact +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +Run: + +```bash +git commit -m "feat: add viewer Vue Vite preview" +``` + +Expected: commit succeeds. Do not amend or rewrite history. + +## Task 9: GitHub Feature Loop PR Prep + +**Files:** +- Modify only task-owned files if `$github-push-prepare` finds a scoped issue. + +- [ ] **Step 1: Invoke `$github-push-prepare` local branch-prep mode** + +Pass: + +- task record: `docs/todos/2026-06-19-issue-148-viewer-vue-vite-frontend/todo.md` +- plan: `docs/todos/2026-06-19-issue-148-viewer-vue-vite-frontend/plan.md` +- spec: `docs/todos/2026-06-19-issue-148-viewer-vue-vite-frontend/spec.md` +- changed surface: viewer frontend/build/package/dependency/test/task docs +- preserved unrelated dirty paths: none known before implementation; re-check before staging +- remote-write approvals: none +- fetch approval: none +- security-sensitive surfaces: package/lockfile, frontend browser code, build tooling, viewer CSP-adjacent artifact tests + +Expected: local branch-prep either completes with exact next commands for push/PR or reports a blocker. It must not push, create PR, merge, or archive without explicit current-turn approval. + +- [ ] **Step 2: Record PR-prep result** + +Update `todo.md` with: + +```markdown +- 2026-06-19: `$github-push-prepare` local branch-prep result: . Push/PR creation did not run because remote-write approval was not granted. +``` + +## Self-Review Checklist + +- Spec coverage: tasks cover dependency intake, hidden Vite/Vue preview, route state, API client, Dashboard fixture page, package contract, existing viewer security/server tests, browser smoke, security gates, commit, and GitHub PR prep. +- Placeholders: no `TBD`, `TODO`, or unspecified file paths are intentionally present. +- Type consistency: route/API/Dashboard function names in tests match implementation snippets. +- Boundary check: production viewer remains legacy default in first slice; `server.ts`, `document.ts`, and `auth.ts` are not planned for modification except direct contract fixes. +- Approval check: no fetch, push, PR creation, merge, closure, or archival is authorized by this plan. diff --git a/docs/todos/2026-06-19-issue-148-viewer-vue-vite-frontend/spec.md b/docs/todos/2026-06-19-issue-148-viewer-vue-vite-frontend/spec.md new file mode 100644 index 000000000..633f1046a --- /dev/null +++ b/docs/todos/2026-06-19-issue-148-viewer-vue-vite-frontend/spec.md @@ -0,0 +1,377 @@ +# Issue 148 Viewer Vue/Vite Frontend Spec + +## Status + +Approved source issue: GitHub issue #148 is open and locally valid. + +Current phase: design/spec after arena synthesis. + +Implementation status: not started. + +## Goal + +Introduce a typed Vue 3 + Vite frontend for the agentmemory viewer while preserving the existing viewer server/proxy/security model, package runtime behavior, and current visual direction. + +## Non-Goals + +- Do not replace iii-engine, REST endpoints, stream endpoints, auth, persistence, or the viewer proxy architecture. +- Do not rewrite all viewer tabs in one implementation slice. +- Do not make installed users run a Vite server. +- Do not add new production static asset routes in the first slice. +- Do not visually redesign the viewer from scratch. +- Do not target `https://github.com/rohitg00/agentmemory/` for branch, push, or PR work. + +## Current Evidence + +The viewer is currently a single static HTML template at `src/viewer/index.html`. The file contains inline CSS, inline browser JavaScript, hash tab routing, API calls, WebSocket logic, auth prompt handling, toasts, graph behavior, replay behavior, i18n application, and per-tab rendering. + +`src/viewer/document.ts` loads that template from source or package output and injects: + +- `__AGENTMEMORY_VIEWER_NONCE__` +- `__AGENTMEMORY_VERSION__` +- `__AGENTMEMORY_LOCALE__` + +`src/viewer/server.ts` owns the runtime server/proxy boundary: + +- loopback host resolution and optional non-loopback bind; +- Host header allowlist and DNS-rebinding defense; +- non-loopback inbound bearer validation for proxied API calls; +- `/`, `/viewer`, and `/agentmemory/viewer` viewer serving; +- `/favicon.svg`; +- CORS preflight handling; +- REST API proxying with outbound `AGENTMEMORY_SECRET` bearer. + +The root build script currently copies `src/viewer/index.html` directly to `dist/viewer/index.html`. + +Existing tests protect viewer behavior at several levels: + +- CSP and nonce behavior in `test/viewer-security.test.ts`; +- server/proxy routing in `test/viewer-server-routing.test.ts`; +- bind/host behavior in `test/viewer-host.test.ts`; +- package asset contract in `test/build-package-contract.test.ts`; +- i18n document injection in `test/viewer-i18n.test.ts`; +- VM/string tests for dashboard, timeline, memories, graph, and token-savings behavior. + +## Architecture + +### Stable Server Boundary + +The Node viewer boundary remains authoritative. + +`src/viewer/server.ts` must not import Vue, Vite, browser source modules, or dev-server middleware. It continues to serve a static HTML document and proxy REST calls exactly as it does today. + +`src/viewer/document.ts` remains small. Its responsibilities stay limited to template lookup, nonce creation, version injection, locale bundle injection, and returning the CSP generated by `buildViewerCsp()`. + +`src/auth.ts` keeps the strict CSP shape unless a later explicitly reviewed slice proves a change is necessary. The target CSP posture is: + +- `default-src 'none'`; +- nonce-backed script execution; +- `script-src-attr 'none'`; +- no `script-src 'unsafe-inline'`; +- `img-src 'self'`; +- no CDN fonts or external stylesheets. + +### Frontend Boundary + +Vue/Vite source lives under `src/viewer/app/` in the first slice. It is a browser-only layer and cannot import Node-only modules, `iii-sdk`, filesystem APIs, server modules, or runtime code that depends on them. + +Recommended structure: + +```text +src/viewer/ + app/ + index.html + main.ts + App.vue + env.ts + routes.ts + api/ + client.ts + validators.ts + types.ts + i18n/ + index.ts + realtime/ + client.ts + events.ts + state/ + viewer.ts + dashboard.ts + components/ + ViewerChrome.vue + TabBar.vue + ViewerFooter.vue + ViewerAuthPrompt.vue + ToastHost.vue + FlagBanners.vue + EmptyState.vue + pages/ + DashboardPage.vue + GraphPage.placeholder.vue + MemoriesPage.placeholder.vue + TimelinePage.placeholder.vue + SessionsPage.placeholder.vue + LessonsPage.placeholder.vue + ActionsPage.placeholder.vue + CrystalsPage.placeholder.vue + AuditPage.placeholder.vue + ActivityPage.placeholder.vue + ProfilePage.placeholder.vue + ReplayPage.placeholder.vue + styles/ + tokens.css + chrome.css + primitives.css + vite.config.ts +``` + +Use Vue Composition API and plain typed module/composable state for the first slice. Do not add Pinia or Vue Router until a concrete migrated workflow needs them. The current route contract is hash-backed tab state, which a focused `routes.ts` module can preserve with less dependency and behavior churn. + +### Routing + +The first route implementation preserves existing hash routes: + +- `#dashboard` +- `#graph` +- `#memories` +- `#timeline` +- `#sessions` +- `#lessons` +- `#actions` +- `#crystals` +- `#audit` +- `#activity` +- `#profile` +- `#replay` + +Unknown, empty, or malformed hashes normalize to `dashboard`. + +The route module may accept query-style hash parameters for migrated pages, such as `#memories?q=auth&type=pattern`, but it must not require server route changes. History-mode routing is out of scope. + +### API Client + +`src/viewer/app/api/client.ts` centralizes viewer API behavior now spread through the inline script. + +It must preserve these contracts: + +- REST base resolution from current location and existing `?port` compatibility. +- WebSocket base resolution from current location and optional `wsPort`. +- `/agentmemory/` prefixing for API calls. +- `Cache-Control: no-cache` for viewer API reads where current behavior depends on freshness. +- 10-second browser-side timeout. +- `sessionStorage` bearer token key `agentmemory-viewer-token`. +- Do not override an explicit caller-supplied `Authorization` header. +- 401 response opens the auth prompt. +- HTTP, network, and timeout errors produce safe escaped toast details. +- Browser code never learns or stores server secrets except the user-provided viewer bearer token. + +Prefer small local validators/type guards in the browser first. The repo already has `zod`, but bundling Zod into the viewer is a bundle-size and behavior decision. Use Zod in the browser only if implementation evidence shows local validators are inadequate for important endpoint responses. + +### Realtime Boundary + +Plan a dedicated realtime module even if the first slice only stubs it. + +The module should preserve: + +- direct WebSocket stream connection where available; +- fallback stream behavior; +- bounded reconnect behavior; +- polling fallback; +- typed observation/event normalization before page components consume events. + +Graph and replay behavior should not be migrated until focused tests cover current canvas/runtime behavior. + +### Visual System + +The migration preserves the current visual direction before extracting new primitives. + +Move existing design tokens and chrome behavior into CSS files: + +- paper/off-white and dark theme variables; +- black structural borders; +- red accent; +- compact uppercase tab labels; +- fixed header, tab bar, scrollable content, and footer behavior; +- bottom-right toast placement; +- current auth prompt placement; +- graph container sizing and controls when graph migrates. + +Avoid UI frameworks and broad icon/design-system dependencies in the first slice. + +## First Implementation Slice + +The first slice is a hidden/preview Vue/Vite foundation. It does not replace the shipped production viewer by default. + +Build: + +- Add Vue/Vite source under `src/viewer/app/`. +- Add `src/viewer/vite.config.ts`. +- Add a `viewer:build` script that creates a preview artifact, such as `dist/viewer-next/index.html`. +- Keep the normal root `build` shipping the existing `dist/viewer/index.html` from `src/viewer/index.html` in the first slice. +- Add package-contract tests that make the preview status explicit. + +Frontend: + +- Implement `App.vue` shell with current chrome, tab list, theme state, auth prompt, toast host, flag banner area, and footer. +- Implement typed hash routing for the current tab ids. +- Implement typed environment/bootstrap helpers. +- Implement typed API client and local validators. +- Implement Dashboard as the first migrated page against fixtures. +- Add placeholder pages for unmigrated tabs. + +Tests: + +- Keep existing viewer server/security tests passing. +- Add focused tests for route parsing, API client behavior, local validators, Dashboard rendering, and fixture browser behavior. +- Browser fixture tests must fail on uncaught exceptions and CSP console violations. + +Cutover is a later slice. It requires explicit evidence that the Vue artifact can become `dist/viewer/index.html` without loosening server/proxy/security behavior or losing tested viewer behavior. + +## Cutover Criteria + +Before the Vue app becomes the default shipped viewer: + +- The package build emits `dist/viewer/index.html` from the Vite build. +- The emitted production viewer remains a single HTML document or otherwise has an explicitly reviewed static asset/CSP design. +- No new production static routes are required unless separately reviewed. +- `renderViewerDocument()` still injects nonce, version, and locale data. +- Existing CSP tests still pass with no `script-src 'unsafe-inline'`, no inline DOM event handlers, no external scripts/styles/fonts, and no `img-src data:`. +- Browser fixture tests cover initial render, tab navigation, auth prompt, toasts, Dashboard, Memories search route behavior, and any migrated realtime behavior. +- Graph and Replay either remain behind a tested legacy adapter or have focused browser tests. + +Strict single-file artifact constraints for the preferred cutover: + +- no external Vite asset references; +- no `modulepreload`; +- no source-map leakage; +- no new `/assets/*` route; +- placeholder-compatible nonced script output; +- inline CSS compatible with the existing CSP posture. + +## Local Development + +The viewer development loop must not require a running personal agentmemory daemon. + +Target scripts: + +- `viewer:dev`: start Vite against fixture data by default. +- `viewer:dev:real`: start Vite against an explicitly supplied local REST base such as `AGENTMEMORY_VIEWER_DEV_REST=http://127.0.0.1:3111`. +- `viewer:fixtures`: start the fixture REST server used by tests/dev. +- `viewer:test`: run viewer frontend unit/component/browser fixture tests. +- `viewer:build`: build the preview or production viewer artifact for the current slice. + +Fixture mode must not read private daemon state, `~/.agentmemory`, secret files, package-manager credential config, or user environment dumps. Fixture data should be checked-in sanitized response shapes under `test/fixtures/viewer/`. + +## Testing Strategy + +### Existing Tests To Preserve + +- `test/viewer-security.test.ts` +- `test/viewer-host.test.ts` +- `test/viewer-server-routing.test.ts` +- `test/viewer-i18n.test.ts` +- `test/build-package-contract.test.ts` + +### New Focused Tests + +- Route tests for hash parsing, invalid hash fallback, tab active state, and back/forward sync. +- API client tests with mocked `fetch` for bearer attachment, explicit authorization preservation, 401 auth prompt signaling, timeout abort, escaped error details, and REST/WS URL derivation. +- Validator tests for tolerant endpoint parsing. +- Dashboard component/page tests using fixtures for success, partial failure, malformed collections, missing IDs, escaped metadata, and empty states. +- Viewer build tests asserting preview artifact constraints: placeholders, no external Vite assets, no `modulepreload`, no source-map leakage, no inline DOM handlers. +- Browser fixture tests against local HTTP, not `file://`, using the real viewer server where feasible. + +### Fixture Endpoints For Dashboard + +The first fixture server should provide minimal deterministic responses for: + +- `/agentmemory/health` +- `/agentmemory/config/flags` +- `/agentmemory/sessions` +- `/agentmemory/memories?latest=true&limit=500` +- `/agentmemory/graph/stats` +- `/agentmemory/audit?limit=5` +- `/agentmemory/semantic` +- `/agentmemory/procedural` +- `/agentmemory/relations` +- `/agentmemory/lessons` +- `/agentmemory/crystals` +- `/agentmemory/insights` + +It should also support controlled 401, 500, timeout, and malformed-response cases. + +## Dependency Intake + +Expected dependency candidates: + +- `vue` +- `vite` +- `@vitejs/plugin-vue` +- browser-test tooling only if the selected test runner requires it + +Deferred unless concrete need appears: + +- Pinia +- Vue Router +- UI component libraries +- graph/chart libraries +- icon packages +- single-file Vite plugins +- browser-bundled Zod + +Before dependency changes: + +- Verify current stable versions from official/package metadata. +- Pin direct dependencies exactly. +- Preserve `pnpm-workspace.yaml` hardening. +- Record dependency decisions as accept, reject, replace, pin differently, or defer. +- Review lifecycle scripts and build approvals before accepting them. +- Review lockfile churn for non-registry sources, unexpected maintainers, native install scripts, and broad transitive additions. + +Required gates if dependency or lockfile surfaces change: + +- OSV scan. +- Manual lockfile review. +- Semgrep for frontend/build/security-sensitive changes. +- Staged Gitleaks before commit. + +## Verification For PR Readiness + +Expected verification, unless blockers are recorded: + +- Focused viewer Vitest tests. +- Viewer build command for the active slice. +- `corepack pnpm run build`. +- `corepack pnpm test`. +- `corepack pnpm run lint`. +- `git diff --check`. +- Semgrep. +- OSV if manifests/lockfiles changed. +- `gitleaks protect --staged --redact` before commit. + +If `corepack pnpm exec`, `corepack pnpm run`, or `corepack pnpm test` is blocked by pnpm ignored-build hardening, follow repo instructions: run `corepack pnpm install --frozen-lockfile --ignore-scripts`, then retry the repo-native command. Do not approve dependency builds or treat direct `node_modules/.bin/*` calls as normal verification unless the pnpm command remains blocked and the blocker is recorded. + +## Rejected Alternatives + +Big-bang full viewer rewrite is rejected because current viewer behavior has accumulated many safety and regression tests, especially around auth, i18n, dashboard resilience, memories search, graph behavior, replay behavior, and escaping. + +Using Vite as the production server is rejected because installed users run the packaged Node CLI, and `startViewerServer()` owns production security behavior. + +Adding production static asset routes in the first slice is rejected because it expands server responsibilities and requires CSP/cache/path-hardening review. + +History-mode routing is rejected because current server routes and API path layout are hash-route friendly. + +Pinia and Vue Router are rejected for the first slice because typed composables and hash routing cover the current requirement with less dependency risk. + +Using `v-html` or quick `innerHTML` ports is rejected for migrated Vue components. User/API strings should render through Vue text interpolation or explicit escaping. + +## Terminal Workflow Contract + +This issue is currently valid. If implementation reaches PR merge readiness, the terminal approval request must explicitly bundle: + +1. merging the PR into `origin/main`; +2. archiving this Codex thread after successful merge. + +After a successful merge, call `set_thread_archived({ archived: true })` before final handoff. If merge approval, merge, or archival is unavailable, report a blocker. + +If later evidence shows the issue is invalid, stale, duplicate, or already fixed, the closure approval request must explicitly bundle GitHub issue closure and archiving this Codex thread after successful closure. diff --git a/docs/todos/2026-06-19-issue-148-viewer-vue-vite-frontend/todo.md b/docs/todos/2026-06-19-issue-148-viewer-vue-vite-frontend/todo.md new file mode 100644 index 000000000..4e3ca5efb --- /dev/null +++ b/docs/todos/2026-06-19-issue-148-viewer-vue-vite-frontend/todo.md @@ -0,0 +1,181 @@ +# Issue 148 Viewer Vue/Vite Frontend + +Scope: repository worktree `/Users/A1538552/.codex/worktrees/b2b7/agentmemory` + +Branch: `issue/148-viewer-vue-vite-frontend` + +Issue: https://github.com/wbugitlab1/agentmemory/issues/148 + +## Sprint Contract + +Goal: refactor the runtime viewer toward a typed Vue 3 + Vite frontend while preserving the existing viewer server/proxy/security model and current visual direction. + +Scope: +- Viewer frontend source and build integration under `src/viewer/`. +- Viewer document/server handoff that currently loads and serves `src/viewer/index.html`. +- Focused viewer tests, build/package contract tests, and task-local docs. +- Dependency/package metadata only when needed for Vue/Vite/browser-test tooling, with dependency intake recorded before changes. + +Non-goals: +- Do not replace iii-engine, REST, stream, auth, or viewer proxy architecture. +- Do not redesign the viewer visually from scratch. +- Do not target, push, or prepare a PR against `https://github.com/rohitg00/agentmemory/`. +- Do not touch parent checkout or unrelated issue 821-830 worktrees. +- Do not close the issue, push, create/merge a PR, or archive this thread without explicit current-turn approval. + +Acceptance criteria: +- The viewer has typed Vue/Vite frontend boundaries or an approved first migration slice that materially advances that end state. +- Existing viewer server/proxy/security behavior is preserved. +- The runtime viewer still renders through the package build output. +- Route-backed page/tab state, typed API boundary, and fixture/browser-test strategy are either implemented in the selected slice or explicitly staged in the saved plan. +- Tests cover the changed viewer build/runtime surface and preserve current security invariants. + +Intended verification: +- Focused Vitest for viewer document/server/package contract changes. +- Focused viewer/browser smoke verification when a runnable frontend is available. +- `corepack pnpm run build` or the closest targeted build command covering viewer assets. +- `corepack pnpm test` before PR readiness unless a blocker is recorded. +- `git diff --check`. +- Required security gates for code/dependency/tooling changes: Semgrep; OSV if dependency/lockfile surfaces change; staged Gitleaks before commit. + +Known boundaries: +- Public unauthenticated issue reads are allowed; credentialed GitHub reads require explicit approval if needed. +- Fetch, pull, push, PR creation, PR merge, issue closure, thread archival, publishing, deployment, migrations, destructive cleanup, and remote/project/account changes require explicit current-turn approval. +- Terminal archival contract: if this valid issue reaches a successful PR merge, the merge approval request must bundle both PR merge into `origin/main` and archiving this Codex thread after successful merge. After successful merge, call `set_thread_archived({ archived: true })` before final handoff. If merge approval, merge, or archival is unavailable, report a blocker. + +Stop conditions: +- Branch creation or branch safety evidence becomes invalid. +- The issue is found duplicate/stale/already fixed. +- Implementation would require changing backend architecture, auth/security behavior, persistence/schema, or remote state without approval. +- Dependency intake finds unacceptable supply-chain risk. +- Required verification cannot run and no targeted substitute is adequate. + +## Validity Evidence + +- 2026-06-19: Initial `git status -sb --untracked-files=all` showed detached clean `HEAD`. +- 2026-06-19: Local `HEAD` and local `origin/main` both resolved to `eacce17ea273b2d07ab120d910f3685d8f42cc48`. +- 2026-06-19: No local or tracked `origin/issue/148-viewer-vue-vite-frontend` branch existed before branch creation. +- 2026-06-19: Created and switched to local branch `issue/148-viewer-vue-vite-frontend` from that HEAD. +- 2026-06-19: `git remote -v` confirmed `origin` is `https://github.com/wbugitlab1/agentmemory.git`; `upstream` points at `https://github.com/rohitg00/agentmemory.git` but was not used. +- 2026-06-19: Public unauthenticated GitHub API read showed issue #148 is open, has 0 comments, and mirrors upstream issue 336. +- 2026-06-19: Local viewer remains a single large `src/viewer/index.html` file with inline HTML/CSS/JS; `wc -l` reported 4,644 lines. +- 2026-06-19: `package.json` build script copies `src/viewer/index.html` directly into `dist/viewer/`. +- 2026-06-19: `src/viewer/document.ts` loads the static viewer template and injects nonce, version, and locale data. +- 2026-06-19: `src/viewer/server.ts` owns the existing viewer server/proxy/security model that must be preserved. +- 2026-06-19: No Vue/Vite viewer app structure, viewer Vite config, Playwright config, `viewer:dev:*`, or `viewer:test` scripts were found. + +Validity status: valid for design and implementation planning. + +Residual uncertainty: +- Local remote-tracking refs were not freshly fetched in this thread. +- No credentialed GitHub reads were performed. +- Related viewer branches/tasks exist, but local evidence found no implemented Vue/Vite viewer migration. + +## Feature / Verification Matrix + +| Change | Verification method | Status | Evidence | +| --- | --- | --- | --- | +| Validate issue #148 | Public issue read and local viewer/build inspection | Done | Issue is open; current viewer remains static single-file HTML copied by the build. | +| Preserve branch/worktree safety | Git status, branch-ref checks, remote inspection | Done | Clean branch `issue/148-viewer-vue-vite-frontend` created from local `origin/main` SHA `eacce17e`. | +| Arena migration design | Candidate designs, cross-judge, synthesis note | Done | Candidate B selected as base; A/C grafts synthesized into `spec.md`. | +| Implementation plan | Saved `plan.md` with file paths, tests, dependency intake, and PR-prep boundaries | Done | Plan saved and self-reviewed against spec/repo evidence before implementation edits. | +| Viewer migration implementation | TDD and focused viewer build/runtime tests | Done | Vue/Vite preview slice implemented; production viewer remains legacy default. | +| GitHub PR preparation | `$github-feature-loop` and mandatory local PR-prep phase | Pending | Remote writes require explicit current-turn approval. | + +## Arena Ledger + +| Phase | Status | Evidence | +| --- | --- | --- | +| Frame | Done | Artifact: migration design/spec. Rubric covered server/proxy/CSP/package preservation, typed Vue/Vite boundaries, incremental first slice, fixture/browser DX, dependency/security gates, and plan concreteness. | +| Fan out | Done | Candidate A, B, and C wrote proposals under `/tmp/arena-issue148-viewer-vue-vite-frontend/`; no repo source edits. | +| Cross-judge | Done | Judge scored B highest (30), A second (29), C third (26). | +| Pick | Done | Candidate B selected as base because it preserves shipped legacy viewer initially and defines explicit cutover criteria. | +| Graft | Done | Grafted A's strict single-file production artifact/CSP tests, local validator caution, and detailed fixture endpoints; grafted C's realtime boundary, CSP console-failure browser checks, and visual-token migration guidance. | +| Verify | Done | `spec.md` and `plan.md` were checked against local viewer/server/package evidence; plan tightened to require a single-file preview artifact and SFC shim up front. | + +## Subagent Ledger + +| Workstream | Scope | Edits allowed | Expected output | Result | Residual risk | +| --- | --- | --- | --- | --- | --- | +| Arena candidates | Design proposal only; no repo source edits | No | Candidate migration designs with rationale | Complete: three proposals written under `/tmp/arena-issue148-viewer-vue-vite-frontend/`. | Candidate quality depends on prompt specificity. | +| Arena judge | Candidate design review only | No | Rubric scores and base recommendation | Complete: recommended Candidate B as base, with grafts from A and C. | Judge did not run tests or perform dependency lookup; parent must verify before implementation. | +| Pre-implementation reviewers | Plan/spec review only | No | High/Medium findings before code edits | Complete: three reviewers found a valid proxy-boundary blocker plus shell/browser/type/dependency gaps. | Browser fixture automation remains a known follow-up because no repo-native browser test dependency is currently approved. | + +## Review Triage + +| Finding | Classification | Action | +| --- | --- | --- | +| Planned `resolveViewerUrls()` bypassed viewer proxy and called REST port directly | fixed | Updated `plan.md` to preserve legacy viewer-origin API base and 3111-to-3113 compatibility. | +| Vite HTML entry used `/src/main.ts` under app root | fixed | Updated `plan.md` to use `/main.ts`. | +| Vue was planned as a production dependency | fixed | Updated `plan.md` to keep Vue/Vite/SFC tooling in `devDependencies`. | +| Dependency install commands used ambient credential environment | fixed | Updated `plan.md` to use sanitized `env -u ... corepack pnpm install ...` commands. | +| API timeout behavior lacked a test | fixed | Added planned API timeout/abort test coverage. | +| Shell components listed but not wired | fixed | Added planned shell state, tab, toast, auth prompt, flag, empty state, footer, and theme wiring tasks. | +| Vue SFC lint/type coverage absent | fixed | Added `vue-tsc@3.3.5`, `viewer:typecheck`, and app-local tsconfig plan. | +| Automated browser fixture tests absent | accepted_risk | Manual local browser smoke remains required; automated fixture CI is documented as deferred because adding Playwright/browser dependencies broadens this first slice. | +| Preview bootstrap kept locale placeholder in a side-channel string | fixed | Updated preview HTML to assign `window.__AM_LOCALE__` through a static-preview-safe placeholder fallback, removed favicon link, and strengthened build test placeholder replacement assertions. | +| API client dropped explicit authorization when `init.headers` used standard `Headers`/tuple forms | fixed | Normalized request headers with `Headers`, preserved caller authorization, and added coverage for `Headers` and tuple-array inputs. | + +## Arena Synthesis + +Base: Candidate B. + +Reason: Candidate B best fits the current repo contract by introducing Vue/Vite as a hidden/preview path first, keeping the shipped `src/viewer/index.html` and `dist/viewer/index.html` behavior stable during the initial slice, and defining cutover criteria before the Vue artifact becomes the production viewer. + +Grafts: +- From Candidate A: strict single-file production artifact constraints for the eventual cutover, including no new static routes, no external Vite assets, no `modulepreload`, no source-map leakage, and placeholder-compatible nonced script output. +- From Candidate A: focused test split for viewer build, API client, route state, and fixture browser behavior. +- From Candidate A: prefer small local browser validators/type guards first; defer bundling Zod into the viewer unless implementation proves it is worth the bundle and behavior cost. +- From Candidate A: use explicit Dashboard fixture endpoints for first browser tests. +- From Candidate C: include a planned realtime module for direct WebSocket, fallback stream, bounded reconnect, and polling fallback. +- From Candidate C: browser fixture tests must fail on CSP console violations and uncaught exceptions. +- From Candidate C: migrate current visual tokens, chrome layout, and theme behavior before redesigning components. + +Rejected: +- Candidate A's option to flip default serving in the first slice is rejected as too optimistic for a 4,644-line legacy viewer unless parity evidence is substantially stronger than expected. +- Candidate B's initial browser-Zod suggestion is narrowed to local validators first, because the frontend bundle and tolerant legacy behavior matter. +- Candidate C's CSP wording was inaccurate: current CSP includes `default-src 'none'`. + +## Approval Notes + +- No current approval exists for dependency installation, dependency changes, fetch, pull, push, PR creation, PR merge, issue closure, thread archival, publishing, deployment, migration, destructive cleanup, credentialed/session actions, or remote/project/account state changes. +- If later invalid/stale/duplicate/already fixed evidence appears, the closure approval request must explicitly bundle GitHub issue closure and archiving this Codex thread after successful closure. +- If the issue remains valid through PR completion, the terminal merge approval request must explicitly bundle PR merge into `origin/main` and archiving this Codex thread after successful merge. + +## Progress Notes + +- 2026-06-19: Dependency manifest accepted `vue@3.5.38`, `vite@8.0.16`, `@vitejs/plugin-vue@6.0.7`, and `vue-tsc@3.3.5` as exact devDependencies. Lockfile materialization used sanitized `env -u ... corepack pnpm install ... --ignore-scripts`; lifecycle build approvals were not granted. +- 2026-06-19: Added a hidden Vue/Vite preview app under `src/viewer/app/`, built by `scripts/build-viewer.ts` into `dist/viewer-next/index.html`; legacy `src/viewer/index.html` remains the shipped `dist/viewer/index.html`. +- 2026-06-19: Preserved the viewer proxy boundary in `resolveViewerUrls()`: browser API calls resolve to the viewer origin/port, with legacy `?port=3111` to viewer `3113` compatibility and WebSocket defaulting to viewer port minus one. +- 2026-06-19: Browser smoke verified Vue/Vite preview at `http://127.0.0.1:8137/dist/viewer-next/index.html?smoke=5#dashboard`; screenshot `/Users/A1538552/.codex/worktrees/b2b7/agentmemory/issue-148-viewer-preview.png` inspected with `view_image`; fresh tab console reported 0 errors and 0 warnings. +- 2026-06-19: Residual risk: automated browser fixture CI is deferred; no Playwright/browser package dependency was added in this slice. + +## Verification Evidence + +| Check | Result | Evidence | +| --- | --- | --- | +| `corepack pnpm run viewer:test` | Pass | 4 files, 20 tests passed. | +| `corepack pnpm run viewer:typecheck` | Pass | `vue-tsc --noEmit -p src/viewer/app/tsconfig.json` exited 0. | +| `corepack pnpm exec vitest run test/build-package-contract.test.ts` | Pass | 1 file, 5 tests passed. | +| `corepack pnpm run build` | Pass | Built TS outputs, `dist/viewer-next/index.html`, and legacy `dist/viewer/index.html`; existing tsdown plugin-timing/dynamic-import warnings observed. | +| Focused legacy viewer contracts | Pass | `test/viewer-security.test.ts test/viewer-host.test.ts test/viewer-server-routing.test.ts test/viewer-i18n.test.ts test/build-package-contract.test.ts`: 5 files, 77 tests passed. | +| `corepack pnpm run lint` | Pass | Repo JS/TS lint exited 0; Vue SFC coverage handled by `viewer:typecheck`. | +| `corepack pnpm test` | Pass | 206 files, 2817 tests passed. | +| `git diff --check` / `git diff --cached --check` | Pass | No whitespace errors. | +| `osv-scanner scan source .` | Pass | 472 packages scanned from `pnpm-lock.yaml`; one existing waiver filtered; no issues found. | +| `semgrep scan --config p/default --error --metrics=off .` | Pass | 917 tracked files scanned, 0 findings. | +| Targeted Semgrep on new viewer paths | Pass | 33 files scanned, 0 findings after replacing a test-only ``, + ); +} + +function readBuiltAsset(outDir: string, href: string): string { + return readFileSync(join(outDir, href.replace(/^\//, "")), "utf8"); +} + +function removeInlinedAssets(outDir: string): void { + for (const entry of readdirSync(outDir)) { + if (entry !== "index.html") { + rmSync(join(outDir, entry), { recursive: true, force: true }); + } + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await buildViewerPreview(); +} diff --git a/src/viewer/app/App.vue b/src/viewer/app/App.vue new file mode 100644 index 000000000..beda3c448 --- /dev/null +++ b/src/viewer/app/App.vue @@ -0,0 +1,60 @@ + + + diff --git a/src/viewer/app/api/client.ts b/src/viewer/app/api/client.ts new file mode 100644 index 000000000..416f3ce54 --- /dev/null +++ b/src/viewer/app/api/client.ts @@ -0,0 +1,108 @@ +export type ViewerApiToast = { + title: string; + message: string; +}; + +export type ViewerApiClientOptions = { + restBase: string; + storage: Pick | Map; + onAuthRequired: () => void; + onToast: (toast: ViewerApiToast) => void; + timeoutMs?: number; +}; + +export class ViewerApiError extends Error { + constructor( + message: string, + public readonly status: number | null, + ) { + super(message); + this.name = "ViewerApiError"; + } +} + +function storageGet(storage: ViewerApiClientOptions["storage"], key: string): string | null { + return storage instanceof Map ? storage.get(key) ?? null : storage.getItem(key); +} + +function escapeText(value: string): string { + return value.replace(/[&<>"']/g, (ch) => { + switch (ch) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case "\"": + return """; + default: + return "'"; + } + }); +} + +function buildUrl(restBase: string, path: string): string { + const normalized = path.startsWith("/") ? path : `/agentmemory/${path}`; + const apiPath = normalized.startsWith("/agentmemory") ? normalized : `/agentmemory${normalized}`; + return `${restBase}${apiPath}`; +} + +function requestHeaders( + initHeaders: HeadersInit | undefined, + storage: ViewerApiClientOptions["storage"], +): Headers { + const headers = new Headers(initHeaders); + headers.set("Cache-Control", "no-cache"); + const token = storageGet(storage, "agentmemory-viewer-token"); + if (token && !headers.has("Authorization")) { + headers.set("Authorization", `Bearer ${token}`); + } + return headers; +} + +export function createViewerApiClient(options: ViewerApiClientOptions) { + const timeoutMs = options.timeoutMs ?? 10_000; + + async function requestJson(path: string, init: RequestInit = {}): Promise { + const headers = requestHeaders(init.headers, options.storage); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(buildUrl(options.restBase, path), { + ...init, + headers, + signal: controller.signal, + }); + if (response.status === 401) options.onAuthRequired(); + if (!response.ok) { + const message = `HTTP ${response.status} while loading ${escapeText(path)}`; + options.onToast({ title: "Viewer API error", message }); + throw new ViewerApiError(message, response.status); + } + return await response.json(); + } catch (error) { + if (error instanceof ViewerApiError) throw error; + const message = + error instanceof Error && error.name === "AbortError" + ? `Request timed out while loading ${escapeText(path)}` + : `Network error while loading ${escapeText(path)}`; + options.onToast({ title: "Viewer API error", message }); + throw new ViewerApiError(message, null); + } finally { + clearTimeout(timeout); + } + } + + return { + getJson: (path: string, init: RequestInit = {}) => + requestJson(path, { ...init, method: "GET" }), + postJson: (path: string, body: unknown, init: RequestInit = {}) => + requestJson(path, { + ...init, + method: "POST", + headers: new Headers([["Content-Type", "application/json"], ...new Headers(init.headers)]), + body: JSON.stringify(body), + }), + }; +} diff --git a/src/viewer/app/api/types.ts b/src/viewer/app/api/types.ts new file mode 100644 index 000000000..43f5203fc --- /dev/null +++ b/src/viewer/app/api/types.ts @@ -0,0 +1,22 @@ +export type ViewerSession = { + id: string; + project?: string; + status?: string; + startedAt?: string; + endedAt?: string | null; +}; + +export type ViewerMemory = { + id: string; + content: string; + type?: string; + project?: string; + timestamp?: string; +}; + +export type DashboardData = { + health: Record | null; + sessions: ViewerSession[]; + memories: ViewerMemory[]; + failedEndpoints: string[]; +}; diff --git a/src/viewer/app/api/validators.ts b/src/viewer/app/api/validators.ts new file mode 100644 index 000000000..78b2b73cd --- /dev/null +++ b/src/viewer/app/api/validators.ts @@ -0,0 +1,42 @@ +import type { ViewerMemory, ViewerSession } from "./types.js"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function toSession(value: unknown): ViewerSession | null { + if (!isRecord(value) || typeof value.id !== "string" || !value.id) return null; + return { + id: value.id, + project: typeof value.project === "string" ? value.project : undefined, + status: typeof value.status === "string" ? value.status : undefined, + startedAt: typeof value.startedAt === "string" ? value.startedAt : undefined, + endedAt: + typeof value.endedAt === "string" || value.endedAt === null ? value.endedAt : undefined, + }; +} + +export function toMemory(value: unknown): ViewerMemory | null { + if (!isRecord(value) || typeof value.id !== "string") return null; + const content = typeof value.content === "string" ? value.content : ""; + return { + id: value.id, + content, + type: typeof value.type === "string" ? value.type : undefined, + project: typeof value.project === "string" ? value.project : undefined, + timestamp: typeof value.timestamp === "string" ? value.timestamp : undefined, + }; +} + +export function listFromEnvelope( + value: unknown, + keys: string[], + convert: (item: unknown) => T | null, +): T[] { + const source = isRecord(value) + ? keys.map((key) => value[key]).find(Array.isArray) + : Array.isArray(value) + ? value + : []; + return (source as unknown[]).map(convert).filter((item): item is T => item !== null); +} diff --git a/src/viewer/app/components/EmptyState.vue b/src/viewer/app/components/EmptyState.vue new file mode 100644 index 000000000..7945be519 --- /dev/null +++ b/src/viewer/app/components/EmptyState.vue @@ -0,0 +1,10 @@ + + + diff --git a/src/viewer/app/components/FlagBanners.vue b/src/viewer/app/components/FlagBanners.vue new file mode 100644 index 000000000..880709ea9 --- /dev/null +++ b/src/viewer/app/components/FlagBanners.vue @@ -0,0 +1,9 @@ + + + diff --git a/src/viewer/app/components/TabBar.vue b/src/viewer/app/components/TabBar.vue new file mode 100644 index 000000000..28edffe12 --- /dev/null +++ b/src/viewer/app/components/TabBar.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/viewer/app/components/ToastHost.vue b/src/viewer/app/components/ToastHost.vue new file mode 100644 index 000000000..1f28a9ba9 --- /dev/null +++ b/src/viewer/app/components/ToastHost.vue @@ -0,0 +1,14 @@ + + + diff --git a/src/viewer/app/components/ViewerAuthPrompt.vue b/src/viewer/app/components/ViewerAuthPrompt.vue new file mode 100644 index 000000000..278e4c47a --- /dev/null +++ b/src/viewer/app/components/ViewerAuthPrompt.vue @@ -0,0 +1,9 @@ + + + diff --git a/src/viewer/app/components/ViewerFooter.vue b/src/viewer/app/components/ViewerFooter.vue new file mode 100644 index 000000000..c2e8a7341 --- /dev/null +++ b/src/viewer/app/components/ViewerFooter.vue @@ -0,0 +1,10 @@ + + + diff --git a/src/viewer/app/css.d.ts b/src/viewer/app/css.d.ts new file mode 100644 index 000000000..cbe652dbe --- /dev/null +++ b/src/viewer/app/css.d.ts @@ -0,0 +1 @@ +declare module "*.css"; diff --git a/src/viewer/app/env.ts b/src/viewer/app/env.ts new file mode 100644 index 000000000..9ff1e8f63 --- /dev/null +++ b/src/viewer/app/env.ts @@ -0,0 +1,41 @@ +export type ViewerUrls = { + restBase: string; + wsBase: string; +}; + +function numericPort(value: string | null): number | null { + if (!value) return null; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +export function resolveViewerUrls(location: URL): ViewerUrls { + const params = location.searchParams; + const explicitViewerPort = numericPort(params.get("port")); + const explicitWsPort = numericPort(params.get("wsPort")); + const currentPort = numericPort(location.port); + const resolvedViewerPort = + explicitViewerPort === 3111 + ? 3113 + : explicitViewerPort ?? (currentPort === 3111 ? 3113 : currentPort); + const restBase = resolvedViewerPort + ? `${location.protocol}//${location.hostname}:${resolvedViewerPort}` + : location.origin; + const wsPort = explicitWsPort ?? (resolvedViewerPort ? resolvedViewerPort - 1 : null); + const wsProtocol = location.protocol === "https:" ? "wss:" : "ws:"; + const wsBase = wsPort ? `${wsProtocol}//${location.hostname}:${wsPort}` : `${wsProtocol}//${location.host}`; + return { restBase, wsBase }; +} + +export function viewerVersion(): string { + return typeof window !== "undefined" && typeof window.__AM_VERSION__ === "string" + ? window.__AM_VERSION__ + : "development"; +} + +declare global { + interface Window { + __AM_VERSION__?: string; + __AM_LOCALE__?: Record; + } +} diff --git a/src/viewer/app/i18n/index.ts b/src/viewer/app/i18n/index.ts new file mode 100644 index 000000000..21fd97244 --- /dev/null +++ b/src/viewer/app/i18n/index.ts @@ -0,0 +1,9 @@ +export type ViewerLocale = Record; + +export function localeFromWindow(): ViewerLocale { + return typeof window !== "undefined" && window.__AM_LOCALE__ ? window.__AM_LOCALE__ : {}; +} + +export function translate(locale: ViewerLocale, key: string, fallback = key): string { + return locale[key] ?? fallback; +} diff --git a/src/viewer/app/index.html b/src/viewer/app/index.html new file mode 100644 index 000000000..882179184 --- /dev/null +++ b/src/viewer/app/index.html @@ -0,0 +1,16 @@ + + + + + + agentmemory viewer preview + + +
+ + + + diff --git a/src/viewer/app/main.ts b/src/viewer/app/main.ts new file mode 100644 index 000000000..29e292dae --- /dev/null +++ b/src/viewer/app/main.ts @@ -0,0 +1,7 @@ +import { createApp } from "vue"; +import App from "./App.vue"; +import "./styles/tokens.css"; +import "./styles/chrome.css"; +import "./styles/primitives.css"; + +createApp(App).mount("#app"); diff --git a/src/viewer/app/pages/DashboardPage.vue b/src/viewer/app/pages/DashboardPage.vue new file mode 100644 index 000000000..b22a2d6a6 --- /dev/null +++ b/src/viewer/app/pages/DashboardPage.vue @@ -0,0 +1,36 @@ + + + diff --git a/src/viewer/app/pages/PlaceholderPage.vue b/src/viewer/app/pages/PlaceholderPage.vue new file mode 100644 index 000000000..a9cbd56e4 --- /dev/null +++ b/src/viewer/app/pages/PlaceholderPage.vue @@ -0,0 +1,10 @@ + + + diff --git a/src/viewer/app/realtime/client.ts b/src/viewer/app/realtime/client.ts new file mode 100644 index 000000000..12aaf1126 --- /dev/null +++ b/src/viewer/app/realtime/client.ts @@ -0,0 +1,15 @@ +import type { ViewerRealtimeEvent } from "./events.js"; + +export type ViewerRealtimeClient = { + connect: () => void; + disconnect: () => void; +}; + +export function createNoopRealtimeClient( + _onEvent: (event: ViewerRealtimeEvent) => void, +): ViewerRealtimeClient { + return { + connect: () => {}, + disconnect: () => {}, + }; +} diff --git a/src/viewer/app/realtime/events.ts b/src/viewer/app/realtime/events.ts new file mode 100644 index 000000000..08c1eaf70 --- /dev/null +++ b/src/viewer/app/realtime/events.ts @@ -0,0 +1,5 @@ +export type ViewerRealtimeEvent = + | { type: "connected" } + | { type: "disconnected" } + | { type: "memory"; id: string } + | { type: "session"; id: string }; diff --git a/src/viewer/app/routes.ts b/src/viewer/app/routes.ts new file mode 100644 index 000000000..fc00bdb1d --- /dev/null +++ b/src/viewer/app/routes.ts @@ -0,0 +1,48 @@ +export const TAB_DEFINITIONS = [ + { id: "dashboard", labelKey: "nav.dashboard" }, + { id: "graph", labelKey: "nav.graph" }, + { id: "memories", labelKey: "nav.memories" }, + { id: "timeline", labelKey: "nav.timeline" }, + { id: "sessions", labelKey: "nav.sessions" }, + { id: "lessons", labelKey: "nav.lessons" }, + { id: "actions", labelKey: "nav.actions" }, + { id: "crystals", labelKey: "nav.crystals" }, + { id: "audit", labelKey: "nav.audit" }, + { id: "activity", labelKey: "nav.activity" }, + { id: "profile", labelKey: "nav.profile" }, + { id: "replay", labelKey: "nav.replay" }, +] as const; + +export type ViewerTab = (typeof TAB_DEFINITIONS)[number]["id"]; + +export type ViewerRoute = { + tab: ViewerTab; + query?: Record; +}; + +const TABS = new Set(TAB_DEFINITIONS.map((tab) => tab.id)); + +export function normalizeTab(value: string | null | undefined): ViewerTab { + const tab = String(value ?? "").replace(/^#/, "").split("?")[0]!.toLowerCase(); + return TABS.has(tab) ? (tab as ViewerTab) : "dashboard"; +} + +export function parseHash(hash: string): ViewerRoute { + const raw = hash.replace(/^#/, ""); + const [tabPart, queryPart] = raw.split("?"); + const tab = normalizeTab(tabPart); + const query: Record = {}; + if (queryPart) { + const params = new URLSearchParams(queryPart); + for (const [key, value] of params) { + if (value) query[key] = value; + } + } + return Object.keys(query).length > 0 ? { tab, query } : { tab }; +} + +export function buildHash(route: ViewerRoute): string { + const params = new URLSearchParams(route.query ?? {}); + const suffix = params.size > 0 ? `?${params.toString()}` : ""; + return `#${route.tab}${suffix}`; +} diff --git a/src/viewer/app/state/dashboard.ts b/src/viewer/app/state/dashboard.ts new file mode 100644 index 000000000..13e93e567 --- /dev/null +++ b/src/viewer/app/state/dashboard.ts @@ -0,0 +1,22 @@ +import type { DashboardData } from "../api/types.js"; +import { listFromEnvelope, toMemory, toSession } from "../api/validators.js"; + +function failedEndpointsFrom(value: Record): string[] { + return Array.isArray(value.failedEndpoints) + ? value.failedEndpoints.filter((item): item is string => typeof item === "string") + : []; +} + +export function buildDashboardData(value: Record): DashboardData { + const failedEndpoints = failedEndpointsFrom(value); + if (value.health === null) failedEndpoints.push("health"); + return { + health: + typeof value.health === "object" && value.health !== null && !Array.isArray(value.health) + ? (value.health as Record) + : null, + sessions: listFromEnvelope(value.sessions, ["sessions", "items"], toSession), + memories: listFromEnvelope(value.memories, ["memories", "items"], toMemory), + failedEndpoints: [...new Set(failedEndpoints)], + }; +} diff --git a/src/viewer/app/state/viewer.ts b/src/viewer/app/state/viewer.ts new file mode 100644 index 000000000..22baf087c --- /dev/null +++ b/src/viewer/app/state/viewer.ts @@ -0,0 +1,21 @@ +import type { ViewerTab } from "../routes.js"; + +export type ViewerTheme = "light" | "dark"; + +export type ViewerToast = { + id: string; + title: string; + message: string; +}; + +export type ViewerShellState = { + activeTab: ViewerTab; + theme: ViewerTheme; + toasts: ViewerToast[]; + authRequired: boolean; + flags: string[]; +}; + +export function preferredTheme(storage: Pick | null): ViewerTheme { + return storage?.getItem("agentmemory-theme") === "dark" ? "dark" : "light"; +} diff --git a/src/viewer/app/styles/chrome.css b/src/viewer/app/styles/chrome.css new file mode 100644 index 000000000..7687325e1 --- /dev/null +++ b/src/viewer/app/styles/chrome.css @@ -0,0 +1,51 @@ +.viewer-shell { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--bg); +} + +.viewer-shell[data-theme="dark"] { + --bg: #181816; + --bg-alt: #252522; + --border-heavy: #f4f4ef; + --ink: #f4f4ef; + --ink-secondary: #e0e0da; + --ink-muted: #a9a99f; + --accent: #ff4b4b; +} + +.viewer-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border-bottom: 4px solid var(--border-heavy); + padding: 10px 24px; + background: var(--bg); +} + +.viewer-brand { + display: flex; + align-items: baseline; + gap: 10px; +} + +.viewer-brand h1 { + margin: 0; + color: var(--ink); + font-family: var(--font-display); + font-size: 24px; +} + +.viewer-brand span { + color: var(--ink-muted); + font-family: var(--font-ui); + font-size: 11px; + text-transform: uppercase; +} + +.viewer-content { + flex: 1; + padding: 24px; +} diff --git a/src/viewer/app/styles/primitives.css b/src/viewer/app/styles/primitives.css new file mode 100644 index 000000000..4eaf7205a --- /dev/null +++ b/src/viewer/app/styles/primitives.css @@ -0,0 +1,99 @@ +.viewer-button { + border: 2px solid var(--border-heavy); + background: var(--bg); + color: var(--ink); + cursor: pointer; + font-family: var(--font-ui); + padding: 4px 8px; +} + +.tab-bar { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px 24px; + border-bottom: 2px solid var(--border-heavy); + background: var(--bg-alt); +} + +.tab-bar a { + color: var(--ink-muted); + font-family: var(--font-ui); + font-size: 11px; + text-decoration: none; + text-transform: uppercase; +} + +.tab-bar a.active { + color: var(--accent); +} + +.viewer-warning { + border: 2px solid var(--accent); + padding: 10px 12px; + margin-bottom: 16px; + font-family: var(--font-ui); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; +} + +.stat-card { + border: 2px solid var(--border-heavy); + padding: 12px; + background: var(--bg); +} + +.stat-label { + display: block; + color: var(--ink-muted); + font-family: var(--font-ui); + font-size: 11px; + text-transform: uppercase; +} + +.viewer-list { + padding-left: 18px; +} + +.toast-host { + position: fixed; + right: 16px; + bottom: 16px; + display: grid; + gap: 8px; + max-width: min(360px, calc(100vw - 32px)); +} + +.viewer-toast, +.auth-prompt, +.flag-banners, +.empty-state { + border: 2px solid var(--border-heavy); + background: var(--bg); + color: var(--ink-secondary); + font-family: var(--font-ui); + padding: 10px 12px; +} + +.empty-state { + display: grid; + gap: 4px; +} + +.flag-banners { + border-width: 0 0 2px; +} + +.viewer-footer { + display: flex; + justify-content: space-between; + border-top: 2px solid var(--border-heavy); + padding: 8px 24px; + font-family: var(--font-ui); + font-size: 11px; + text-transform: uppercase; +} diff --git a/src/viewer/app/styles/tokens.css b/src/viewer/app/styles/tokens.css new file mode 100644 index 000000000..b95045d02 --- /dev/null +++ b/src/viewer/app/styles/tokens.css @@ -0,0 +1,25 @@ +:root { + --bg: #f9f9f7; + --bg-alt: #f0f0ec; + --border-heavy: #111111; + --ink: #111111; + --ink-secondary: #333333; + --ink-muted: #666666; + --accent: #cc0000; + --font-display: Georgia, "Times New Roman", serif; + --font-body: Georgia, serif; + --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +html, +body, +#app { + margin: 0; + min-height: 100%; +} + +body { + background: var(--bg); + color: var(--ink-secondary); + font-family: var(--font-body); +} diff --git a/src/viewer/app/tsconfig.json b/src/viewer/app/tsconfig.json new file mode 100644 index 000000000..186799ffa --- /dev/null +++ b/src/viewer/app/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "types": ["node"] + }, + "include": ["./**/*.ts", "./**/*.vue"] +} diff --git a/src/viewer/app/vue-shim.d.ts b/src/viewer/app/vue-shim.d.ts new file mode 100644 index 000000000..1f6de9a72 --- /dev/null +++ b/src/viewer/app/vue-shim.d.ts @@ -0,0 +1,6 @@ +declare module "*.vue" { + import type { DefineComponent } from "vue"; + + const component: DefineComponent, Record, unknown>; + export default component; +} diff --git a/src/viewer/vite.config.ts b/src/viewer/vite.config.ts new file mode 100644 index 000000000..c0dd28262 --- /dev/null +++ b/src/viewer/vite.config.ts @@ -0,0 +1,26 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import vue from "@vitejs/plugin-vue"; +import { defineConfig } from "vite"; + +const viewerDir = dirname(fileURLToPath(import.meta.url)); +const viewerRoot = resolve(viewerDir, "app"); + +export default defineConfig({ + root: viewerRoot, + plugins: [vue()], + build: { + outDir: resolve(viewerDir, "..", "..", "dist", "viewer-next"), + emptyOutDir: true, + modulePreload: false, + sourcemap: false, + cssCodeSplit: false, + rollupOptions: { + input: resolve(viewerRoot, "index.html"), + output: { + entryFileNames: "viewer.js", + assetFileNames: "viewer.[ext]", + }, + }, + }, +}); diff --git a/test/build-package-contract.test.ts b/test/build-package-contract.test.ts index 2d57cee41..5ec7c4135 100644 --- a/test/build-package-contract.test.ts +++ b/test/build-package-contract.test.ts @@ -8,6 +8,8 @@ const pluginRoot = join(repoRoot, "plugin"); type PackageJson = { bin?: Record; + dependencies?: Record; + devDependencies?: Record; exports?: Record; files?: string[]; scripts?: Record; @@ -100,8 +102,18 @@ describe("build and package output contract", () => { expect(files.has("dist/")).toBe(true); expect(files.has("plugin/")).toBe(true); expect(buildScript).toContain("mkdir -p dist/viewer"); + expect(buildScript).toContain("corepack pnpm run viewer:build"); expect(buildScript).toContain("cp src/viewer/index.html dist/viewer/"); expect(buildScript).toContain("cp src/viewer/favicon.svg dist/viewer/"); + expect(pkg.scripts?.["viewer:build"]).toBe("tsx scripts/build-viewer.ts"); + expect(pkg.scripts?.["viewer:test"]).toBe( + "vitest run test/viewer-app-routes.test.ts test/viewer-app-api-client.test.ts test/viewer-app-dashboard.test.ts test/viewer-vite-build.test.ts", + ); + expect(pkg.scripts?.["viewer:typecheck"]).toBe( + "vue-tsc --noEmit -p src/viewer/app/tsconfig.json", + ); + expect(pkg.dependencies?.vue).toBeUndefined(); + expect(pkg.devDependencies?.vue).toBe("3.5.38"); }); it("hook scripts referenced by plugin manifests are generated into dist/hooks and plugin/scripts", () => { diff --git a/test/fixtures/viewer/dashboard-partial-failure.json b/test/fixtures/viewer/dashboard-partial-failure.json new file mode 100644 index 000000000..55b3e4f28 --- /dev/null +++ b/test/fixtures/viewer/dashboard-partial-failure.json @@ -0,0 +1,6 @@ +{ + "health": null, + "sessions": { "sessions": [] }, + "memories": { "memories": [] }, + "failedEndpoints": ["health"] +} diff --git a/test/fixtures/viewer/dashboard.json b/test/fixtures/viewer/dashboard.json new file mode 100644 index 000000000..5cbc93a06 --- /dev/null +++ b/test/fixtures/viewer/dashboard.json @@ -0,0 +1,31 @@ +{ + "health": { "ok": true, "version": "0.9.27" }, + "sessions": { + "sessions": [ + { + "id": "sess_active", + "project": "agentmemory", + "status": "active", + "startedAt": "2026-06-19T09:00:00Z" + }, + { + "id": "sess_done", + "project": "agentmemory", + "status": "completed", + "startedAt": "2026-06-18T10:00:00Z", + "endedAt": "2026-06-18T10:30:00Z" + } + ] + }, + "memories": { + "memories": [ + { + "id": "mem_1", + "content": "Preserve viewer proxy boundary", + "type": "decision", + "project": "agentmemory", + "timestamp": "2026-06-19T09:05:00Z" + } + ] + } +} diff --git a/test/viewer-app-api-client.test.ts b/test/viewer-app-api-client.test.ts new file mode 100644 index 000000000..326262589 --- /dev/null +++ b/test/viewer-app-api-client.test.ts @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createViewerApiClient, + type ViewerApiToast, +} from "../src/viewer/app/api/client.js"; + +describe("viewer app API client", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.useRealTimers(); + }); + + it("attaches the saved bearer without overriding explicit authorization", async () => { + const calls: Array = []; + globalThis.fetch = vi.fn(async (_url, init) => { + calls.push(init); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }) as typeof fetch; + const storage = new Map([["agentmemory-viewer-token", "viewer-secret"]]); + const client = createViewerApiClient({ + restBase: "http://localhost:3113", + storage, + onAuthRequired: () => {}, + onToast: () => {}, + }); + + await client.getJson("health"); + await client.getJson("health", { headers: { Authorization: "Bearer explicit" } }); + + expect(new Headers(calls[0]!.headers).get("Authorization")).toBe("Bearer viewer-secret"); + expect(new Headers(calls[1]!.headers).get("Authorization")).toBe("Bearer explicit"); + }); + + it("preserves authorization provided through standard Headers inputs", async () => { + const calls: Array = []; + globalThis.fetch = vi.fn(async (_url, init) => { + calls.push(init); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }) as typeof fetch; + const storage = new Map([["agentmemory-viewer-token", "viewer-secret"]]); + const client = createViewerApiClient({ + restBase: "http://localhost:3113", + storage, + onAuthRequired: () => {}, + onToast: () => {}, + }); + + await client.getJson("health", { headers: new Headers({ Authorization: "Bearer explicit" }) }); + await client.getJson("health", { headers: [["Authorization", "Bearer tuple"]] }); + + expect(new Headers(calls[0]!.headers).get("Authorization")).toBe("Bearer explicit"); + expect(new Headers(calls[1]!.headers).get("Authorization")).toBe("Bearer tuple"); + }); + + it("signals auth prompt on 401", async () => { + globalThis.fetch = vi.fn(async () => new Response("{}", { status: 401 })) as typeof fetch; + const authRequired = vi.fn(); + const client = createViewerApiClient({ + restBase: "http://localhost:3113", + storage: new Map(), + onAuthRequired: authRequired, + onToast: () => {}, + }); + + await expect(client.getJson("health")).rejects.toMatchObject({ status: 401 }); + expect(authRequired).toHaveBeenCalledTimes(1); + }); + + it("emits escaped toast-facing errors on HTTP failure", async () => { + globalThis.fetch = vi.fn(async () => new Response("", { status: 500 })) as + typeof fetch; + const toasts: ViewerApiToast[] = []; + const client = createViewerApiClient({ + restBase: "http://localhost:3113", + storage: new Map(), + onAuthRequired: () => {}, + onToast: (toast) => toasts.push(toast), + }); + + await expect(client.getJson("memories")).rejects.toMatchObject({ status: 500 }); + expect(toasts[0]!.message).toContain("HTTP 500"); + expect(toasts[0]!.message).not.toContain("