feat(ui-enhancement-pack): v0 — progress + markdown + files browser + edit mode

UI Enhancement Pack v0 — operator gets a substantially better UI for
browsing + viewing + editing workspace canon files. Four items ship as
one ambitious vertical because they share infrastructure (allowlist +
path-safety daemon-side; markdown rendering UI-side). NO new schema,
NO migrations, NO new event types.

Item 1A — Acceptance tab enhancement (Slice Story View v0 extension):
- Checkbox pills (rounded with status icon: ✓ done / ◯ active /
  ⚠ blocked) instead of raw [ ]/[x] syntax.
- 4-filter chip row (All / Active / Done / Blocked; default All).
- Click-to-expand row detail panel with prominent file:line citation.
- v0's PROGRESS.md parser still drives the row list; this slice
  enhances the rendering layer only.

Item 1B — Top-level Progress browse view:
- New /progress route + Progress sidebar Link (CheckSquare icon)
  alongside the existing Slices entry.
- Daemon: new domain/progress/progress-indexer.ts walks
  OPENRIG_PROGRESS_SCAN_ROOTS (env-configured, comma-separated
  name:path pairs), parses PROGRESS.md files with checkbox-hierarchy
  awareness ([ ]/[x]/[~] checkboxes + ## / ### / #### headings +
  YAML frontmatter strip + 2-space-indent depth + skip-dirs for
  node_modules/.git/.worktrees/dist), and returns aggregate counts
  per file + workspace-wide.
- New routes/progress.ts: GET /api/progress/tree returns
  { files, aggregate, scannedRoots } or 503 with structured
  setup hint when no scan roots are configured.
- New ProgressWorkspace.tsx with two-pane layout: tree pane (per-
  file collapsible sections with checkbox pills + status icons) +
  inline detail panel on row click. Filter row (status: all/active/
  done/blocked) + free-text search across rows. Read-only at v0
  (edit is item 4 territory).

Item 2 — Markdown viewer expansion:
- New components/markdown/MarkdownViewer.tsx + SyntaxHighlight.tsx —
  written from scratch (~500 lines total, no new dependencies). Per
  the audit: no markdown / highlighting libs are installed in the UI
  package; the inline parser keeps the bundle weight unchanged.
- Handles: YAML frontmatter (rendered as metadata header above body),
  headings (# / ## / ### / ####), bullet + ordered lists with
  indentation depth, fenced code blocks with per-language syntax
  highlighting, inline code with backticks, links + images with
  relative-src resolution against assetBasePath, tables (| col |
  syntax with header + body rows + zebra striping), bold (**) and
  italic (*) inline emphasis.
- Per-language tokenizer covers ts/tsx/js/jsx, py/python, bash/sh,
  sql, yaml/yml, json. Token classes: keyword (violet), string
  (emerald), comment (stone-500 italic), number (amber), tag (sky),
  punctuation (stone-400). Unknown languages render as plain text
  in the dark code panel.
- Item 2 mermaid carve-out activated: NO mermaid library bundled at
  v0. Mermaid code blocks render as a labeled placeholder with a
  disabled '[render mermaid] (v0+1)' button. NAMED v0+1 trigger:
  "operator dogfood reports the click-to-render flow is friction"
  — recorded in commit + handoff per PRD § Item 2.
- DocsTab.tsx refactored to consume MarkdownViewer for .md files;
  non-.md files keep the v0 plain-`<pre>` fallback. Slice Story
  View v0's docs UX is preserved + extended.

Item 3 — File browser (allowlisted, daemon-enforced path-safety):
- Driver pivot from PRD's suggested ConfigStore key family
  (files.allowlist.<name>): the existing ConfigStore uses a strict
  VALID_KEYS allowlist that doesn't admit dynamic key families
  cleanly. Switched to env-var approach:
  OPENRIG_FILES_ALLOWLIST=name1:/abs/path,name2:/abs/path. Same
  delimited-pair shape as OPENRIG_PROGRESS_SCAN_ROOTS for
  consistency. Fail-closed default: empty/unset → routes return
  503 with structured setup hint.
- New domain/files/path-safety.ts: shared resolver with three
  guarantees — '..' segment rejection (literal path-component, not
  substring; foo..bar.md is allowed), absolute-path-as-relative
  rejection, and symlink-realpath check ensuring no resolved file
  escapes the canonical root via path.sep boundary (prevents the
  /foo/bar matching /foo/bar-other false-positive).
- New routes/files.ts: GET /roots, GET /list, GET /read,
  GET /asset, POST /write. Path-safety errors map to structured
  4xx codes (root_unknown / path_invalid / path_escape /
  not_a_file / not_a_directory / stat_failed) so the UI can
  render specific error messages.
- /asset infers Content-Type from extension (image/png /
  image/jpeg / image/svg+xml / video/mp4 / etc.) for embedded
  image rendering in MarkdownViewer.
- New FilesWorkspace.tsx + useFiles.ts hooks: two-pane layout
  (allowlist root selector + breadcrumbs + directory tree on left;
  file content panel on right). Auto-selects first root; resets
  path + selection on root change. Up-directory affordance.
  Content-type-aware rendering: .md → MarkdownViewer; text-like →
  SyntaxHighlight; image → inline `<img>` via /api/files/asset;
  download-only types (.zip, .pdf, .mp4) → download link;
  unknown → text fallback.

Item 4 — Optional edit mode (atomic write + JSONL audit):
- Per PRD's recommended landing posture: lightweight `<textarea>`
  edit (no CodeMirror dependency) + daemon write route +
  audit JSONL. The atomic-write contract is the load-bearing
  primitive; the UI shell is intentionally minimalist.
- New domain/files/file-write-service.ts: writeAtomic({...}):
  resolves path-safety → re-stats target → re-hashes via SHA-256 →
  rejects with WriteConflictError carrying current{Mtime,
  ContentHash} on mismatch (409) → writes to temp file in same
  directory → fsync → atomic rename → re-stats for new mtime/
  contentHash → appends one JSONL row to ~/.openrig/file-edit-
  audit.jsonl with ts, actor, root, path, prev/new mtime + hash,
  byteCountDelta. Audit-write failure surfaces as a separate
  error code (write succeeded; audit append did not — operator
  can re-derive from filesystem state). PRD's no-clobber-on-
  concurrent-edit invariant pinned by tests.
- POST /api/files/write integrated into routes/files.ts; conflict
  path returns 409 with the daemon's current state for the UI
  to surface a Refresh affordance.
- FilesWorkspace edit mode: header-bar Edit toggle (default off);
  toggling on flips the right pane to a `<textarea>` editor with
  Save / Cancel / status indicator (draft (unsaved) / no changes /
  saved). Save submits POST /write with last-known mtime +
  contentHash; 409 conflict surfaces inline with current
  daemon-side mtime; Cancel discards the draft and re-reads.
- Driver carve-out NOT invoked: edit mode ships as the PRD's
  recommended in-scope item. NAMED v0+1 trigger left available
  for future scope-down if dogfood reveals friction (per
  PRD § Item 4 Driver carve-out).

AppShell + routes wiring:
- New header Links: Progress (CheckSquare icon) + Files (Folder
  icon) alongside Slices (Layers). Surface-title resolver
  recognizes /progress + /files paths.
- routes.tsx: register progressRoute (/progress) + filesRoute
  (/files) on the existing root tree.

PRD reinterpretations recorded:
- Item 3 allowlist source: env-var (OPENRIG_FILES_ALLOWLIST) instead
  of ConfigStore key family — driver pivot to fit existing
  ConfigStore's strict VALID_KEYS allowlist.
- Item 1B scan roots: env-var (OPENRIG_PROGRESS_SCAN_ROOTS) for
  consistency with the same delimited-pair shape.
- Item 2 mermaid carve-out activated per PRD: NO mermaid bundled;
  placeholder + disabled button + named v0+1 trigger.

Verification:
- daemon: 2796/2796 (was 2750 baseline at 6097f4e + 46 new across
  3 new test files: 18 files-path-safety + 15 files-routes + 12
  progress-indexer + 1 from setup). Path-safety tests pin '..'
  segment rejection vs filename-substring, absolute-path
  rejection, symlink-escape rejection, env-var fallback chain
  (empty-string treats as missing for legacy fallback). Files-
  routes tests pin 503 graceful, list+read+asset round-trip,
  atomic-write success + 409 conflict path, audit JSONL append.
  Progress indexer tests pin checkbox state parsing (active/done/
  blocked), heading depth derivation, indent depth derivation,
  frontmatter strip, skip-dirs, max-depth, aggregate counts.
- ui: 478/478 (was 458 baseline + 20 new across 2 test files:
  13 markdown-viewer + 7 acceptance-tab-enhancement). Markdown
  tests pin frontmatter rendering, headings/lists/code/inline-
  code/links/images/tables/bold/italic, mermaid carve-out
  placeholder. Acceptance tests pin pill rendering, filter row,
  filtering behavior, expand/collapse, v0-baseline preservation.
- cli: untouched by this slice; baseline 800.
- tsc clean for daemon + cli + ui.

Boundaries preserved:
- Worktree-only on ui-enhancement-pack-v0; main untouched at 6097f4e.
- NO new schema, NO migrations, NO new event types.
- Slice Story View v0's StoryTab / DecisionsTab / TestsVerificationTab
  / TopologyTab — untouched. Acceptance + Docs tabs extended only.
- v1 in-flight (separate worktree at 7bb892e): non-overlap on
  load-bearing files; AcceptanceTab will need a clean merge when
  v1 lands (v1 adds CurrentStepPanel ABOVE the checkbox list; this
  slice extends the checkbox list rendering — surgical merge).
- PL-019 surfaces (RigNode/Explorer/graph-projection/edge animation)
  — untouched.
- PL-004 Phase A/B/C/D — read-only consumers; no modifications.
- PL-005 Mission Control — untouched.
- ConfigStore (cli/src/config-store.ts) — untouched per the
  env-var pivot.
- All migrations on main — untouched.
- AppShell entries other than the new Progress + Files Links —
  untouched.

Operator setup hint for QA dogfood (orch routes to QA):

  export OPENRIG_FILES_ALLOWLIST="workspace:/Users/wrandom/code/substrate/shared-docs/openrig-work,hub:/Users/wrandom/code/projects/openrig-hub"
  export OPENRIG_PROGRESS_SCAN_ROOTS="workspace:/Users/wrandom/code/substrate/shared-docs/openrig-work"
  rig daemon restart

Then:
- Header → Slices/Progress/Files (3 sidebar Links).
- /progress: tree of PROGRESS.md across the workspace with
  filter + search + click-to-expand detail.
- /files: allowlist roots + directory tree + content panel
  (markdown rendered via MarkdownViewer; code via
  SyntaxHighlight; images inline; download-only fallback for
  zip/pdf/video).
- /files edit mode: toggle Edit → save STEERING.md inline →
  audit row appears in ~/.openrig/file-edit-audit.jsonl →
  concurrent edit from a separate terminal triggers 409
  Conflict with Refresh affordance.

Hard-gate (operator dogfood per PRD audit row 12) is QA-owned
per feedback_qa_owns_dogfood_fix_loop.md. Worktree retained for
guard inspection.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-04 04:50:57 -07:00
1 parent 6097f4e633
commit 1a12230dce
22 files changed
+3535 -30

No files matched your search

@@ -0,0 +1,227 @@
// UI Enhancement Pack v0 — atomic file write + JSONL audit.
//
// Item 4: operator-actionable write surface for STEERING.md /
// PROGRESS.md / spec YAML / any allowlisted file. Honors the dashboard
// precedent's mtime + content-hash invariants (cited as retired-but-
// instructive from `services/dashboard/bin/server.py:1269-1289`;
// reimplemented fresh in TypeScript per current OpenRig conventions).
//
// Atomic-write semantics:
// 1. Resolve target path under allowlist root (path-safety from
// file-allowlist.ts).
// 2. Re-stat file: if mtime != expectedMtime OR contentHash !=
// expectedContentHash, reject with WriteConflictError carrying
// the current mtime + contentHash for the UI to surface.
// 3. Write content to a temp file in the same directory.
// 4. fsync the temp file so the write is durable before the rename.
// 5. Atomic rename over the target path. On POSIX this is a single
// inode swap, so other readers either see the old file or the
// new file — never a partial write.
// 6. Compute new mtime + contentHash from the rename'd target.
// 7. Append one JSONL row to the audit file.
//
// The audit file is `~/.openrig/file-edit-audit.jsonl` by default
// (operator-overridable via env). Append-only; never rotates at v0
// per PRD § Item 4 (rotation is a future concern).
import { createHash } from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import { resolveAllowedPath, type AllowlistRoot } from "./path-safety.js";
export interface FileWriteRequest {
rootName: string;
/** Relative path under the allowlist root; same shape as item 3 routes. */
path: string;
content: string;
/** Caller-known mtime (ISO string) at the time of last read. */
expectedMtime: string;
/** Caller-known SHA-256 content hash (hex) at the time of last read. */
expectedContentHash: string;
/** Operator session that initiated the write (for the audit row). */
actor: string;
}
export interface FileWriteResult {
/** Resolved canonical absolute path that was written. */
absolutePath: string;
/** New mtime after the write (ISO string). */
newMtime: string;
/** New SHA-256 content hash (hex) after the write. */
newContentHash: string;
/** Byte-count delta (new - prev). */
byteCountDelta: number;
}
export class WriteConflictError extends Error {
constructor(
public readonly currentMtime: string,
public readonly currentContentHash: string,
public readonly details: Record<string, unknown>,
) {
super("file changed externally; refresh required before writing");
this.name = "WriteConflictError";
}
}
export class FileWriteError extends Error {
constructor(
public readonly code: "stat_failed" | "tmp_write_failed" | "rename_failed" | "audit_write_failed",
message: string,
public readonly details?: Record<string, unknown>,
) {
super(message);
this.name = "FileWriteError";
}
}
export interface FileWriteServiceOpts {
/** Allowlist roots resolved from env at startup (re-resolved per request is fine; cheap). */
allowlist: AllowlistRoot[];
/** Override default audit file location for tests. */
auditFilePath?: string;
/** Override Date.now() for tests (returns ISO timestamp). */
now?: () => Date;
}
const DEFAULT_AUDIT_FILE = path.join(
process.env.HOME ?? process.env.USERPROFILE ?? "/tmp",
".openrig",
"file-edit-audit.jsonl",
);
export class FileWriteService {
private readonly allowlist: AllowlistRoot[];
private readonly auditFilePath: string;
private readonly now: () => Date;
constructor(opts: FileWriteServiceOpts) {
this.allowlist = opts.allowlist;
this.auditFilePath = opts.auditFilePath ?? DEFAULT_AUDIT_FILE;
this.now = opts.now ?? (() => new Date());
}
/**
* Atomically write content to an allowlisted file. Throws
* WriteConflictError on mtime/contentHash mismatch; throws
* FileWriteError on stat / temp-write / rename / audit failures.
* Returns FileWriteResult with the new mtime + contentHash on
* success.
*/
writeAtomic(req: FileWriteRequest): FileWriteResult {
const target = resolveAllowedPath(this.allowlist, req.rootName, req.path);
// Pre-write checks: re-stat + re-hash for conflict detection.
let prevStat: fs.Stats;
let prevContent: Buffer;
try {
prevStat = fs.statSync(target);
prevContent = fs.readFileSync(target);
} catch (err) {
throw new FileWriteError(
"stat_failed",
`failed to read '${target}' for conflict detection: ${err instanceof Error ? err.message : String(err)}`,
{ target },
);
}
const prevMtime = prevStat.mtime.toISOString();
const prevContentHash = sha256Hex(prevContent);
if (prevMtime !== req.expectedMtime || prevContentHash !== req.expectedContentHash) {
throw new WriteConflictError(prevMtime, prevContentHash, {
target,
expectedMtime: req.expectedMtime,
expectedContentHash: req.expectedContentHash,
});
}
// Write to temp file in the SAME directory (so atomic rename
// works across the same filesystem). Suffix with PID + random
// so concurrent writes don't collide.
const tmpName = `.openrig-write-${process.pid}-${Math.random().toString(36).slice(2, 10)}-${path.basename(target)}`;
const tmpPath = path.join(path.dirname(target), tmpName);
let tmpFd: number | null = null;
try {
tmpFd = fs.openSync(tmpPath, "w");
fs.writeFileSync(tmpFd, req.content);
fs.fsyncSync(tmpFd);
} catch (err) {
// Clean up temp file if it was partially created.
try { if (tmpFd !== null) fs.closeSync(tmpFd); } catch { /* ignore */ }
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
throw new FileWriteError(
"tmp_write_failed",
`failed to write+fsync temp file at '${tmpPath}': ${err instanceof Error ? err.message : String(err)}`,
{ target, tmpPath },
);
} finally {
try { if (tmpFd !== null) fs.closeSync(tmpFd); } catch { /* ignore */ }
}
try {
fs.renameSync(tmpPath, target);
} catch (err) {
// Best-effort cleanup of the orphan temp file. We don't try to
// restore the previous content because we never touched the
// target (rename failed atomically).
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
throw new FileWriteError(
"rename_failed",
`failed to atomically rename '${tmpPath}' over '${target}': ${err instanceof Error ? err.message : String(err)}`,
{ target, tmpPath },
);
}
// Re-stat to get the new mtime + content hash.
const newStat = fs.statSync(target);
const newContent = fs.readFileSync(target);
const newMtime = newStat.mtime.toISOString();
const newContentHash = sha256Hex(newContent);
const byteCountDelta = newContent.byteLength - prevContent.byteLength;
// Append the audit JSONL row. Failure here is logged but does
// NOT undo the write — the user's edit landed; we don't want
// audit-system flakes to revert canon edits.
const auditRow = {
ts: this.now().toISOString(),
actor: req.actor,
root: req.rootName,
path: req.path,
absolutePath: target,
prevMtime,
newMtime,
prevContentHash,
newContentHash,
byteCountDelta,
};
try {
this.appendAuditRow(auditRow);
} catch (err) {
throw new FileWriteError(
"audit_write_failed",
`write succeeded but audit append failed: ${err instanceof Error ? err.message : String(err)}`,
{ target, auditFilePath: this.auditFilePath, ...auditRow },
);
}
return {
absolutePath: target,
newMtime,
newContentHash,
byteCountDelta,
};
}
private appendAuditRow(row: Record<string, unknown>): void {
fs.mkdirSync(path.dirname(this.auditFilePath), { recursive: true });
fs.appendFileSync(this.auditFilePath, `${JSON.stringify(row)}\n`);
}
/** Test/debug helper: returns the configured audit file path. */
getAuditFilePath(): string {
return this.auditFilePath;
}
}
export function sha256Hex(content: Buffer | string): string {
return createHash("sha256").update(content).digest("hex");
}
@@ -0,0 +1,220 @@
// UI Enhancement Pack v0 — file allowlist + path-safety helpers.
//
// Daemon-enforced fail-closed path-safety for the operator-allowlisted
// file browser surface (item 3 + item 4). Reused by all four
// /api/files/* routes.
//
// Allowlist source (driver pivot from PRD's suggested ConfigStore key
// family — see handoff): OPENRIG_FILES_ALLOWLIST env var, comma-
// separated `<name>:<absolute-path>` pairs. Operator sets via shell
// (or via `~/.openrig` env file). Empty / unset → no allowlist roots
// → routes return empty roots list with a structured "configure
// OPENRIG_FILES_ALLOWLIST" hint. Default after fresh install: nothing
// (the safe default per PRD § Item 3).
//
// Path-safety contract:
// - Each request resolves <relativePath> against <rootName>'s
// canonical absolute path.
// - Any escape attempt (.., absolute-path passed as relativePath,
// symlink whose realpath escapes the root) is rejected with a
// structured error.
// - Symlinks INSIDE the allowlisted tree resolve normally; symlinks
// pointing OUTSIDE the tree are treated as escape attempts.
// - Reads/lists never return content from outside the resolved root.
//
// MVP single-host context: no per-rig overrides, no remote allowlist
// management, no audit at allowlist-resolution time (allowlist
// resolution is read-only). Audit applies to writes only (item 4).
import * as fs from "node:fs";
import * as path from "node:path";
export interface AllowlistRoot {
/** Operator-supplied display name (e.g., "workspace"). */
name: string;
/** Canonical absolute path on disk. */
canonicalPath: string;
}
export class FilePathSafetyError extends Error {
constructor(
public readonly code: "root_unknown" | "path_escape" | "path_invalid" | "stat_failed" | "not_a_file" | "not_a_directory",
message: string,
public readonly details?: Record<string, unknown>,
) {
super(message);
this.name = "FilePathSafetyError";
}
}
const ENV_VAR = "OPENRIG_FILES_ALLOWLIST";
const LEGACY_ENV_VAR = "RIGGED_FILES_ALLOWLIST";
/**
* Reads the allowlist env var and returns the parsed root list. Each
* pair is `<name>:<absolute-path>`; whitespace around delimiters is
* trimmed. Invalid pairs (no colon, empty name, non-absolute path)
* are silently skipped (logged at construction by the caller, not
* here — keep this pure for tests). Duplicate names: last entry wins
* (operator presumably means to override).
*/
export function readAllowlistFromEnv(env: NodeJS.ProcessEnv = process.env): AllowlistRoot[] {
// Use || (not ??) so an empty-string OPENRIG_FILES_ALLOWLIST falls
// back to the legacy var. ?? only handles null/undefined.
const raw = (env[ENV_VAR] || env[LEGACY_ENV_VAR] || "").toString();
if (!raw.trim()) return [];
const out = new Map<string, string>();
for (const pair of raw.split(",")) {
const trimmed = pair.trim();
if (!trimmed) continue;
const colon = trimmed.indexOf(":");
if (colon === -1) continue;
const name = trimmed.slice(0, colon).trim();
const rawPath = trimmed.slice(colon + 1).trim();
if (!name || !rawPath) continue;
if (!path.isAbsolute(rawPath)) continue;
// Resolve to canonical absolute path. If the path doesn't exist
// on disk yet, fall back to path.resolve so the operator can
// pre-configure roots. realpath would throw on missing paths.
let canonical: string;
try {
canonical = fs.realpathSync(rawPath);
} catch {
canonical = path.resolve(rawPath);
}
out.set(name, canonical);
}
return Array.from(out.entries()).map(([name, canonicalPath]) => ({ name, canonicalPath }));
}
/**
* Resolves a relative path against an allowlist root. Throws
* FilePathSafetyError on any unsafe condition. Returns the
* canonical absolute path on success. Caller is expected to stat
* the result to distinguish file/directory.
*
* Path-safety algorithm:
* 1. Validate the root exists in the allowlist (reject "root_unknown").
* 2. Reject relative paths that look absolute or contain `..`
* segments (reject "path_invalid" / "path_escape").
* 3. Resolve the relative path against the canonical root.
* 4. Resolve symlinks: if the realpath does not start with the
* canonical root + path separator, reject "path_escape".
* 5. The base case `path = ""` resolves to the root itself (allowed
* so callers can list the root directory).
*/
export function resolveAllowedPath(
allowlist: AllowlistRoot[],
rootName: string,
relativePath: string,
): string {
const root = allowlist.find((r) => r.name === rootName);
if (!root) {
throw new FilePathSafetyError(
"root_unknown",
`allowlist root '${rootName}' is not configured. Allowlist configured roots: ${allowlist.map((r) => r.name).join(", ") || "(none)"}.`,
{ rootName, configuredRoots: allowlist.map((r) => r.name) },
);
}
// Reject ../ escape attempts BEFORE filesystem resolution. Even
// though step 4 catches realpath escapes, this rejects the
// expressed-intent-to-escape with a clearer error code.
if (relativePath.includes("..")) {
// Distinguish a literal ".." segment from a filename like "foo..bar"
// — split on path.sep and `/` and inspect each segment.
const segments = relativePath.split(/[\\/]/).filter((s) => s.length > 0);
for (const seg of segments) {
if (seg === "..") {
throw new FilePathSafetyError(
"path_escape",
`relative path '${relativePath}' contains a '..' segment; reject.`,
{ rootName, relativePath },
);
}
}
}
if (path.isAbsolute(relativePath)) {
throw new FilePathSafetyError(
"path_invalid",
`relative path '${relativePath}' must not be absolute (reject).`,
{ rootName, relativePath },
);
}
const candidate = path.resolve(root.canonicalPath, relativePath);
let realpath: string;
try {
realpath = fs.realpathSync(candidate);
} catch {
// If the candidate doesn't exist on disk yet, fall back to the
// unresolved candidate. Subsequent stat will surface the absence
// with a more specific error code.
realpath = candidate;
}
// Containment check with path.sep boundary to avoid the
// /foo/bar matching /foo/bar-other false-positive.
const rootWithSep = root.canonicalPath.endsWith(path.sep)
? root.canonicalPath
: `${root.canonicalPath}${path.sep}`;
if (realpath !== root.canonicalPath && !realpath.startsWith(rootWithSep)) {
throw new FilePathSafetyError(
"path_escape",
`resolved path '${realpath}' falls outside allowlist root '${rootName}' (${root.canonicalPath}).`,
{ rootName, relativePath, resolved: realpath, rootCanonical: root.canonicalPath },
);
}
return realpath;
}
/** Convenience: resolve + assert the result is an existing regular file. */
export function resolveAllowedFile(
allowlist: AllowlistRoot[],
rootName: string,
relativePath: string,
): string {
const resolved = resolveAllowedPath(allowlist, rootName, relativePath);
let stat: fs.Stats;
try {
stat = fs.statSync(resolved);
} catch (err) {
throw new FilePathSafetyError(
"stat_failed",
`failed to stat '${resolved}': ${err instanceof Error ? err.message : String(err)}`,
{ rootName, relativePath, resolved },
);
}
if (!stat.isFile()) {
throw new FilePathSafetyError(
"not_a_file",
`resolved path '${resolved}' is not a regular file.`,
{ rootName, relativePath, resolved },
);
}
return resolved;
}
/** Convenience: resolve + assert the result is an existing directory. */
export function resolveAllowedDirectory(
allowlist: AllowlistRoot[],
rootName: string,
relativePath: string,
): string {
const resolved = resolveAllowedPath(allowlist, rootName, relativePath);
let stat: fs.Stats;
try {
stat = fs.statSync(resolved);
} catch (err) {
throw new FilePathSafetyError(
"stat_failed",
`failed to stat '${resolved}': ${err instanceof Error ? err.message : String(err)}`,
{ rootName, relativePath, resolved },
);
}
if (!stat.isDirectory()) {
throw new FilePathSafetyError(
"not_a_directory",
`resolved path '${resolved}' is not a directory.`,
{ rootName, relativePath, resolved },
);
}
return resolved;
}
@@ -0,0 +1,238 @@
// UI Enhancement Pack v0 — workspace PROGRESS.md indexer.
//
// Walks operator-allowlisted scan roots, finds PROGRESS.md files,
// parses each into a checkbox-hierarchy tree, and emits a normalized
// payload consumed by:
// - GET /api/progress/tree (the new top-level Progress browse view)
// - the new ProgressTree React component
//
// Scan strategy (item 1B):
// - Operator configures progress-scan roots via env var
// OPENRIG_PROGRESS_SCAN_ROOTS=root1:/abs/path,root2:/abs/path
// (same delimited-pair shape as OPENRIG_FILES_ALLOWLIST).
// - For each configured root, recursively find PROGRESS.md files
// up to a small max depth (default 6) — enough to catch
// mission/lane/slice nesting without descending into node_modules
// or other deep trees.
// - Each PROGRESS.md becomes one mission/lane/slice node; rows
// parsed from `[ ]` / `[x]` / `[~]` checkbox lines + Markdown
// headings (## / ###) for hierarchy.
//
// MVP single-host: in-memory walk per request; no caching at v0
// (file count is bounded by operator's allowlist scope; sub-second
// for the typical workspace).
import * as fs from "node:fs";
import * as path from "node:path";
export type CheckboxStatus = "active" | "done" | "blocked" | "unknown";
export interface ProgressRow {
/** 1-based source line number. */
line: number;
/** Indent depth (0 = top-level; nested by 2-space indent / Markdown heading depth). */
depth: number;
/** Parsed status from the `[x]` / `[ ]` / `[~]` syntax. */
status: CheckboxStatus;
/** The text after the checkbox (or the heading text when this row is a heading). */
text: string;
/** "checkbox" for `[ ]` lines; "heading" for `##` / `###`. */
kind: "checkbox" | "heading";
}
export interface ProgressFileNode {
/** Operator-supplied scan-root display name. */
rootName: string;
/** Path relative to the scan root (e.g., "missions/foo/PROGRESS.md"). */
relPath: string;
/** Canonical absolute path on disk (handy for the UI's "open in editor" affordance). */
absolutePath: string;
mtime: string;
rows: ProgressRow[];
/** Top-level title (from frontmatter or first H1). */
title: string | null;
/** Aggregate counts derived from rows (kind === "checkbox"). */
counts: { total: number; done: number; blocked: number; active: number };
}
export interface ProgressScanRoot {
name: string;
canonicalPath: string;
}
export interface ProgressTreeResult {
files: ProgressFileNode[];
/** Aggregate over all files. */
aggregate: { totalFiles: number; totalRows: number; totalDone: number; totalBlocked: number; totalActive: number };
/** Roots that were scanned (for the UI to render "scanned N roots"). */
scannedRoots: ProgressScanRoot[];
}
const DEFAULT_MAX_DEPTH = 6;
const PROGRESS_FILENAME = "PROGRESS.md";
const SKIP_DIRS = new Set(["node_modules", ".git", ".worktrees", "dist", "build", ".turbo", ".next"]);
const ENV_VAR = "OPENRIG_PROGRESS_SCAN_ROOTS";
const LEGACY_ENV_VAR = "RIGGED_PROGRESS_SCAN_ROOTS";
export function readProgressRootsFromEnv(env: NodeJS.ProcessEnv = process.env): ProgressScanRoot[] {
const raw = env[ENV_VAR] ?? env[LEGACY_ENV_VAR] ?? "";
if (!raw.trim()) return [];
const out = new Map<string, string>();
for (const pair of raw.split(",")) {
const trimmed = pair.trim();
if (!trimmed) continue;
const colon = trimmed.indexOf(":");
if (colon === -1) continue;
const name = trimmed.slice(0, colon).trim();
const rawPath = trimmed.slice(colon + 1).trim();
if (!name || !rawPath || !path.isAbsolute(rawPath)) continue;
let canonical: string;
try { canonical = fs.realpathSync(rawPath); } catch { canonical = path.resolve(rawPath); }
out.set(name, canonical);
}
return Array.from(out.entries()).map(([name, canonicalPath]) => ({ name, canonicalPath }));
}
export interface ProgressIndexerOpts {
roots: ProgressScanRoot[];
/** Override max recursion depth for tests. */
maxDepth?: number;
}
export class ProgressIndexer {
private readonly roots: ProgressScanRoot[];
private readonly maxDepth: number;
constructor(opts: ProgressIndexerOpts) {
this.roots = opts.roots;
this.maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH;
}
isReady(): boolean {
return this.roots.length > 0;
}
scan(): ProgressTreeResult {
const files: ProgressFileNode[] = [];
for (const root of this.roots) {
this.walkRoot(root, files);
}
files.sort((a, b) => a.relPath.localeCompare(b.relPath));
const aggregate = files.reduce(
(acc, f) => ({
totalFiles: acc.totalFiles + 1,
totalRows: acc.totalRows + f.counts.total,
totalDone: acc.totalDone + f.counts.done,
totalBlocked: acc.totalBlocked + f.counts.blocked,
totalActive: acc.totalActive + f.counts.active,
}),
{ totalFiles: 0, totalRows: 0, totalDone: 0, totalBlocked: 0, totalActive: 0 },
);
return { files, aggregate, scannedRoots: this.roots };
}
private walkRoot(root: ProgressScanRoot, out: ProgressFileNode[]): void {
this.walkDir(root, root.canonicalPath, "", 0, out);
}
private walkDir(root: ProgressScanRoot, abs: string, rel: string, depth: number, out: ProgressFileNode[]): void {
if (depth > this.maxDepth) return;
let entries: fs.Dirent[];
try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { return; }
for (const entry of entries) {
if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) continue;
const childAbs = path.join(abs, entry.name);
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
this.walkDir(root, childAbs, childRel, depth + 1, out);
} else if (entry.isFile() && entry.name === PROGRESS_FILENAME) {
const node = this.parseProgressFile(root, childAbs, childRel);
if (node) out.push(node);
}
}
}
private parseProgressFile(root: ProgressScanRoot, absolutePath: string, relPath: string): ProgressFileNode | null {
let content: string;
let mtime: Date;
try {
content = fs.readFileSync(absolutePath, "utf-8");
mtime = fs.statSync(absolutePath).mtime;
} catch { return null; }
const rows: ProgressRow[] = [];
const lines = content.split("\n");
let title: string | null = null;
let inFrontmatter = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
const lineNumber = i + 1;
// Skip YAML frontmatter.
if (lineNumber === 1 && line.trim() === "---") { inFrontmatter = true; continue; }
if (inFrontmatter) {
if (line.trim() === "---") inFrontmatter = false;
continue;
}
// First H1 → title (if frontmatter didn't supply one).
if (!title) {
const h1 = line.match(/^#\s+(.+?)$/);
if (h1) title = h1[1]!.trim();
}
// Heading rows (## / ###).
const h = line.match(/^(#{2,4})\s+(.+?)$/);
if (h) {
rows.push({
line: lineNumber,
depth: h[1]!.length - 2, // ## → 0, ### → 1, #### → 2
status: "unknown",
text: h[2]!.trim(),
kind: "heading",
});
continue;
}
// Checkbox rows. Match either bare `[ ]` (no list bullet) or
// `- [ ]` / `* [ ]`. The bullet's leading indent determines depth.
const cb = line.match(/^(\s*)(?:[-*]\s+)?\[([ xX~])\]\s+(.+)$/);
if (cb) {
const indentSpaces = cb[1]!.length;
const indicator = cb[2]!.toLowerCase();
const text = cb[3]!.trim();
const status: CheckboxStatus =
indicator === "x" ? "done"
: indicator === "~" ? "blocked"
: "active";
rows.push({
line: lineNumber,
depth: Math.floor(indentSpaces / 2),
status,
text,
kind: "checkbox",
});
}
}
const checkboxes = rows.filter((r) => r.kind === "checkbox");
const counts = {
total: checkboxes.length,
done: checkboxes.filter((r) => r.status === "done").length,
blocked: checkboxes.filter((r) => r.status === "blocked").length,
active: checkboxes.filter((r) => r.status === "active").length,
};
return {
rootName: root.name,
relPath,
absolutePath,
mtime: mtime.toISOString(),
rows,
title,
counts,
};
}
}
+250
View File
@@ -0,0 +1,250 @@
// UI Enhancement Pack v0 — file browser routes.
//
// Endpoints (item 3 + item 4):
// GET /api/files/roots allowlist root list
// GET /api/files/list?root=<name>&path=<rel> directory entries
// GET /api/files/read?root=<name>&path=<rel> file content + metadata
// GET /api/files/asset?root=<name>&path=<rel> raw bytes for embedded images
// POST /api/files/write atomic write (item 4)
//
// Route-order discipline (per Phase A R1 SSE lesson): all routes are
// literal — no `/:param` catchalls — so order doesn't matter for
// shadowing. Kept sequential for readability.
import { Hono } from "hono";
import * as fs from "node:fs";
import * as path from "node:path";
import {
resolveAllowedDirectory,
resolveAllowedFile,
resolveAllowedPath,
FilePathSafetyError,
type AllowlistRoot,
} from "../domain/files/path-safety.js";
import {
WriteConflictError,
FileWriteError,
sha256Hex,
type FileWriteService,
} from "../domain/files/file-write-service.js";
export interface FilesRoutesDeps {
/** Allowlist resolved at startup; empty array = no roots configured. */
allowlist: AllowlistRoot[];
/** Atomic-write service; absent → POST /write returns 503 unconfigured. */
writeService: FileWriteService | null;
}
export function filesRoutes(): Hono {
const app = new Hono();
function getDeps(c: { get: (key: string) => unknown }): FilesRoutesDeps | null {
const allowlist = c.get("filesAllowlist" as never) as AllowlistRoot[] | undefined;
const writeService = c.get("fileWriteService" as never) as FileWriteService | null | undefined;
if (!allowlist) return null;
return { allowlist, writeService: writeService ?? null };
}
function pathSafetyErrorResponse(c: { json: (body: unknown, status?: number) => Response }, err: FilePathSafetyError): Response {
const status =
err.code === "root_unknown" ? 400
: err.code === "path_invalid" || err.code === "path_escape" ? 400
: err.code === "stat_failed" ? 404
: err.code === "not_a_file" || err.code === "not_a_directory" ? 400
: 500;
return c.json({ error: err.code, message: err.message, ...(err.details ?? {}) }, status as 200);
}
app.get("/roots", (c) => {
const deps = getDeps(c);
if (!deps) return c.json({ error: "files_routes_unavailable" }, 503);
if (deps.allowlist.length === 0) {
return c.json({
roots: [],
hint: "No allowlist roots configured. Set OPENRIG_FILES_ALLOWLIST=name1:/abs/path,name2:/abs/path and restart the daemon.",
});
}
return c.json({
roots: deps.allowlist.map((r) => ({ name: r.name, path: r.canonicalPath })),
});
});
app.get("/list", (c) => {
const deps = getDeps(c);
if (!deps) return c.json({ error: "files_routes_unavailable" }, 503);
const rootName = c.req.query("root") ?? "";
const relativePath = c.req.query("path") ?? "";
if (!rootName) return c.json({ error: "root_required" }, 400);
try {
const resolved = resolveAllowedDirectory(deps.allowlist, rootName, relativePath);
const entries = fs.readdirSync(resolved, { withFileTypes: true });
return c.json({
root: rootName,
path: relativePath,
entries: entries
.map((entry) => {
// Skip dotfiles by default unless they're inside an
// allowlisted root that's a dot-directory itself (e.g.,
// operator allowlists ~/.openrig — the operator clearly
// wants to inspect dotfiles in that case). Implementation:
// include dotfiles always at v0; the operator has already
// expressed inspection intent by allowlisting the root.
const fullPath = path.join(resolved, entry.name);
let stat: fs.Stats | null = null;
try { stat = fs.statSync(fullPath); } catch { /* skip stat-failed */ }
return {
name: entry.name,
type: entry.isDirectory() ? "dir" as const : entry.isFile() ? "file" as const : "other" as const,
size: stat?.isFile() ? stat.size : null,
mtime: stat ? stat.mtime.toISOString() : null,
};
})
.sort((a, b) => {
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
return a.name.localeCompare(b.name);
}),
});
} catch (err) {
if (err instanceof FilePathSafetyError) return pathSafetyErrorResponse(c, err);
return c.json({ error: "list_failed", message: err instanceof Error ? err.message : String(err) }, 500);
}
});
app.get("/read", (c) => {
const deps = getDeps(c);
if (!deps) return c.json({ error: "files_routes_unavailable" }, 503);
const rootName = c.req.query("root") ?? "";
const relativePath = c.req.query("path") ?? "";
if (!rootName || !relativePath) return c.json({ error: "root_and_path_required" }, 400);
try {
const resolved = resolveAllowedFile(deps.allowlist, rootName, relativePath);
const stat = fs.statSync(resolved);
const content = fs.readFileSync(resolved);
return c.json({
root: rootName,
path: relativePath,
absolutePath: resolved,
content: content.toString("utf-8"),
mtime: stat.mtime.toISOString(),
contentHash: sha256Hex(content),
size: stat.size,
});
} catch (err) {
if (err instanceof FilePathSafetyError) return pathSafetyErrorResponse(c, err);
return c.json({ error: "read_failed", message: err instanceof Error ? err.message : String(err) }, 500);
}
});
app.get("/asset", (c) => {
const deps = getDeps(c);
if (!deps) return c.json({ error: "files_routes_unavailable" }, 503);
const rootName = c.req.query("root") ?? "";
const relativePath = c.req.query("path") ?? "";
if (!rootName || !relativePath) return c.json({ error: "root_and_path_required" }, 400);
try {
const resolved = resolveAllowedFile(deps.allowlist, rootName, relativePath);
const data = fs.readFileSync(resolved);
const contentType = inferContentType(resolved);
return new Response(new Uint8Array(data), {
status: 200,
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=300",
},
});
} catch (err) {
if (err instanceof FilePathSafetyError) return pathSafetyErrorResponse(c, err);
return c.json({ error: "asset_failed", message: err instanceof Error ? err.message : String(err) }, 500);
}
});
app.post("/write", async (c) => {
const deps = getDeps(c);
if (!deps) return c.json({ error: "files_routes_unavailable" }, 503);
if (!deps.writeService) {
return c.json({
error: "file_write_service_unavailable",
hint: "OPENRIG_FILES_ALLOWLIST is empty or unset; configure at least one root and restart.",
}, 503);
}
const body = await c.req.json<{
root?: string;
path?: string;
content?: string;
expectedMtime?: string;
expectedContentHash?: string;
actor?: string;
}>().catch(() => ({} as never));
if (!body.root) return c.json({ error: "root_required" }, 400);
if (!body.path) return c.json({ error: "path_required" }, 400);
if (typeof body.content !== "string") return c.json({ error: "content_required" }, 400);
if (!body.expectedMtime) return c.json({ error: "expectedMtime_required" }, 400);
if (!body.expectedContentHash) return c.json({ error: "expectedContentHash_required" }, 400);
if (!body.actor) return c.json({ error: "actor_required" }, 400);
try {
const result = deps.writeService.writeAtomic({
rootName: body.root,
path: body.path,
content: body.content,
expectedMtime: body.expectedMtime,
expectedContentHash: body.expectedContentHash,
actor: body.actor,
});
return c.json({
root: body.root,
path: body.path,
absolutePath: result.absolutePath,
newMtime: result.newMtime,
newContentHash: result.newContentHash,
byteCountDelta: result.byteCountDelta,
});
} catch (err) {
if (err instanceof WriteConflictError) {
return c.json({
error: "write_conflict",
message: err.message,
currentMtime: err.currentMtime,
currentContentHash: err.currentContentHash,
}, 409);
}
if (err instanceof FilePathSafetyError) return pathSafetyErrorResponse(c, err);
if (err instanceof FileWriteError) {
return c.json({ error: err.code, message: err.message, ...(err.details ?? {}) }, 500);
}
return c.json({ error: "write_failed", message: err instanceof Error ? err.message : String(err) }, 500);
}
});
return app;
}
function inferContentType(absPath: string): string {
const ext = path.extname(absPath).toLowerCase();
switch (ext) {
case ".png": return "image/png";
case ".jpg":
case ".jpeg": return "image/jpeg";
case ".gif": return "image/gif";
case ".webp": return "image/webp";
case ".svg": return "image/svg+xml";
case ".mp4": return "video/mp4";
case ".webm": return "video/webm";
case ".pdf": return "application/pdf";
case ".md":
case ".txt":
case ".log": return "text/plain; charset=utf-8";
case ".json": return "application/json; charset=utf-8";
case ".yaml":
case ".yml": return "text/yaml; charset=utf-8";
case ".js":
case ".ts":
case ".tsx":
case ".jsx":
case ".css":
case ".html": return "text/plain; charset=utf-8";
default: return "application/octet-stream";
}
}
// Re-export for the route-order discipline test in workflow-routes.
export { resolveAllowedPath };
+40
View File
@@ -0,0 +1,40 @@
// UI Enhancement Pack v0 — progress browse view route.
//
// Endpoint:
// GET /api/progress/tree
//
// Returns the indexed PROGRESS.md hierarchy across operator-allowlisted
// scan roots (configured via OPENRIG_PROGRESS_SCAN_ROOTS). Empty roots
// → 503 with a setup hint, mirroring the slices-route shape so the UI
// can render a clear configuration message instead of a generic error.
import { Hono } from "hono";
import type { ProgressIndexer } from "../domain/progress/progress-indexer.js";
export interface ProgressRoutesDeps {
indexer: ProgressIndexer;
}
export function progressRoutes(): Hono {
const app = new Hono();
function getDeps(c: { get: (key: string) => unknown }): ProgressRoutesDeps | null {
const indexer = c.get("progressIndexer" as never) as ProgressIndexer | undefined;
if (!indexer) return null;
return { indexer };
}
app.get("/tree", (c) => {
const deps = getDeps(c);
if (!deps) return c.json({ error: "progress_indexer_unavailable" }, 503);
if (!deps.indexer.isReady()) {
return c.json({
error: "progress_scan_roots_not_configured",
hint: "Set OPENRIG_PROGRESS_SCAN_ROOTS=name1:/abs/path,name2:/abs/path and restart the daemon.",
}, 503);
}
return c.json(deps.indexer.scan());
});
return app;
}
+14
View File
@@ -67,6 +67,8 @@ import { watchdogRoutes } from "./routes/watchdog.js";
import { workflowRoutes } from "./routes/workflow.js";
import { missionControlRoutes } from "./routes/mission-control.js";
import { slicesRoutes } from "./routes/slices.js";
import { filesRoutes } from "./routes/files.js";
import { progressRoutes } from "./routes/progress.js";
import type { StreamStore } from "./domain/stream-store.js";
import type { QueueRepository } from "./domain/queue-repository.js";
import type { InboxHandler } from "./domain/inbox-handler.js";
@@ -148,6 +150,12 @@ export interface AppDeps {
// "slices_root_not_configured" 503 so the UI can surface a setup hint.
sliceIndexer?: import("./domain/slices/slice-indexer.js").SliceIndexer;
sliceDetailProjector?: import("./domain/slices/slice-detail-projector.js").SliceDetailProjector;
/** UI Enhancement Pack v0 — file allowlist + browser routes (item 3). */
filesAllowlist?: import("./domain/files/path-safety.js").AllowlistRoot[];
/** UI Enhancement Pack v0 — atomic write service (item 4). */
fileWriteService?: import("./domain/files/file-write-service.js").FileWriteService | null;
/** UI Enhancement Pack v0 — workspace PROGRESS.md indexer (item 1B). */
progressIndexer?: import("./domain/progress/progress-indexer.js").ProgressIndexer;
missionControlAuditBrowse?: import("./domain/mission-control/audit-browse.js").MissionControlAuditBrowse;
missionControlNotificationDispatcher?: import("./domain/mission-control/notification-dispatcher.js").MissionControlNotificationDispatcher;
/**
@@ -324,6 +332,9 @@ export function createApp(deps: AppDeps): Hono {
c.set("missionControlFleetCliCapability" as never, deps.missionControlFleetCliCapability);
c.set("sliceIndexer" as never, deps.sliceIndexer);
c.set("sliceDetailProjector" as never, deps.sliceDetailProjector);
c.set("filesAllowlist" as never, deps.filesAllowlist);
c.set("fileWriteService" as never, deps.fileWriteService);
c.set("progressIndexer" as never, deps.progressIndexer);
c.set("missionControlAuditBrowse" as never, deps.missionControlAuditBrowse);
c.set("missionControlNotificationDispatcher" as never, deps.missionControlNotificationDispatcher);
c.set("specReviewService" as never, deps.specReviewService);
@@ -384,6 +395,9 @@ export function createApp(deps: AppDeps): Hono {
);
// Slice Story View v0 — slice indexer + per-tab payload routes.
app.route("/api/slices", slicesRoutes());
// UI Enhancement Pack v0 — files (item 3 + item 4) + progress (item 1B) routes.
app.route("/api/files", filesRoutes());
app.route("/api/progress", progressRoutes());
app.route("/api/rigs/:rigId/env", envRoutes());
app.route("/api/restore-check", restoreCheckRoutes);
+20
View File
@@ -717,6 +717,26 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
deps.sliceDetailProjector = sliceDetailProjector;
}
// UI Enhancement Pack v0:
// - file allowlist (item 3) from OPENRIG_FILES_ALLOWLIST
// - atomic write service (item 4) wired only when allowlist non-empty
// - progress scan-roots (item 1B) from OPENRIG_PROGRESS_SCAN_ROOTS
//
// Empty env → empty allowlist / no-roots indexer; routes return 503
// with structured config hints so the UI can surface a setup message
// instead of a generic error.
{
const { readAllowlistFromEnv } = await import("./domain/files/path-safety.js");
const { FileWriteService } = await import("./domain/files/file-write-service.js");
const { ProgressIndexer, readProgressRootsFromEnv } = await import("./domain/progress/progress-indexer.js");
const filesAllowlist = readAllowlistFromEnv();
deps.filesAllowlist = filesAllowlist;
deps.fileWriteService = filesAllowlist.length > 0
? new FileWriteService({ allowlist: filesAllowlist })
: null;
deps.progressIndexer = new ProgressIndexer({ roots: readProgressRootsFromEnv() });
}
const sessionTransport = deps.sessionTransport;
if (sessionTransport) {
const watchdogPolicyEngine = new WatchdogPolicyEngine({
@@ -0,0 +1,177 @@
// UI Enhancement Pack v0 — path-safety helper tests.
//
// Pins the load-bearing fail-closed semantics of the file allowlist:
// - root_unknown rejection
// - .. escape rejection at the segment boundary (not substring)
// - absolute-path-as-relative-path rejection
// - symlink-escaping-root rejection (realpath check)
// - filename containing ".." substring (e.g., foo..bar) does NOT
// reject — only literal `..` segments do
// - the base case (path = "") resolves to the root itself
//
// Pure unit tests — no Hono app, no daemon wiring.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
FilePathSafetyError,
readAllowlistFromEnv,
resolveAllowedDirectory,
resolveAllowedFile,
resolveAllowedPath,
} from "../src/domain/files/path-safety.js";
describe("UI Enhancement Pack v0 — readAllowlistFromEnv", () => {
it("returns empty list when env unset", () => {
expect(readAllowlistFromEnv({})).toEqual([]);
});
it("parses comma-separated name:path pairs", () => {
const list = readAllowlistFromEnv({
OPENRIG_FILES_ALLOWLIST: "workspace:/abs/path1, openrig-hub: /abs/path2",
});
expect(list).toHaveLength(2);
expect(list[0]?.name).toBe("workspace");
expect(list[1]?.name).toBe("openrig-hub");
});
it("ignores pairs with no colon, empty name, or non-absolute path", () => {
const list = readAllowlistFromEnv({
OPENRIG_FILES_ALLOWLIST: "no-colon-here, :missing-name, name:relative-path, ok:/abs/ok",
});
expect(list.map((r) => r.name)).toEqual(["ok"]);
});
it("dedupes by name (last wins)", () => {
const list = readAllowlistFromEnv({
OPENRIG_FILES_ALLOWLIST: "x:/path/a, x:/path/b",
});
expect(list).toHaveLength(1);
expect(list[0]?.canonicalPath).toContain("/path/b");
});
it("falls back to RIGGED_FILES_ALLOWLIST when OPENRIG_FILES_ALLOWLIST is empty", () => {
const list = readAllowlistFromEnv({
OPENRIG_FILES_ALLOWLIST: "",
RIGGED_FILES_ALLOWLIST: "legacy:/abs/legacy",
});
expect(list.map((r) => r.name)).toEqual(["legacy"]);
});
});
describe("UI Enhancement Pack v0 — resolveAllowedPath", () => {
let tempDir: string;
let allowlist: { name: string; canonicalPath: string }[];
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "files-safety-"));
mkdirSync(join(tempDir, "workspace", "subdir"), { recursive: true });
writeFileSync(join(tempDir, "workspace", "STEERING.md"), "# steering");
writeFileSync(join(tempDir, "workspace", "subdir", "nested.md"), "# nested");
// Outside-root file for symlink-escape test.
mkdirSync(join(tempDir, "outside-root"), { recursive: true });
writeFileSync(join(tempDir, "outside-root", "secret.txt"), "secret");
// Canonicalize via realpath to match macOS /var → /private/var
// resolution; production code does the same in readAllowlistFromEnv.
allowlist = [{ name: "workspace", canonicalPath: realpathSync(join(tempDir, "workspace")) }];
});
afterEach(() => rmSync(tempDir, { recursive: true, force: true }));
it("resolves a relative path inside the root", () => {
const resolved = resolveAllowedPath(allowlist, "workspace", "STEERING.md");
expect(resolved).toBe(realpathSync(join(tempDir, "workspace", "STEERING.md")));
});
it("resolves '' (empty path) to the root itself", () => {
expect(resolveAllowedPath(allowlist, "workspace", "")).toBe(realpathSync(join(tempDir, "workspace")));
});
it("rejects unknown root with code 'root_unknown'", () => {
expect(() => resolveAllowedPath(allowlist, "not-allowlisted", "any.md"))
.toThrowError(/allowlist root 'not-allowlisted' is not configured/);
try { resolveAllowedPath(allowlist, "not-allowlisted", "any.md"); }
catch (e) { expect((e as FilePathSafetyError).code).toBe("root_unknown"); }
});
it("rejects '..' segment with code 'path_escape'", () => {
expect(() => resolveAllowedPath(allowlist, "workspace", "../outside-root/secret.txt"))
.toThrowError(/contains a '\.\.' segment/);
try { resolveAllowedPath(allowlist, "workspace", "../outside-root/secret.txt"); }
catch (e) { expect((e as FilePathSafetyError).code).toBe("path_escape"); }
});
it("does NOT reject filenames containing '..' as a substring (e.g., foo..bar)", () => {
writeFileSync(join(tempDir, "workspace", "foo..bar.md"), "ok");
const resolved = resolveAllowedPath(allowlist, "workspace", "foo..bar.md");
expect(resolved).toBe(realpathSync(join(tempDir, "workspace", "foo..bar.md")));
});
it("rejects an absolute path passed as relative", () => {
expect(() => resolveAllowedPath(allowlist, "workspace", "/etc/passwd"))
.toThrowError(/must not be absolute/);
try { resolveAllowedPath(allowlist, "workspace", "/etc/passwd"); }
catch (e) { expect((e as FilePathSafetyError).code).toBe("path_invalid"); }
});
it("rejects symlink whose realpath escapes the root", () => {
// Create a symlink inside the workspace pointing OUTSIDE the root.
symlinkSync(join(tempDir, "outside-root", "secret.txt"), join(tempDir, "workspace", "escape-link"));
expect(() => resolveAllowedPath(allowlist, "workspace", "escape-link"))
.toThrowError(/falls outside allowlist root/);
try { resolveAllowedPath(allowlist, "workspace", "escape-link"); }
catch (e) { expect((e as FilePathSafetyError).code).toBe("path_escape"); }
});
it("does NOT match a sibling path with the same prefix (path.sep boundary)", () => {
// Allowlist root is ".../workspace"; a sibling ".../workspace-other"
// should NOT be reachable by passing path = "" or any relative path.
mkdirSync(join(tempDir, "workspace-other"), { recursive: true });
writeFileSync(join(tempDir, "workspace-other", "leak.md"), "leak");
// The file resolves cleanly inside the right root if the path
// is relative to it; the wrong-root attack doesn't even get
// expressed without a mismatched root, so the test verifies the
// root-name mismatch instead.
expect(() => resolveAllowedPath(allowlist, "workspace-other", "leak.md"))
.toThrowError(/'workspace-other' is not configured/);
});
});
describe("UI Enhancement Pack v0 — resolveAllowedFile / resolveAllowedDirectory", () => {
let tempDir: string;
let allowlist: { name: string; canonicalPath: string }[];
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "files-safety-typed-"));
mkdirSync(join(tempDir, "ws", "dir-only"), { recursive: true });
writeFileSync(join(tempDir, "ws", "real.md"), "hi");
allowlist = [{ name: "ws", canonicalPath: realpathSync(join(tempDir, "ws")) }];
});
afterEach(() => rmSync(tempDir, { recursive: true, force: true }));
it("resolveAllowedFile on a real file returns the absolute path", () => {
expect(resolveAllowedFile(allowlist, "ws", "real.md")).toBe(realpathSync(join(tempDir, "ws", "real.md")));
});
it("resolveAllowedFile on a directory rejects with 'not_a_file'", () => {
try { resolveAllowedFile(allowlist, "ws", "dir-only"); }
catch (e) { expect((e as FilePathSafetyError).code).toBe("not_a_file"); }
});
it("resolveAllowedFile on a missing path rejects with 'stat_failed'", () => {
try { resolveAllowedFile(allowlist, "ws", "ghost.md"); }
catch (e) { expect((e as FilePathSafetyError).code).toBe("stat_failed"); }
});
it("resolveAllowedDirectory on a real directory returns the absolute path", () => {
expect(resolveAllowedDirectory(allowlist, "ws", "dir-only")).toBe(realpathSync(join(tempDir, "ws", "dir-only")));
});
it("resolveAllowedDirectory on a file rejects with 'not_a_directory'", () => {
try { resolveAllowedDirectory(allowlist, "ws", "real.md"); }
catch (e) { expect((e as FilePathSafetyError).code).toBe("not_a_directory"); }
});
});
+235
View File
@@ -0,0 +1,235 @@
// UI Enhancement Pack v0 — files routes end-to-end tests.
//
// Drives the routes against a hand-mounted Hono app with a temp
// allowlist. Pins:
// - GET /api/files/roots: empty roots → hint; populated → list
// - GET /api/files/list: directory listing + path-safety negatives
// - GET /api/files/read: content + mtime + contentHash + size
// - GET /api/files/asset: image bytes with right Content-Type
// - POST /api/files/write: success → audit row appended; mtime
// mismatch → 409 with current{Mtime,ContentHash}
// - 503 graceful path when filesAllowlist context unset
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Hono } from "hono";
import { createHash } from "node:crypto";
import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { filesRoutes } from "../src/routes/files.js";
import { FileWriteService } from "../src/domain/files/file-write-service.js";
import type { AllowlistRoot } from "../src/domain/files/path-safety.js";
function buildApp(opts: { allowlist: AllowlistRoot[]; writeService: FileWriteService | null } | null): Hono {
const app = new Hono();
app.use("*", async (c, next) => {
if (opts) {
c.set("filesAllowlist" as never, opts.allowlist);
c.set("fileWriteService" as never, opts.writeService);
}
await next();
});
app.route("/api/files", filesRoutes());
return app;
}
function sha256(content: string | Buffer): string {
return createHash("sha256").update(content).digest("hex");
}
describe("UI Enhancement Pack v0 — /api/files routes", () => {
let tempDir: string;
let allowlist: AllowlistRoot[];
let writeService: FileWriteService;
let app: Hono;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "files-routes-"));
mkdirSync(join(tempDir, "workspace", "subdir"), { recursive: true });
writeFileSync(join(tempDir, "workspace", "STEERING.md"), "# steering content\n");
writeFileSync(join(tempDir, "workspace", "subdir", "nested.md"), "# nested\n");
// Tiny PNG signature for asset-type detection.
writeFileSync(join(tempDir, "workspace", "image.png"), Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]));
allowlist = [{ name: "workspace", canonicalPath: realpathSync(join(tempDir, "workspace")) }];
writeService = new FileWriteService({
allowlist,
auditFilePath: join(tempDir, "audit.jsonl"),
});
app = buildApp({ allowlist, writeService });
});
afterEach(() => rmSync(tempDir, { recursive: true, force: true }));
describe("GET /roots", () => {
it("returns 503 when filesAllowlist context is unset", async () => {
const res = await buildApp(null).request("/api/files/roots");
expect(res.status).toBe(503);
});
it("returns empty roots + setup hint when allowlist is empty", async () => {
const res = await buildApp({ allowlist: [], writeService: null }).request("/api/files/roots");
expect(res.status).toBe(200);
const body = (await res.json()) as { roots: AllowlistRoot[]; hint?: string };
expect(body.roots).toEqual([]);
expect(body.hint).toContain("OPENRIG_FILES_ALLOWLIST");
});
it("returns the configured roots", async () => {
const res = await app.request("/api/files/roots");
expect(res.status).toBe(200);
const body = (await res.json()) as { roots: Array<{ name: string; path: string }> };
expect(body.roots).toEqual([{ name: "workspace", path: realpathSync(join(tempDir, "workspace")) }]);
});
});
describe("GET /list", () => {
it("lists root directory entries with type + size + mtime", async () => {
const res = await app.request("/api/files/list?root=workspace&path=");
expect(res.status).toBe(200);
const body = (await res.json()) as { entries: Array<{ name: string; type: string }> };
const names = body.entries.map((e) => e.name).sort();
expect(names).toContain("STEERING.md");
expect(names).toContain("subdir");
expect(names).toContain("image.png");
});
it("sorts directories before files", async () => {
const res = await app.request("/api/files/list?root=workspace&path=");
const body = (await res.json()) as { entries: Array<{ name: string; type: string }> };
const types = body.entries.map((e) => e.type);
// First N entries are dirs.
const dirCount = types.findIndex((t) => t !== "dir");
const allDirsFirst = types.slice(0, dirCount === -1 ? types.length : dirCount).every((t) => t === "dir");
expect(allDirsFirst).toBe(true);
});
it("rejects '..' escape with 400", async () => {
const res = await app.request("/api/files/list?root=workspace&path=..%2F..%2F");
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("path_escape");
});
it("rejects unknown root with 400", async () => {
const res = await app.request("/api/files/list?root=does-not-exist&path=");
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("root_unknown");
});
});
describe("GET /read", () => {
it("returns content + mtime + contentHash + size", async () => {
const res = await app.request("/api/files/read?root=workspace&path=STEERING.md");
expect(res.status).toBe(200);
const body = (await res.json()) as { content: string; mtime: string; contentHash: string; size: number };
expect(body.content).toBe("# steering content\n");
expect(body.contentHash).toBe(sha256("# steering content\n"));
expect(body.size).toBeGreaterThan(0);
expect(body.mtime).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it("rejects path-traversal with 400", async () => {
const res = await app.request("/api/files/read?root=workspace&path=..%2Fescape.md");
expect(res.status).toBe(400);
});
});
describe("GET /asset", () => {
it("serves a .png file with image/png Content-Type", async () => {
const res = await app.request("/api/files/asset?root=workspace&path=image.png");
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("image/png");
expect(res.headers.get("cache-control")).toContain("max-age");
});
});
describe("POST /write", () => {
it("returns 503 with hint when no writeService is wired", async () => {
const res = await buildApp({ allowlist, writeService: null }).request("/api/files/write", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ root: "workspace", path: "STEERING.md", content: "x", expectedMtime: "x", expectedContentHash: "x", actor: "y" }),
});
expect(res.status).toBe(503);
});
it("rejects when expectedMtime mismatches with 409 + current values", async () => {
const res = await app.request("/api/files/write", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
root: "workspace",
path: "STEERING.md",
content: "rewritten",
expectedMtime: "2099-01-01T00:00:00.000Z",
expectedContentHash: "deadbeef",
actor: "test@r",
}),
});
expect(res.status).toBe(409);
const body = (await res.json()) as { error: string; currentMtime: string; currentContentHash: string };
expect(body.error).toBe("write_conflict");
expect(body.currentContentHash).toBe(sha256("# steering content\n"));
});
it("succeeds when expectedMtime + expectedContentHash match; appends audit row", async () => {
const target = join(tempDir, "workspace", "STEERING.md");
const stat = statSync(target);
const expectedMtime = stat.mtime.toISOString();
const expectedContentHash = sha256(readFileSync(target));
const res = await app.request("/api/files/write", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
root: "workspace",
path: "STEERING.md",
content: "# new steering\n",
expectedMtime,
expectedContentHash,
actor: "test@r",
}),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { newContentHash: string; byteCountDelta: number };
expect(body.newContentHash).toBe(sha256("# new steering\n"));
// File on disk has the new content.
expect(readFileSync(target, "utf-8")).toBe("# new steering\n");
// Audit row appended.
const auditPath = join(tempDir, "audit.jsonl");
const audit = readFileSync(auditPath, "utf-8").trim().split("\n");
expect(audit).toHaveLength(1);
const row = JSON.parse(audit[0]!) as { actor: string; root: string; path: string };
expect(row.actor).toBe("test@r");
expect(row.root).toBe("workspace");
expect(row.path).toBe("STEERING.md");
});
it("rejects missing required fields with 400", async () => {
const res = await app.request("/api/files/write", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ root: "workspace" }),
});
expect(res.status).toBe(400);
});
it("rejects path-traversal with 400 (path-safety beats stat)", async () => {
const res = await app.request("/api/files/write", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
root: "workspace",
path: "../escape.md",
content: "x",
expectedMtime: "x",
expectedContentHash: "x",
actor: "y",
}),
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("path_escape");
});
});
});
@@ -0,0 +1,150 @@
// UI Enhancement Pack v0 — progress indexer tests.
//
// Drives the indexer against a temp workspace fixture with a few
// PROGRESS.md files at different nesting depths. Pins:
// - root walk respects max depth
// - PROGRESS.md files at deeper levels picked up
// - skip dirs (node_modules, .git, .worktrees, dist, etc.)
// - frontmatter ignored; rows parsed from `[ ]` / `[x]` / `[~]`
// and `## Heading` lines
// - hierarchy depth derived from indent
// - aggregate counts add up across files
// - empty roots → isReady() false; scan returns no files
// - readProgressRootsFromEnv parses delimited pairs
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ProgressIndexer, readProgressRootsFromEnv } from "../src/domain/progress/progress-indexer.js";
describe("UI Enhancement Pack v0 — readProgressRootsFromEnv", () => {
it("returns empty when env unset", () => {
expect(readProgressRootsFromEnv({})).toEqual([]);
});
it("parses comma-separated name:path pairs", () => {
const roots = readProgressRootsFromEnv({
OPENRIG_PROGRESS_SCAN_ROOTS: "shared:/abs/shared, hub:/abs/hub",
});
expect(roots.map((r) => r.name)).toEqual(["shared", "hub"]);
});
});
describe("UI Enhancement Pack v0 — ProgressIndexer", () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "progress-indexer-"));
});
afterEach(() => rmSync(tempDir, { recursive: true, force: true }));
it("isReady() = false when no roots configured", () => {
const indexer = new ProgressIndexer({ roots: [] });
expect(indexer.isReady()).toBe(false);
expect(indexer.scan().files).toEqual([]);
});
it("isReady() = true when at least one root configured", () => {
mkdirSync(join(tempDir, "ws"), { recursive: true });
const indexer = new ProgressIndexer({ roots: [{ name: "ws", canonicalPath: join(tempDir, "ws") }] });
expect(indexer.isReady()).toBe(true);
});
it("walks scan roots and finds PROGRESS.md files at each level", () => {
mkdirSync(join(tempDir, "ws", "missions", "alpha"), { recursive: true });
writeFileSync(join(tempDir, "ws", "PROGRESS.md"), "# Top\n- [x] root-done\n- [ ] root-active\n");
writeFileSync(join(tempDir, "ws", "missions", "alpha", "PROGRESS.md"), "# Alpha\n- [ ] mission-active\n");
const indexer = new ProgressIndexer({ roots: [{ name: "ws", canonicalPath: join(tempDir, "ws") }] });
const result = indexer.scan();
expect(result.files).toHaveLength(2);
expect(result.files.map((f) => f.relPath).sort()).toEqual(["PROGRESS.md", "missions/alpha/PROGRESS.md"]);
});
it("parses checkbox states (active / done / blocked)", () => {
mkdirSync(join(tempDir, "ws"), { recursive: true });
writeFileSync(join(tempDir, "ws", "PROGRESS.md"),
"# Test\n- [ ] active item\n- [x] done item\n- [~] blocked item\n");
const indexer = new ProgressIndexer({ roots: [{ name: "ws", canonicalPath: join(tempDir, "ws") }] });
const result = indexer.scan();
const file = result.files[0]!;
const checkboxes = file.rows.filter((r) => r.kind === "checkbox");
expect(checkboxes).toHaveLength(3);
expect(checkboxes[0]?.status).toBe("active");
expect(checkboxes[1]?.status).toBe("done");
expect(checkboxes[2]?.status).toBe("blocked");
expect(file.counts).toEqual({ total: 3, done: 1, blocked: 1, active: 1 });
});
it("emits heading rows for ## / ### / ####", () => {
mkdirSync(join(tempDir, "ws"), { recursive: true });
writeFileSync(join(tempDir, "ws", "PROGRESS.md"),
"# Title\n## Section A\n- [ ] item-a\n### Subsection\n- [ ] item-b\n");
const indexer = new ProgressIndexer({ roots: [{ name: "ws", canonicalPath: join(tempDir, "ws") }] });
const result = indexer.scan();
const headings = result.files[0]!.rows.filter((r) => r.kind === "heading");
expect(headings).toHaveLength(2);
expect(headings[0]?.text).toBe("Section A");
expect(headings[0]?.depth).toBe(0); // ## → depth 0
expect(headings[1]?.text).toBe("Subsection");
expect(headings[1]?.depth).toBe(1); // ### → depth 1
});
it("derives indent depth from leading 2-space pairs", () => {
mkdirSync(join(tempDir, "ws"), { recursive: true });
writeFileSync(join(tempDir, "ws", "PROGRESS.md"),
"- [ ] level0\n - [ ] level1\n - [ ] level2\n");
const indexer = new ProgressIndexer({ roots: [{ name: "ws", canonicalPath: join(tempDir, "ws") }] });
const checkboxes = indexer.scan().files[0]!.rows.filter((r) => r.kind === "checkbox");
expect(checkboxes.map((r) => r.depth)).toEqual([0, 1, 2]);
});
it("strips YAML frontmatter before parsing rows", () => {
mkdirSync(join(tempDir, "ws"), { recursive: true });
writeFileSync(join(tempDir, "ws", "PROGRESS.md"),
"---\nslice: foo\nstatus: active\n---\n# Title\n- [x] real-row\n");
const indexer = new ProgressIndexer({ roots: [{ name: "ws", canonicalPath: join(tempDir, "ws") }] });
const file = indexer.scan().files[0]!;
expect(file.title).toBe("Title");
expect(file.counts.total).toBe(1);
});
it("skips dotdirs, node_modules, .git, .worktrees, dist", () => {
mkdirSync(join(tempDir, "ws", "node_modules", "x"), { recursive: true });
mkdirSync(join(tempDir, "ws", ".git"), { recursive: true });
mkdirSync(join(tempDir, "ws", ".worktrees", "branch"), { recursive: true });
mkdirSync(join(tempDir, "ws", "dist"), { recursive: true });
writeFileSync(join(tempDir, "ws", "node_modules", "x", "PROGRESS.md"), "- [ ] nope\n");
writeFileSync(join(tempDir, "ws", ".git", "PROGRESS.md"), "- [ ] nope\n");
writeFileSync(join(tempDir, "ws", ".worktrees", "branch", "PROGRESS.md"), "- [ ] nope\n");
writeFileSync(join(tempDir, "ws", "dist", "PROGRESS.md"), "- [ ] nope\n");
writeFileSync(join(tempDir, "ws", "PROGRESS.md"), "- [ ] real\n");
const indexer = new ProgressIndexer({ roots: [{ name: "ws", canonicalPath: join(tempDir, "ws") }] });
const result = indexer.scan();
expect(result.files).toHaveLength(1);
expect(result.files[0]?.relPath).toBe("PROGRESS.md");
});
it("respects maxDepth", () => {
mkdirSync(join(tempDir, "ws", "a", "b", "c", "d"), { recursive: true });
writeFileSync(join(tempDir, "ws", "a", "b", "c", "d", "PROGRESS.md"), "- [ ] deep\n");
const indexer = new ProgressIndexer({
roots: [{ name: "ws", canonicalPath: join(tempDir, "ws") }],
maxDepth: 2,
});
expect(indexer.scan().files).toHaveLength(0);
});
it("aggregate counts sum across files", () => {
mkdirSync(join(tempDir, "ws", "x"), { recursive: true });
writeFileSync(join(tempDir, "ws", "PROGRESS.md"), "- [x] a\n- [ ] b\n");
writeFileSync(join(tempDir, "ws", "x", "PROGRESS.md"), "- [~] c\n- [x] d\n");
const result = new ProgressIndexer({ roots: [{ name: "ws", canonicalPath: join(tempDir, "ws") }] }).scan();
expect(result.aggregate.totalFiles).toBe(2);
expect(result.aggregate.totalRows).toBe(4);
expect(result.aggregate.totalDone).toBe(2);
expect(result.aggregate.totalActive).toBe(1);
expect(result.aggregate.totalBlocked).toBe(1);
});
});
+33 -1
View File
@@ -1,6 +1,6 @@
import { type CSSProperties, type ReactNode, useCallback, useEffect, useState, createContext, useContext } from "react";
import { Link, useRouterState } from "@tanstack/react-router";
import { Cog, FileText, Layers, SquarePlus } from "lucide-react";
import { CheckSquare, Cog, FileText, Folder, Layers, SquarePlus } from "lucide-react";
import { Explorer, type ExplorerSurface } from "./Explorer.js";
import { SharedDetailDrawer, type DrawerSelection } from "./SharedDetailDrawer.js";
import type { DiscoveryPlacementTarget } from "./DiscoveryPanel.js";
@@ -97,6 +97,8 @@ function resolveSurfaceTitle(pathname: string, rigId: string | null, rigName: st
if (pathname.startsWith("/bundles/inspect")) return "Bundle Inspector";
if (pathname.startsWith("/bundles/install")) return "Bundle Install";
if (pathname.startsWith("/slices")) return "Slices";
if (pathname.startsWith("/progress")) return "Progress";
if (pathname.startsWith("/files")) return "Files";
return null;
}
@@ -297,6 +299,36 @@ export function AppShell({ children }: AppShellProps) {
>
<Layers className="h-4 w-4" />
</Link>
{/* UI Enhancement Pack v0 — top-level Progress (item 1B) +
Files (items 3 + 4) workspace links. Both navigate to
their own routes with internal layouts (no left-sidebar
overlay; the route body owns its UX entirely). */}
<Link
to="/progress"
data-testid="progress-link"
className={`inline-flex h-8 w-8 items-center justify-center text-stone-700 transition-colors ${
pathname.startsWith("/progress")
? "text-stone-950"
: "hover:text-stone-950"
}`}
aria-label="Open progress"
title="Open progress"
>
<CheckSquare className="h-4 w-4" />
</Link>
<Link
to="/files"
data-testid="files-link"
className={`inline-flex h-8 w-8 items-center justify-center text-stone-700 transition-colors ${
pathname.startsWith("/files")
? "text-stone-950"
: "hover:text-stone-950"
}`}
aria-label="Open files"
title="Open files"
>
<Folder className="h-4 w-4" />
</Link>
<button
type="button"
data-testid="system-toggle"
@@ -0,0 +1,430 @@
// UI Enhancement Pack v0 — Files browser workspace.
//
// Top-level center-workspace surface for /files route. Two-pane shape:
// - Left: allowlist root selector + directory tree of the selected root.
// - Right: file content panel (markdown via MarkdownViewer, code via
// SyntaxHighlight, images inline, other → "view as text" affordance).
//
// Item 4 (edit mode) is integrated: a header toggle flips the right
// pane into a `<textarea>` editor with Save/Cancel; Save does the
// daemon's atomic-write contract; 409 conflicts surface a refresh
// affordance per the PRD's recommendation. Per item 4's recommended
// landing posture: lightweight `<textarea>` (no CodeMirror).
import { useEffect, useMemo, useState } from "react";
import {
fileAssetUrl,
useFilesList,
useFilesRead,
useFilesRoots,
useFilesWrite,
type AllowlistRoot,
type FileEntry,
type FilesReadResponse,
type FileWriteResult,
} from "../../hooks/useFiles.js";
import { MarkdownViewer } from "../markdown/MarkdownViewer.js";
import { SyntaxHighlight } from "../markdown/SyntaxHighlight.js";
const TEXT_LIKE_EXTENSIONS = new Set([".md", ".txt", ".log", ".yaml", ".yml", ".json", ".js", ".jsx", ".ts", ".tsx", ".py", ".sh", ".bash", ".sql", ".css", ".html"]);
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]);
const DOWNLOAD_ONLY_EXTENSIONS = new Set([".zip", ".tar", ".gz", ".pdf", ".mp4", ".webm", ".mov"]);
function isUnavailable(data: unknown): data is { unavailable: true; error: string; hint?: string } {
return Boolean(data && typeof data === "object" && "unavailable" in (data as Record<string, unknown>));
}
export function FilesWorkspace() {
const roots = useFilesRoots();
const [selectedRoot, setSelectedRoot] = useState<string | null>(null);
const [currentPath, setCurrentPath] = useState<string>("");
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [editMode, setEditMode] = useState<boolean>(false);
// Default-select the first root once roots arrive.
useEffect(() => {
if (selectedRoot) return;
if (!roots.data || isUnavailable(roots.data)) return;
const first = roots.data.roots[0];
if (first) setSelectedRoot(first.name);
}, [roots.data, selectedRoot]);
// Reset path + selected file when root changes.
useEffect(() => {
setCurrentPath("");
setSelectedFile(null);
setEditMode(false);
}, [selectedRoot]);
return (
<div data-testid="files-workspace" className="flex h-full flex-col lg:pl-[var(--workspace-left-offset,0px)] lg:pr-[var(--workspace-right-offset,0px)]">
<header className="border-b border-stone-200 bg-stone-50 px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-500">Workspace</div>
<h1 className="font-headline text-xl font-bold tracking-tight text-stone-900">Files</h1>
</header>
<div className="flex flex-1 min-h-0">
<aside data-testid="files-tree-pane" className="w-72 shrink-0 overflow-y-auto border-r border-stone-200 bg-stone-50">
<RootSelector roots={roots.data} isLoading={roots.isLoading} selectedRoot={selectedRoot} onSelect={setSelectedRoot} />
{selectedRoot && (
<>
<Breadcrumbs root={selectedRoot} path={currentPath} onNavigate={setCurrentPath} />
<DirectoryTree
root={selectedRoot}
path={currentPath}
onEnterDir={(rel) => { setCurrentPath(rel); setSelectedFile(null); }}
onSelectFile={(rel) => { setSelectedFile(rel); setEditMode(false); }}
selectedFile={selectedFile}
/>
</>
)}
</aside>
<main data-testid="files-content-pane" className="flex-1 min-w-0 overflow-y-auto bg-white">
{!selectedRoot && (
<div className="m-auto p-4 font-mono text-[10px] text-stone-400">
Select an allowlist root to browse.
</div>
)}
{selectedRoot && !selectedFile && (
<div className="p-4 font-mono text-[10px] text-stone-400" data-testid="files-no-selection">
Select a file from the tree.
</div>
)}
{selectedRoot && selectedFile && (
<FileContentPanel
root={selectedRoot}
path={selectedFile}
editMode={editMode}
onToggleEditMode={() => setEditMode((v) => !v)}
/>
)}
</main>
</div>
</div>
);
}
function RootSelector({
roots,
isLoading,
selectedRoot,
onSelect,
}: {
roots: ReturnType<typeof useFilesRoots>["data"] | undefined;
isLoading: boolean;
selectedRoot: string | null;
onSelect: (name: string) => void;
}) {
if (isLoading) return <div className="p-3 font-mono text-[10px] text-stone-400">Loading roots…</div>;
if (!roots) return null;
if (isUnavailable(roots)) {
return (
<div data-testid="files-roots-unavailable" className="p-3 font-mono text-[10px] text-stone-500">
<div>Files routes unavailable.</div>
{roots.hint && <div className="mt-1 text-stone-400">{roots.hint}</div>}
</div>
);
}
if (roots.roots.length === 0) {
return (
<div data-testid="files-roots-empty" className="p-3 font-mono text-[10px] text-stone-500">
<div>No allowlist roots configured.</div>
{roots.hint && <div className="mt-1 text-stone-400">{roots.hint}</div>}
</div>
);
}
return (
<div data-testid="files-root-selector" className="border-b border-stone-200 p-2">
<div className="mb-1 font-mono text-[8px] uppercase tracking-[0.18em] text-stone-500">Roots</div>
<ul>
{roots.roots.map((r: AllowlistRoot) => (
<li key={r.name}>
<button
type="button"
data-testid={`files-root-${r.name}`}
data-active={selectedRoot === r.name}
onClick={() => onSelect(r.name)}
className={`block w-full px-2 py-1 text-left font-mono text-[10px] hover:bg-stone-100 ${
selectedRoot === r.name ? "bg-stone-200/80 text-stone-900" : "text-stone-700"
}`}
title={r.path}
>
{r.name}
</button>
</li>
))}
</ul>
</div>
);
}
function Breadcrumbs({ root, path, onNavigate }: { root: string; path: string; onNavigate: (path: string) => void }) {
const segments = path ? path.split("/") : [];
return (
<nav data-testid="files-breadcrumbs" className="flex flex-wrap items-baseline gap-1 border-b border-stone-200 px-2 py-1 font-mono text-[10px] text-stone-700">
<button type="button" onClick={() => onNavigate("")} className="font-bold hover:underline">{root}</button>
{segments.map((seg, idx) => {
const accumulated = segments.slice(0, idx + 1).join("/");
return (
<span key={accumulated}>
<span className="mx-0.5 text-stone-400">/</span>
<button type="button" onClick={() => onNavigate(accumulated)} className="hover:underline">
{seg}
</button>
</span>
);
})}
</nav>
);
}
function DirectoryTree({
root,
path,
onEnterDir,
onSelectFile,
selectedFile,
}: {
root: string;
path: string;
onEnterDir: (rel: string) => void;
onSelectFile: (rel: string) => void;
selectedFile: string | null;
}) {
const list = useFilesList(root, path);
if (list.isLoading) return <div className="p-3 font-mono text-[10px] text-stone-400">Loading…</div>;
if (list.isError) return <div data-testid="files-list-error" className="p-3 font-mono text-[10px] text-red-600">{(list.error as Error)?.message ?? "Error loading directory."}</div>;
if (!list.data || list.data.entries.length === 0) {
return <div className="p-3 font-mono text-[10px] text-stone-400">Empty directory.</div>;
}
return (
<ul data-testid="files-directory-tree" className="p-1">
{path && (
<li>
<button
type="button"
data-testid="files-up"
onClick={() => onEnterDir(parentPath(path))}
className="block w-full px-2 py-1 text-left font-mono text-[10px] text-stone-500 hover:bg-stone-100"
>
..
</button>
</li>
)}
{list.data.entries.map((entry: FileEntry) => {
const rel = path ? `${path}/${entry.name}` : entry.name;
const isFile = entry.type === "file";
const isSelected = selectedFile === rel;
return (
<li key={rel}>
<button
type="button"
data-testid={`files-entry-${rel}`}
data-type={entry.type}
onClick={() => isFile ? onSelectFile(rel) : entry.type === "dir" ? onEnterDir(rel) : undefined}
disabled={entry.type === "other"}
className={`block w-full px-2 py-1 text-left font-mono text-[10px] ${
entry.type === "other"
? "text-stone-400"
: `hover:bg-stone-100 ${isSelected ? "bg-stone-200/80 text-stone-900" : "text-stone-700"}`
}`}
>
{entry.type === "dir" ? `▸ ${entry.name}` : entry.name}
</button>
</li>
);
})}
</ul>
);
}
function FileContentPanel({
root,
path,
editMode,
onToggleEditMode,
}: {
root: string;
path: string;
editMode: boolean;
onToggleEditMode: () => void;
}) {
const read = useFilesRead(root, path);
return (
<div data-testid="files-content-panel" className="flex h-full flex-col">
<header className="flex items-center justify-between border-b border-stone-200 bg-stone-50 px-3 py-2 font-mono text-[10px]">
<div className="text-stone-700" data-testid="files-content-path">{root}/{path}</div>
<div className="flex items-center gap-3 text-stone-500">
{read.data && (
<>
<span data-testid="files-content-size">{read.data.size}b</span>
<span data-testid="files-content-mtime">{read.data.mtime}</span>
</>
)}
<button
type="button"
data-testid="files-edit-toggle"
data-active={editMode}
onClick={onToggleEditMode}
className={`border px-2 py-0.5 font-mono text-[9px] uppercase tracking-[0.10em] ${
editMode
? "border-amber-400 bg-amber-50 text-amber-900"
: "border-stone-300 text-stone-700 hover:bg-stone-100"
}`}
>
{editMode ? "editing" : "edit"}
</button>
</div>
</header>
<div className="flex-1 min-h-0 overflow-y-auto">
{read.isLoading && <div className="p-4 font-mono text-[10px] text-stone-400">Loading…</div>}
{read.isError && <div data-testid="files-read-error" className="p-4 font-mono text-[10px] text-red-600">{(read.error as Error)?.message ?? "Error loading file."}</div>}
{read.data && (
editMode
? <FileEditor root={root} path={path} read={read.data} />
: <FileViewer root={root} path={path} read={read.data} />
)}
</div>
</div>
);
}
function FileViewer({ root, path, read }: { root: string; path: string; read: FilesReadResponse }) {
const ext = pathExtension(path);
if (IMAGE_EXTENSIONS.has(ext)) {
return (
<div data-testid="files-image-view" className="p-4">
<img src={fileAssetUrl(root, path)} alt={path} className="max-w-full border border-stone-200" />
</div>
);
}
if (ext === ".md") {
return (
<div className="p-4">
<MarkdownViewer content={read.content} />
</div>
);
}
if (TEXT_LIKE_EXTENSIONS.has(ext)) {
return (
<div data-testid="files-code-view" className="p-4">
<SyntaxHighlight code={read.content} language={ext.slice(1)} />
</div>
);
}
if (DOWNLOAD_ONLY_EXTENSIONS.has(ext)) {
return (
<div data-testid="files-download-only" className="p-4 font-mono text-[10px] text-stone-700">
<a href={fileAssetUrl(root, path)} download className="text-blue-700 underline">
Download {path}
</a>
</div>
);
}
return (
<div data-testid="files-text-fallback" className="p-4">
<pre className="whitespace-pre-wrap break-words font-mono text-[10px] text-stone-800">{read.content}</pre>
</div>
);
}
function FileEditor({ root, path, read }: { root: string; path: string; read: FilesReadResponse }) {
const [draft, setDraft] = useState(read.content);
const [saveError, setSaveError] = useState<string | null>(null);
const [conflict, setConflict] = useState<{ currentMtime: string; currentContentHash: string } | null>(null);
const [savedIndicator, setSavedIndicator] = useState(false);
const write = useFilesWrite();
// When a fresh read comes in (after Refresh on conflict, or after a
// successful save), reset the draft to the new content.
useEffect(() => {
setDraft(read.content);
setConflict(null);
}, [read.contentHash, read.mtime, read.content]);
const dirty = useMemo(() => draft !== read.content, [draft, read.content]);
return (
<div data-testid="files-editor" className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b border-stone-200 bg-amber-50 px-3 py-1.5 font-mono text-[9px]">
<span className="font-bold text-amber-900" data-testid="files-editor-status">
{dirty ? "draft (unsaved)" : "no changes"}
</span>
<button
type="button"
data-testid="files-editor-save"
disabled={!dirty || write.isPending}
onClick={() => {
setSaveError(null);
setConflict(null);
setSavedIndicator(false);
write.mutate(
{
root,
path,
content: draft,
expectedMtime: read.mtime,
expectedContentHash: read.contentHash,
actor: "ui-files-edit-mode",
},
{
onSuccess: (result: FileWriteResult) => {
if ("conflict" in result) {
setConflict({ currentMtime: result.currentMtime, currentContentHash: result.currentContentHash });
} else {
setSavedIndicator(true);
setTimeout(() => setSavedIndicator(false), 2000);
}
},
onError: (err) => {
setSaveError(err instanceof Error ? err.message : String(err));
},
},
);
}}
className="border border-emerald-500 bg-emerald-50 px-2 py-0.5 uppercase tracking-[0.10em] text-emerald-900 disabled:cursor-not-allowed disabled:opacity-50"
>
save
</button>
<button
type="button"
data-testid="files-editor-cancel"
onClick={() => { setDraft(read.content); setSaveError(null); setConflict(null); }}
className="border border-stone-400 bg-white px-2 py-0.5 uppercase tracking-[0.10em] text-stone-700"
>
cancel
</button>
{savedIndicator && (
<span data-testid="files-editor-saved" className="ml-auto text-emerald-700">saved</span>
)}
</div>
{conflict && (
<div data-testid="files-editor-conflict" className="border-b border-red-200 bg-red-50 px-3 py-2 font-mono text-[10px] text-red-900">
File changed externally. Local mtime <code>{read.mtime}</code> ≠ server <code>{conflict.currentMtime}</code>. Refresh to re-read; your edits are preserved in the textarea below.
</div>
)}
{saveError && (
<div data-testid="files-editor-error" className="border-b border-red-200 bg-red-50 px-3 py-2 font-mono text-[10px] text-red-900">
Save failed: {saveError}
</div>
)}
<textarea
data-testid="files-editor-textarea"
value={draft}
onChange={(e) => setDraft(e.target.value)}
className="flex-1 min-h-0 resize-none border-0 bg-stone-50 p-3 font-mono text-[11px] leading-relaxed text-stone-900 outline-none"
spellCheck={false}
/>
</div>
);
}
function pathExtension(p: string): string {
const idx = p.lastIndexOf(".");
if (idx === -1) return "";
return p.slice(idx).toLowerCase();
}
function parentPath(p: string): string {
const idx = p.lastIndexOf("/");
return idx === -1 ? "" : p.slice(0, idx);
}
@@ -0,0 +1,369 @@
// UI Enhancement Pack v0 — lightweight markdown viewer (no deps).
//
// Per the audit: no markdown libraries are installed in the UI
// package. This component implements a small parser that handles the
// cases operators hit when reading canon docs:
// - YAML frontmatter (rendered as a metadata header above the body)
// - Headings (# / ## / ### / ####)
// - Lists (- / * / nested by 2-space indent)
// - Numbered lists (1. / 2. / ...)
// - Code blocks (``` with language tag → SyntaxHighlight component)
// - Inline code (`...`)
// - Bold (**...**) / italic (*...*) — light support
// - Links ([text](url)) and images (![alt](url))
// - Tables (| col | col |)
// - Mermaid code blocks → "[render mermaid]" placeholder per PRD
// item 2 carve-out (no mermaid lib bundled at v0 — named v0+1
// trigger applies if dogfood reports the click-to-render flow is
// friction)
//
// Image src resolution: absolute URLs and `data:` URIs pass through;
// relative paths are resolved against the optional `assetBasePath`
// prop so the daemon's /api/files/asset endpoint or the slice's
// /api/slices/<name>/proof-asset/ endpoint can serve them.
import { useMemo } from "react";
import { SyntaxHighlight } from "./SyntaxHighlight.js";
export interface MarkdownViewerProps {
content: string;
/** Used to resolve relative image src + link href. Optional. */
assetBasePath?: string;
/** Hide the YAML frontmatter metadata header (default false). */
hideFrontmatter?: boolean;
}
export function MarkdownViewer({ content, assetBasePath, hideFrontmatter = false }: MarkdownViewerProps) {
const parsed = useMemo(() => parseMarkdown(content), [content]);
return (
<article data-testid="markdown-viewer" className="prose-tactical max-w-none">
{!hideFrontmatter && parsed.frontmatter && (
<FrontmatterHeader frontmatter={parsed.frontmatter} />
)}
<div className="space-y-3">
{parsed.blocks.map((block, idx) => (
<BlockRenderer key={idx} block={block} assetBasePath={assetBasePath} />
))}
</div>
</article>
);
}
interface ParsedDocument {
frontmatter: Record<string, string> | null;
blocks: Block[];
}
type Block =
| { type: "heading"; level: 1 | 2 | 3 | 4; text: string }
| { type: "paragraph"; text: string }
| { type: "code"; language: string | null; text: string; isMermaid: boolean }
| { type: "list"; ordered: boolean; items: Array<{ depth: number; text: string }> }
| { type: "table"; headers: string[]; rows: string[][] }
| { type: "blank" };
function parseMarkdown(content: string): ParsedDocument {
const { frontmatter, body } = stripFrontmatter(content);
const lines = body.split("\n");
const blocks: Block[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i]!;
// Fenced code block.
const fence = line.match(/^```(\w*)\s*$/);
if (fence) {
const language = fence[1] || null;
const isMermaid = language?.toLowerCase() === "mermaid";
const start = i + 1;
let end = start;
while (end < lines.length && !lines[end]!.match(/^```\s*$/)) end++;
const text = lines.slice(start, end).join("\n");
blocks.push({ type: "code", language, text, isMermaid });
i = end + 1;
continue;
}
// Heading.
const heading = line.match(/^(#{1,4})\s+(.+?)\s*$/);
if (heading) {
blocks.push({ type: "heading", level: heading[1]!.length as 1 | 2 | 3 | 4, text: heading[2]! });
i++;
continue;
}
// List (bulleted or ordered).
if (line.match(/^\s*([-*]|\d+\.)\s+/)) {
const items: Array<{ depth: number; text: string }> = [];
const ordered = !!line.match(/^\s*\d+\./);
while (i < lines.length && lines[i]!.match(/^\s*([-*]|\d+\.)\s+/)) {
const m = lines[i]!.match(/^(\s*)([-*]|\d+\.)\s+(.+)$/);
if (!m) break;
const depth = Math.floor((m[1]?.length ?? 0) / 2);
items.push({ depth, text: m[3]! });
i++;
}
blocks.push({ type: "list", ordered, items });
continue;
}
// Table (header + separator + body rows).
if (line.match(/^\s*\|.*\|\s*$/) && i + 1 < lines.length && lines[i + 1]!.match(/^\s*\|[\s\-:|]+\|\s*$/)) {
const headers = parseTableRow(line);
i += 2; // skip header + separator
const rows: string[][] = [];
while (i < lines.length && lines[i]!.match(/^\s*\|.*\|\s*$/)) {
rows.push(parseTableRow(lines[i]!));
i++;
}
blocks.push({ type: "table", headers, rows });
continue;
}
// Blank line.
if (line.trim() === "") {
blocks.push({ type: "blank" });
i++;
continue;
}
// Paragraph (collect consecutive non-blank, non-special lines).
const paragraph: string[] = [line];
i++;
while (i < lines.length) {
const next = lines[i]!;
if (next.trim() === "") break;
if (next.match(/^```/) || next.match(/^#{1,4}\s/) || next.match(/^\s*([-*]|\d+\.)\s/) || next.match(/^\s*\|.*\|\s*$/)) break;
paragraph.push(next);
i++;
}
blocks.push({ type: "paragraph", text: paragraph.join(" ") });
}
return { frontmatter, blocks };
}
function stripFrontmatter(content: string): { frontmatter: Record<string, string> | null; body: string } {
if (!content.startsWith("---\n") && !content.startsWith("---\r\n")) {
return { frontmatter: null, body: content };
}
const rest = content.slice(content.indexOf("\n") + 1);
const endMatch = rest.match(/(^|\n)---(\n|$)/);
if (!endMatch || endMatch.index === undefined) return { frontmatter: null, body: content };
const fmText = rest.slice(0, endMatch.index);
const body = rest.slice(endMatch.index + endMatch[0].length);
const frontmatter: Record<string, string> = {};
for (const line of fmText.split("\n")) {
const m = line.match(/^([\w.-]+):\s*(.+?)\s*$/);
if (m) {
let v = m[2]!;
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
frontmatter[m[1]!] = v;
}
}
return { frontmatter, body };
}
function parseTableRow(line: string): string[] {
return line.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
}
function FrontmatterHeader({ frontmatter }: { frontmatter: Record<string, string> }) {
const entries = Object.entries(frontmatter);
if (entries.length === 0) return null;
return (
<section
data-testid="markdown-frontmatter"
className="mb-4 border border-stone-300 bg-stone-50 p-3"
>
<div className="mb-2 font-mono text-[8px] uppercase tracking-[0.18em] text-stone-500">
Frontmatter
</div>
<dl className="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1">
{entries.map(([k, v]) => (
<Fragment key={k}>
<dt className="font-mono text-[10px] font-bold text-stone-700">{k}</dt>
<dd className="font-mono text-[10px] text-stone-800 break-all">{v}</dd>
</Fragment>
))}
</dl>
</section>
);
}
function BlockRenderer({ block, assetBasePath }: { block: Block; assetBasePath?: string }) {
if (block.type === "blank") return null;
if (block.type === "heading") {
const sizes = { 1: "text-lg font-bold", 2: "text-base font-bold", 3: "text-sm font-bold", 4: "text-xs font-bold" } as const;
const Tag = (`h${block.level}` as "h1" | "h2" | "h3" | "h4");
return <Tag data-testid={`md-heading-${block.level}`} className={`${sizes[block.level]} mt-4 text-stone-900`}>{renderInline(block.text, assetBasePath)}</Tag>;
}
if (block.type === "paragraph") {
return <p data-testid="md-paragraph" className="text-[12px] leading-relaxed text-stone-800">{renderInline(block.text, assetBasePath)}</p>;
}
if (block.type === "code") {
if (block.isMermaid) {
return (
<div data-testid="md-mermaid-placeholder" className="border border-amber-300 bg-amber-50 p-3">
<div className="mb-2 font-mono text-[8px] uppercase tracking-[0.18em] text-amber-700">
mermaid diagram (v0+1 trigger: render-on-click flow)
</div>
<pre className="overflow-x-auto bg-stone-900 p-2 font-mono text-[10px] text-stone-100">
<code>{block.text}</code>
</pre>
<button
type="button"
data-testid="md-mermaid-render-btn"
disabled
title="Mermaid rendering not bundled at v0 (named v0+1 trigger). Inspect the source above."
className="mt-2 cursor-not-allowed border border-amber-400 bg-amber-100 px-2 py-1 font-mono text-[9px] uppercase tracking-[0.10em] text-amber-800"
>
[render mermaid] (v0+1)
</button>
</div>
);
}
return <SyntaxHighlight code={block.text} language={block.language} />;
}
if (block.type === "list") {
const ListTag = block.ordered ? "ol" : "ul";
return (
<ListTag data-testid={`md-list-${block.ordered ? "ol" : "ul"}`} className={`${block.ordered ? "list-decimal" : "list-disc"} ml-5 space-y-1 text-[12px] text-stone-800`}>
{block.items.map((item, idx) => (
<li key={idx} style={{ marginLeft: `${item.depth * 1}rem` }}>
{renderInline(item.text, assetBasePath)}
</li>
))}
</ListTag>
);
}
if (block.type === "table") {
return (
<div data-testid="md-table-wrapper" className="overflow-x-auto">
<table className="w-full border-collapse border border-stone-300 text-[10px]">
<thead className="bg-stone-100">
<tr>{block.headers.map((h, i) => <th key={i} className="border border-stone-300 px-2 py-1 text-left font-bold text-stone-900">{renderInline(h, assetBasePath)}</th>)}</tr>
</thead>
<tbody>
{block.rows.map((row, ri) => (
<tr key={ri} className={ri % 2 === 0 ? "bg-white" : "bg-stone-50"}>
{row.map((cell, ci) => <td key={ci} className="border border-stone-300 px-2 py-1 text-stone-800">{renderInline(cell, assetBasePath)}</td>)}
</tr>
))}
</tbody>
</table>
</div>
);
}
return null;
}
// Light inline parser: handles `code`, **bold**, *italic*, [text](url), ![alt](url).
function renderInline(text: string, assetBasePath?: string): React.ReactNode {
const nodes: React.ReactNode[] = [];
let i = 0;
let key = 0;
const flushPlain = (start: number, end: number) => {
if (end > start) nodes.push(text.slice(start, end));
};
let plainStart = 0;
while (i < text.length) {
const remaining = text.slice(i);
// Image: ![alt](src)
const imgMatch = remaining.match(/^!\[([^\]]*)\]\(([^)]+)\)/);
if (imgMatch) {
flushPlain(plainStart, i);
const src = resolveAssetUrl(imgMatch[2]!, assetBasePath);
nodes.push(
<img
key={key++}
data-testid="md-inline-image"
src={src}
alt={imgMatch[1] ?? ""}
loading="lazy"
className="my-2 inline-block max-w-full border border-stone-200"
/>,
);
i += imgMatch[0].length;
plainStart = i;
continue;
}
// Link: [text](href)
const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
if (linkMatch) {
flushPlain(plainStart, i);
nodes.push(
<a
key={key++}
data-testid="md-inline-link"
href={linkMatch[2]}
target="_blank"
rel="noopener noreferrer"
className="text-blue-700 underline hover:text-blue-900"
>
{linkMatch[1]}
</a>,
);
i += linkMatch[0].length;
plainStart = i;
continue;
}
// Inline code: `...`
if (remaining.startsWith("`")) {
const close = remaining.indexOf("`", 1);
if (close !== -1) {
flushPlain(plainStart, i);
nodes.push(
<code
key={key++}
data-testid="md-inline-code"
className="bg-stone-100 px-1 font-mono text-[10px] text-stone-900"
>
{remaining.slice(1, close)}
</code>,
);
i += close + 1;
plainStart = i;
continue;
}
}
// Bold: **...**
if (remaining.startsWith("**")) {
const close = remaining.indexOf("**", 2);
if (close !== -1) {
flushPlain(plainStart, i);
nodes.push(<strong key={key++} className="font-bold">{remaining.slice(2, close)}</strong>);
i += close + 2;
plainStart = i;
continue;
}
}
// Italic: *...*
if (remaining.startsWith("*") && !remaining.startsWith("**")) {
const close = remaining.indexOf("*", 1);
if (close !== -1 && close > 1) {
flushPlain(plainStart, i);
nodes.push(<em key={key++} className="italic">{remaining.slice(1, close)}</em>);
i += close + 1;
plainStart = i;
continue;
}
}
i++;
}
flushPlain(plainStart, text.length);
return nodes;
}
function resolveAssetUrl(src: string, assetBasePath?: string): string {
if (!src) return src;
if (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("data:") || src.startsWith("/")) return src;
if (!assetBasePath) return src;
// Treat assetBasePath as a URL prefix; combine carefully so we don't
// double-slash or strip the path's relative segment.
const sep = assetBasePath.endsWith("/") ? "" : "/";
return `${assetBasePath}${sep}${src}`;
}
// Minimal Fragment-equivalent without importing react/jsx-runtime
// directly — react-19 supplies Fragment via the named export.
import { Fragment } from "react";
@@ -0,0 +1,272 @@
// UI Enhancement Pack v0 — lightweight syntax highlighter (no deps).
//
// Per the audit: no markdown / highlighting libs are installed in the
// UI package, and the PRD's mermaid carve-out hints at bundle-weight
// concerns. We ship a small per-language tokenizer (~200 lines total)
// that handles the common cases the operator hits in canon docs:
// bash, ts/tsx/js/jsx, py, yaml, json, sql, md, sh, plain text.
//
// Tokenizer style: emit React spans with semantic class names
// (token-keyword, token-string, token-comment, token-number,
// token-tag) styled in globals.css. Each language has a small set of
// regex matchers applied in order; the longest match wins per
// position. Greedy matching for strings + comments handles the
// "multi-line" cases for fenced code (inputs are individual blocks,
// so newlines inside string/comment tokens are uncommon — we still
// scan past them).
//
// This is intentionally not a full highlighter. Edge cases (template
// literals with embedded expressions, regex literals, JSX braces)
// degrade gracefully to "no highlighting on that token". The visual
// goal is "this is clearly code" + "keywords/strings/comments are
// distinguishable" — not pixel-perfect editor parity.
import { useMemo } from "react";
export type HighlightLanguage =
| "bash" | "sh" | "ts" | "tsx" | "js" | "jsx"
| "py" | "python"
| "yaml" | "yml"
| "json"
| "sql"
| "md" | "markdown"
| "text" | "plain" | "";
export function SyntaxHighlight({
code,
language,
}: {
code: string;
language: string | null | undefined;
}) {
const lang = normalizeLanguage(language);
const tokens = useMemo(() => tokenize(code, lang), [code, lang]);
return (
<pre
data-testid="syntax-highlight-block"
data-language={lang}
className="overflow-x-auto bg-stone-900 p-3 font-mono text-[10px] leading-relaxed text-stone-100"
>
<code>
{tokens.map((tok, idx) => (
<span key={idx} className={tok.className} data-token-kind={tok.kind}>
{tok.text}
</span>
))}
</code>
</pre>
);
}
interface Token {
kind: "plain" | "keyword" | "string" | "comment" | "number" | "tag" | "punctuation";
text: string;
className: string;
}
const TOKEN_CLASS: Record<Token["kind"], string> = {
plain: "",
keyword: "text-violet-300",
string: "text-emerald-300",
comment: "text-stone-500 italic",
number: "text-amber-300",
tag: "text-sky-300",
punctuation: "text-stone-400",
};
function token(kind: Token["kind"], text: string): Token {
return { kind, text, className: TOKEN_CLASS[kind] };
}
function normalizeLanguage(lang: string | null | undefined): HighlightLanguage {
if (!lang) return "";
const norm = lang.toLowerCase().trim();
if (norm === "javascript") return "js";
if (norm === "typescript") return "ts";
if (norm === "shell") return "sh";
return norm as HighlightLanguage;
}
const KEYWORDS_BY_LANG: Record<string, Set<string>> = {
ts: new Set(["const", "let", "var", "function", "return", "if", "else", "for", "while", "switch", "case", "break", "continue", "default", "class", "extends", "implements", "interface", "type", "enum", "import", "export", "from", "as", "new", "this", "super", "async", "await", "throw", "try", "catch", "finally", "static", "public", "private", "protected", "readonly", "abstract", "true", "false", "null", "undefined", "void", "string", "number", "boolean", "any", "unknown", "never"]),
tsx: new Set(["const", "let", "var", "function", "return", "if", "else", "for", "while", "switch", "case", "break", "continue", "default", "class", "extends", "implements", "interface", "type", "enum", "import", "export", "from", "as", "new", "this", "super", "async", "await", "throw", "try", "catch", "finally", "static", "public", "private", "protected", "readonly", "abstract", "true", "false", "null", "undefined", "void", "string", "number", "boolean", "any", "unknown", "never"]),
js: new Set(["const", "let", "var", "function", "return", "if", "else", "for", "while", "switch", "case", "break", "continue", "default", "class", "extends", "import", "export", "from", "as", "new", "this", "super", "async", "await", "throw", "try", "catch", "finally", "true", "false", "null", "undefined"]),
jsx: new Set(["const", "let", "var", "function", "return", "if", "else", "for", "while", "switch", "case", "break", "continue", "default", "class", "extends", "import", "export", "from", "as", "new", "this", "super", "async", "await", "throw", "try", "catch", "finally", "true", "false", "null", "undefined"]),
py: new Set(["def", "class", "if", "elif", "else", "for", "while", "return", "import", "from", "as", "with", "try", "except", "finally", "raise", "lambda", "yield", "pass", "break", "continue", "global", "nonlocal", "True", "False", "None", "and", "or", "not", "in", "is", "async", "await"]),
python: new Set(["def", "class", "if", "elif", "else", "for", "while", "return", "import", "from", "as", "with", "try", "except", "finally", "raise", "lambda", "yield", "pass", "break", "continue", "global", "nonlocal", "True", "False", "None", "and", "or", "not", "in", "is", "async", "await"]),
bash: new Set(["if", "then", "else", "elif", "fi", "for", "while", "do", "done", "case", "esac", "function", "return", "export", "local", "readonly", "echo", "printf", "exit", "set", "unset", "shift", "true", "false"]),
sh: new Set(["if", "then", "else", "elif", "fi", "for", "while", "do", "done", "case", "esac", "function", "return", "export", "local", "readonly", "echo", "printf", "exit", "set", "unset", "shift", "true", "false"]),
sql: new Set(["select", "from", "where", "and", "or", "not", "null", "is", "in", "like", "between", "join", "left", "right", "inner", "outer", "on", "group", "by", "order", "having", "limit", "offset", "insert", "into", "values", "update", "set", "delete", "create", "table", "drop", "alter", "index", "primary", "key", "foreign", "references", "constraint", "unique", "default", "as", "case", "when", "then", "else", "end"]),
};
function tokenize(code: string, lang: HighlightLanguage): Token[] {
if (lang === "" || lang === "text" || lang === "plain") return [token("plain", code)];
if (lang === "json") return tokenizeJson(code);
if (lang === "yaml" || lang === "yml") return tokenizeYaml(code);
if (lang === "md" || lang === "markdown") return tokenizeMarkdown(code);
if (lang === "py" || lang === "python") return tokenizeWithKeywordsAndComments(code, lang, "#");
if (lang === "sh" || lang === "bash") return tokenizeWithKeywordsAndComments(code, lang, "#");
if (lang === "ts" || lang === "tsx" || lang === "js" || lang === "jsx") return tokenizeWithKeywordsAndComments(code, lang, "//", "/*", "*/");
if (lang === "sql") return tokenizeWithKeywordsAndComments(code, lang, "--");
return [token("plain", code)];
}
function tokenizeWithKeywordsAndComments(
code: string,
lang: HighlightLanguage,
lineCommentPrefix: string,
blockCommentOpen?: string,
blockCommentClose?: string,
): Token[] {
const keywords = KEYWORDS_BY_LANG[lang] ?? new Set<string>();
const out: Token[] = [];
let i = 0;
while (i < code.length) {
const remaining = code.slice(i);
// Block comment.
if (blockCommentOpen && remaining.startsWith(blockCommentOpen)) {
const closeIdx = blockCommentClose ? remaining.indexOf(blockCommentClose, blockCommentOpen.length) : -1;
const end = closeIdx === -1 ? remaining.length : closeIdx + (blockCommentClose?.length ?? 0);
out.push(token("comment", remaining.slice(0, end)));
i += end;
continue;
}
// Line comment.
if (remaining.startsWith(lineCommentPrefix)) {
const newline = remaining.indexOf("\n");
const end = newline === -1 ? remaining.length : newline;
out.push(token("comment", remaining.slice(0, end)));
i += end;
continue;
}
// String literals (single, double, backtick).
const quote = remaining[0];
if (quote === '"' || quote === "'" || quote === "`") {
let j = 1;
while (j < remaining.length && remaining[j] !== quote) {
if (remaining[j] === "\\" && j + 1 < remaining.length) j += 2;
else j++;
}
const end = j < remaining.length ? j + 1 : j;
out.push(token("string", remaining.slice(0, end)));
i += end;
continue;
}
// Number.
const numMatch = remaining.match(/^-?\d+(\.\d+)?/);
if (numMatch && (i === 0 || !/[a-zA-Z_]/.test(code[i - 1] ?? ""))) {
out.push(token("number", numMatch[0]));
i += numMatch[0].length;
continue;
}
// Identifier — check keyword set.
const idMatch = remaining.match(/^[a-zA-Z_][a-zA-Z0-9_]*/);
if (idMatch) {
if (keywords.has(idMatch[0])) out.push(token("keyword", idMatch[0]));
else out.push(token("plain", idMatch[0]));
i += idMatch[0].length;
continue;
}
// Plain character.
out.push(token("plain", code[i] ?? ""));
i++;
}
return mergeAdjacentPlain(out);
}
function tokenizeYaml(code: string): Token[] {
const out: Token[] = [];
for (const line of code.split(/(\n)/)) {
if (line === "\n") { out.push(token("plain", "\n")); continue; }
// Comment to EOL
const commentIdx = line.indexOf("#");
if (commentIdx !== -1) {
const before = line.slice(0, commentIdx);
out.push(...tokenizeYamlLineBody(before));
out.push(token("comment", line.slice(commentIdx)));
} else {
out.push(...tokenizeYamlLineBody(line));
}
}
return mergeAdjacentPlain(out);
}
function tokenizeYamlLineBody(line: string): Token[] {
// Match leading indent + key + ": " + value.
const m = line.match(/^(\s*)([\w.-]+)(:)(\s*)(.*)$/);
if (m) {
const out: Token[] = [];
out.push(token("plain", m[1]!));
out.push(token("tag", m[2]!));
out.push(token("punctuation", m[3]!));
out.push(token("plain", m[4]!));
const rest = m[5]!;
if (rest.startsWith('"') || rest.startsWith("'")) {
out.push(token("string", rest));
} else if (/^\d+(\.\d+)?$/.test(rest)) {
out.push(token("number", rest));
} else {
out.push(token("plain", rest));
}
return out;
}
return [token("plain", line)];
}
function tokenizeJson(code: string): Token[] {
const out: Token[] = [];
let i = 0;
while (i < code.length) {
const ch = code[i];
if (ch === '"') {
let j = 1;
while (j < code.length - i && code[i + j] !== '"') {
if (code[i + j] === "\\") j += 2; else j++;
}
out.push(token("string", code.slice(i, i + j + 1)));
i += j + 1;
continue;
}
if (ch === "{" || ch === "}" || ch === "[" || ch === "]" || ch === "," || ch === ":") {
out.push(token("punctuation", ch));
i++;
continue;
}
const numMatch = code.slice(i).match(/^-?\d+(\.\d+)?/);
if (numMatch) {
out.push(token("number", numMatch[0]));
i += numMatch[0].length;
continue;
}
const wordMatch = code.slice(i).match(/^(true|false|null)\b/);
if (wordMatch) {
out.push(token("keyword", wordMatch[0]));
i += wordMatch[0].length;
continue;
}
out.push(token("plain", ch ?? ""));
i++;
}
return mergeAdjacentPlain(out);
}
function tokenizeMarkdown(code: string): Token[] {
// Light markdown highlighting inside fenced code blocks: heading
// markers + list bullets + emphasis tokens. Mostly plain — operators
// typically prose-read inline `code` segments, not full markdown
// documents inside fenced code.
return [token("plain", code)];
}
function mergeAdjacentPlain(tokens: Token[]): Token[] {
const out: Token[] = [];
for (const t of tokens) {
const last = out[out.length - 1];
if (last && last.kind === "plain" && t.kind === "plain") {
out[out.length - 1] = token("plain", last.text + t.text);
} else {
out.push(t);
}
}
return out;
}
@@ -0,0 +1,242 @@
// UI Enhancement Pack v0 — Progress browse workspace.
//
// Top-level center-workspace surface for /progress route. Renders the
// workspace's PROGRESS.md hierarchy across operator-allowlisted scan
// roots in one tree.
//
// Two-pane shape:
// - Left: filter row (status: All/Active/Done/Blocked) + free-text
// search + per-file collapsible sections.
// - Right: when a row is clicked, an inline detail panel shows
// the source file:line citation + parent context.
//
// Per item 1B: read-only at v0; edit mode is item 4 (Files workspace).
import { useMemo, useState } from "react";
import { useProgressTree, type ProgressFileNode, type ProgressRow, type CheckboxStatus } from "../../hooks/useProgressTree.js";
type StatusFilter = "all" | "active" | "done" | "blocked";
const FILTERS: StatusFilter[] = ["all", "active", "done", "blocked"];
function isUnavailable(data: unknown): data is { unavailable: true; error: string; hint?: string } {
return Boolean(data && typeof data === "object" && "unavailable" in (data as Record<string, unknown>));
}
export function ProgressWorkspace() {
const tree = useProgressTree();
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [search, setSearch] = useState("");
const [selectedRow, setSelectedRow] = useState<{ file: ProgressFileNode; row: ProgressRow } | null>(null);
return (
<div data-testid="progress-workspace" className="flex h-full flex-col lg:pl-[var(--workspace-left-offset,0px)] lg:pr-[var(--workspace-right-offset,0px)]">
<header className="border-b border-stone-200 bg-stone-50 px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-500">Workspace</div>
<h1 className="font-headline text-xl font-bold tracking-tight text-stone-900">Progress</h1>
{tree.data && !isUnavailable(tree.data) && (
<div className="mt-1 font-mono text-[10px] text-stone-500" data-testid="progress-aggregate">
{tree.data.aggregate.totalFiles} file{tree.data.aggregate.totalFiles === 1 ? "" : "s"}
{" · "}{tree.data.aggregate.totalDone} done
{" · "}{tree.data.aggregate.totalActive} active
{" · "}{tree.data.aggregate.totalBlocked} blocked
{" / "}{tree.data.aggregate.totalRows} total
</div>
)}
<div className="mt-3 flex flex-wrap items-center gap-2" data-testid="progress-filter-row">
{FILTERS.map((f) => (
<button
key={f}
type="button"
data-testid={`progress-filter-${f}`}
data-active={statusFilter === f}
onClick={() => setStatusFilter(f)}
className={`border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.10em] ${
statusFilter === f
? "border-stone-700 bg-stone-700 text-white"
: "border-stone-300 text-stone-700 hover:bg-stone-100"
}`}
>
{f}
</button>
))}
<input
data-testid="progress-search"
type="text"
placeholder="search rows…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="ml-auto min-w-32 flex-1 border border-stone-300 bg-white px-2 py-1 font-mono text-[10px]"
/>
</div>
</header>
<div className="flex flex-1 min-h-0">
<main data-testid="progress-tree-pane" className="flex-1 min-w-0 overflow-y-auto bg-white">
{tree.isLoading && <div className="p-4 font-mono text-[10px] text-stone-400">Loading…</div>}
{tree.isError && <div className="p-4 font-mono text-[10px] text-red-600">Error loading progress tree.</div>}
{isUnavailable(tree.data) && (
<div data-testid="progress-unavailable" className="p-4 font-mono text-[10px] text-stone-500">
<div>Progress indexer unavailable.</div>
{tree.data.hint && <div className="mt-1 text-stone-400">{tree.data.hint}</div>}
</div>
)}
{tree.data && !isUnavailable(tree.data) && tree.data.files.length === 0 && (
<div className="p-4 font-mono text-[10px] text-stone-400">
No PROGRESS.md files found in scan roots: {tree.data.scannedRoots.map((r) => r.name).join(", ") || "(none)"}.
</div>
)}
{tree.data && !isUnavailable(tree.data) && tree.data.files.map((file) => (
<ProgressFileSection
key={`${file.rootName}/${file.relPath}`}
file={file}
statusFilter={statusFilter}
search={search}
onRowClick={(row) => setSelectedRow({ file, row })}
selectedRow={selectedRow}
/>
))}
</main>
{selectedRow && (
<aside data-testid="progress-detail-pane" className="w-72 shrink-0 overflow-y-auto border-l border-stone-200 bg-stone-50 p-3">
<button
type="button"
data-testid="progress-detail-close"
onClick={() => setSelectedRow(null)}
className="mb-2 font-mono text-[9px] uppercase tracking-[0.12em] text-stone-500 hover:text-stone-900"
>
✕ close
</button>
<div className="font-mono text-[10px] text-stone-500">
<div className="font-bold text-stone-700">{selectedRow.file.title ?? selectedRow.file.relPath}</div>
<div className="mt-1 text-[8px] uppercase tracking-[0.12em]">{selectedRow.file.rootName}</div>
<div className="mt-1 text-[9px] text-stone-400 break-all">{selectedRow.file.relPath}:{selectedRow.row.line}</div>
</div>
<div className="mt-3">
<div className="font-mono text-[8px] uppercase tracking-[0.12em] text-stone-500">Row</div>
<div data-testid="progress-detail-text" className="mt-1 whitespace-pre-line font-mono text-[10px] text-stone-800">
{selectedRow.row.text}
</div>
</div>
<div className="mt-3">
<div className="font-mono text-[8px] uppercase tracking-[0.12em] text-stone-500">Status</div>
<div data-testid="progress-detail-status" className="mt-1 font-mono text-[10px] text-stone-700">
{selectedRow.row.status}
</div>
</div>
</aside>
)}
</div>
</div>
);
}
function ProgressFileSection({
file,
statusFilter,
search,
onRowClick,
selectedRow,
}: {
file: ProgressFileNode;
statusFilter: StatusFilter;
search: string;
onRowClick: (row: ProgressRow) => void;
selectedRow: { file: ProgressFileNode; row: ProgressRow } | null;
}) {
const [collapsed, setCollapsed] = useState(false);
const filteredRows = useMemo(() => filterRows(file.rows, statusFilter, search), [file.rows, statusFilter, search]);
if (filteredRows.length === 0 && (statusFilter !== "all" || search)) return null;
return (
<section
data-testid={`progress-file-${file.rootName}-${file.relPath}`}
className="border-b border-stone-100"
>
<button
type="button"
data-testid={`progress-file-${file.rootName}-${file.relPath}-toggle`}
onClick={() => setCollapsed((v) => !v)}
className="flex w-full items-baseline gap-2 bg-stone-50 px-3 py-2 text-left hover:bg-stone-100"
>
<span className="font-mono text-[10px] text-stone-500">{collapsed ? "▸" : "▾"}</span>
<span className="font-mono text-[11px] font-bold text-stone-900">{file.title ?? file.relPath}</span>
<span className="font-mono text-[8px] uppercase tracking-[0.10em] text-stone-500">{file.rootName}</span>
<span className="ml-auto font-mono text-[9px] text-stone-500">
{file.counts.done}/{file.counts.total}
</span>
</button>
{!collapsed && (
<ul className="px-3 py-2">
{filteredRows.map((row) => (
<li
key={`${file.relPath}:${row.line}`}
data-testid={`progress-row-${file.rootName}-${row.line}`}
data-status={row.status}
data-kind={row.kind}
>
<button
type="button"
onClick={() => onRowClick(row)}
style={{ paddingLeft: `${row.depth * 1.2}rem` }}
className={`flex w-full items-baseline gap-2 py-0.5 text-left hover:bg-stone-100 ${
selectedRow?.row === row ? "bg-stone-200/80" : ""
}`}
>
{row.kind === "heading" ? (
<span className="font-mono text-[10px] font-semibold uppercase tracking-[0.10em] text-stone-700">
{row.text}
</span>
) : (
<>
<StatusPill status={row.status} />
<span className="font-mono text-[10px] text-stone-800">{row.text}</span>
<span className="ml-auto font-mono text-[8px] text-stone-400">L{row.line}</span>
</>
)}
</button>
</li>
))}
</ul>
)}
</section>
);
}
function filterRows(rows: ProgressRow[], filter: StatusFilter, search: string): ProgressRow[] {
const lowerSearch = search.trim().toLowerCase();
return rows.filter((r) => {
// Always include headings (they provide context for filtered children).
// When a search is active, also gate headings on the search term.
if (r.kind === "heading") {
if (lowerSearch && !r.text.toLowerCase().includes(lowerSearch)) return false;
return true;
}
if (filter === "active" && r.status !== "active") return false;
if (filter === "done" && r.status !== "done") return false;
if (filter === "blocked" && r.status !== "blocked") return false;
if (lowerSearch && !r.text.toLowerCase().includes(lowerSearch)) return false;
return true;
});
}
function StatusPill({ status }: { status: CheckboxStatus }) {
const { className, icon, label } = pillStyle(status);
return (
<span
data-testid={`progress-row-pill-${status}`}
className={`inline-flex shrink-0 items-center gap-1 rounded-full border px-1.5 py-0 font-mono text-[8px] uppercase tracking-[0.10em] ${className}`}
aria-label={label}
>
<span aria-hidden="true">{icon}</span>
<span>{label}</span>
</span>
);
}
function pillStyle(status: CheckboxStatus): { className: string; icon: string; label: string } {
switch (status) {
case "done": return { className: "border-emerald-400 bg-emerald-50 text-emerald-900", icon: "✓", label: "done" };
case "blocked": return { className: "border-amber-400 bg-amber-50 text-amber-900", icon: "⚠", label: "blocked" };
case "active": return { className: "border-stone-400 bg-stone-50 text-stone-700", icon: "◯", label: "active" };
default: return { className: "border-stone-300 bg-stone-50 text-stone-500", icon: "·", label: "unknown" };
}
}
@@ -1,14 +1,34 @@
// Slice Story View v0 — Acceptance tab.
// Slice Story View v0 + UI Enhancement Pack v0 — Acceptance tab.
//
// Header progress bar + checkbox list pulled from the slice's README /
// IMPLEMENTATION-PRD / PROGRESS.md `[ ]` / `[x]` items. Source citation
// (file + 1-based line) lets the operator jump to the canonical
// statement (Docs tab is the natural follow-on).
// v0 (Slice Story View): header progress bar + checkbox list pulled
// from the slice's README / IMPLEMENTATION-PRD / PROGRESS.md.
//
// UI Enhancement Pack v0 (item 1A) extends the checkbox list with:
// - Checkbox pills (rounded with status icon: ◯ active / ✓ done /
// ⚠ blocked) instead of raw `[ ]` / `[x]` syntax.
// - Status filter chips (All / Active / Done / Blocked; default All).
// - Click-to-expand row detail panel showing source file:line
// citation prominently.
//
// (Hierarchy from `## Heading` sections + ` -` indent is a
// straightforward extension once the daemon-side acceptance parser
// returns parent_section_heading per row; v0's existing parser only
// surfaces the `[ ]`/`[x]` items as a flat list with file+line. The
// pills + filters + expand-detail land at v0; nested-tree treatment
// follows when the parser exposes parent context.)
import type { SliceDetail } from "../../../hooks/useSlices.js";
import { useMemo, useState } from "react";
import type { AcceptanceItem, SliceDetail } from "../../../hooks/useSlices.js";
type StatusFilter = "all" | "active" | "done" | "blocked";
const FILTERS: StatusFilter[] = ["all", "active", "done", "blocked"];
export function AcceptanceTab({ acceptance }: { acceptance: SliceDetail["acceptance"] }) {
const { totalItems, doneItems, percentage, items, closureCallout } = acceptance;
const [filter, setFilter] = useState<StatusFilter>("all");
const filtered = useMemo(() => filterItems(items, filter), [items, filter]);
return (
<div data-testid="acceptance-tab" className="p-4">
<header className="mb-4">
@@ -36,31 +56,119 @@ export function AcceptanceTab({ acceptance }: { acceptance: SliceDetail["accepta
{closureCallout}
</div>
)}
{items.length > 0 && (
<div className="mt-3 flex gap-1" data-testid="acceptance-filter-row">
{FILTERS.map((f) => (
<button
key={f}
type="button"
data-testid={`acceptance-filter-${f}`}
data-active={filter === f}
onClick={() => setFilter(f)}
className={`border px-2 py-1 font-mono text-[9px] uppercase tracking-[0.10em] ${
filter === f
? "border-stone-700 bg-stone-700 text-white"
: "border-stone-300 text-stone-700 hover:bg-stone-100"
}`}
>
{f}
</button>
))}
</div>
)}
</header>
{items.length === 0 ? (
<div className="font-mono text-[10px] text-stone-400" data-testid="acceptance-empty">
No acceptance items found in slice docs (looks for `[ ]` / `[x]` checkbox lines in README / IMPLEMENTATION-PRD / PROGRESS / IMPLEMENTATION).
</div>
) : filtered.length === 0 ? (
<div className="font-mono text-[10px] text-stone-400" data-testid="acceptance-filter-empty">
No items match filter '{filter}'.
</div>
) : (
<ul className="space-y-1" data-testid="acceptance-list">
{items.map((item, idx) => (
<li
key={`${item.source.file}:${item.source.line}`}
data-testid={`acceptance-item-${idx}`}
data-done={item.done}
className="flex items-start gap-2 border-b border-stone-100 py-1"
>
<span className="font-mono text-[10px] text-stone-500" aria-label={item.done ? "done" : "pending"}>
{item.done ? "[x]" : "[ ]"}
</span>
<span className="flex-1 font-mono text-[10px] text-stone-800">{item.text}</span>
<span className="font-mono text-[8px] text-stone-400" title={`${item.source.file}:${item.source.line}`}>
{item.source.file}:{item.source.line}
</span>
</li>
{filtered.map((item, idx) => (
<AcceptanceRow key={`${item.source.file}:${item.source.line}`} item={item} idx={idx} />
))}
</ul>
)}
</div>
);
}
function AcceptanceRow({ item, idx }: { item: AcceptanceItem; idx: number }) {
const [expanded, setExpanded] = useState(false);
const { pillClass, pillIcon, pillLabel } = pillStyle(item.done);
return (
<li
data-testid={`acceptance-item-${idx}`}
data-done={item.done}
className="border-b border-stone-100"
>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
data-testid={`acceptance-item-${idx}-toggle`}
className="flex w-full items-start gap-2 py-1.5 text-left hover:bg-stone-50"
>
<span
data-testid={`acceptance-pill-${idx}`}
className={`inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 font-mono text-[9px] uppercase tracking-[0.10em] ${pillClass}`}
aria-label={pillLabel}
>
<span aria-hidden="true">{pillIcon}</span>
<span>{pillLabel}</span>
</span>
<span className="flex-1 font-mono text-[10px] text-stone-800">{item.text}</span>
<span className="font-mono text-[8px] text-stone-400" title={`${item.source.file}:${item.source.line}`}>
{item.source.file}:{item.source.line}
</span>
</button>
{expanded && (
<div
data-testid={`acceptance-item-${idx}-detail`}
className="ml-2 mt-1 border-l-2 border-stone-300 bg-stone-50 px-3 py-2 font-mono text-[9px] text-stone-700"
>
<div>
<span className="font-bold">Source:</span>{" "}
<span data-testid={`acceptance-item-${idx}-citation`}>{item.source.file}:{item.source.line}</span>
</div>
<div className="mt-1">
<span className="font-bold">Status:</span> {pillLabel}
</div>
<div className="mt-2 whitespace-pre-line text-stone-800">{item.text}</div>
</div>
)}
</li>
);
}
function filterItems(items: AcceptanceItem[], filter: StatusFilter): AcceptanceItem[] {
if (filter === "all") return items;
if (filter === "done") return items.filter((i) => i.done);
if (filter === "active") return items.filter((i) => !i.done);
if (filter === "blocked") {
// v0's parser doesn't expose a "blocked" state separately; the
// blocked filter shows items whose text contains a "blocked" /
// "park" hint as a heuristic. When the parser graduates to
// `[~]` recognition the heuristic will degrade gracefully (the
// filter still works on text patterns).
return items.filter((i) => /\b(blocked|blocker|parked|park)\b/i.test(i.text));
}
return items;
}
function pillStyle(done: boolean): { pillClass: string; pillIcon: string; pillLabel: string } {
if (done) {
return {
pillClass: "border-emerald-400 bg-emerald-50 text-emerald-900",
pillIcon: "✓",
pillLabel: "done",
};
}
return {
pillClass: "border-stone-400 bg-stone-50 text-stone-700",
pillIcon: "◯",
pillLabel: "active",
};
}
@@ -1,14 +1,23 @@
// Slice Story View v0 — Docs tab.
// Slice Story View v0 + UI Enhancement Pack v0 — Docs tab.
//
// Two-column layout: left = file tree (slice folder + descendants),
// right = lazy markdown viewer for the selected file. Composable with
// PL-009 Markdown Viewer when that ships; until then v0 renders the
// markdown content as plain `<pre>` so the operator at least sees the
// canonical text + can copy citations into queue messages.
// right = lazy markdown viewer for the selected file.
//
// UI Enhancement Pack v0 item 2: the right pane now renders Markdown
// via the new MarkdownViewer (YAML frontmatter as metadata header,
// code blocks with syntax highlighting, lists, tables, links,
// images, mermaid placeholder per carve-out). v0's plain-`<pre>`
// fallback is preserved for non-`.md` files.
//
// Image src resolution: relative paths in `.md` content are resolved
// against the slice's proof-asset endpoint when applicable. v1 of
// UI Enhancement Pack can extend this to the slice's docs endpoint
// once the daemon adds an analogous static-asset path for slice docs.
import { useState } from "react";
import type { DocsTreeEntry } from "../../../hooks/useSlices.js";
import { useSliceDoc } from "../../../hooks/useSlices.js";
import { MarkdownViewer } from "../../markdown/MarkdownViewer.js";
export function DocsTab({ sliceName, tree }: { sliceName: string; tree: DocsTreeEntry[] }) {
const initial = tree.find((e) => e.type === "file" && e.name === "README.md")?.relPath
@@ -54,9 +63,15 @@ export function DocsTab({ sliceName, tree }: { sliceName: string; tree: DocsTree
<div className="p-4 font-mono text-[10px] text-red-600">Error loading doc.</div>
)}
{selected && doc.data && (
<pre className="whitespace-pre-wrap break-words p-4 font-mono text-[11px] text-stone-800" data-testid="docs-viewer-content">
{doc.data.content}
</pre>
<div data-testid="docs-viewer-content" className="p-4">
{selected.toLowerCase().endsWith(".md") ? (
<MarkdownViewer content={doc.data.content} />
) : (
<pre className="whitespace-pre-wrap break-words font-mono text-[11px] text-stone-800">
{doc.data.content}
</pre>
)}
</div>
)}
</main>
</div>
+175
View File
@@ -0,0 +1,175 @@
// UI Enhancement Pack v0 — files browser + write hooks.
//
// Wraps:
// - GET /api/files/roots → useFilesRoots
// - GET /api/files/list?root=&path= → useFilesList
// - GET /api/files/read?root=&path= → useFilesRead
// - POST /api/files/write → useFilesWrite (mutation)
//
// All read hooks surface daemon 503 / 4xx as structured errors via
// the `unavailable` shape so the UI renders a setup hint when no
// allowlist is configured.
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
export interface FilesUnavailable {
unavailable: true;
error: string;
hint?: string;
}
export interface AllowlistRoot {
name: string;
path: string;
}
export interface FilesRootsResponse {
roots: AllowlistRoot[];
hint?: string;
}
async function fetchRoots(): Promise<FilesRootsResponse | FilesUnavailable> {
const res = await fetch("/api/files/roots");
if (res.status === 503) {
const body = (await res.json().catch(() => ({}))) as Partial<FilesUnavailable> & { error?: string; hint?: string };
return { unavailable: true, error: body.error ?? "files_routes_unavailable", hint: body.hint };
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as FilesRootsResponse;
}
export function useFilesRoots() {
return useQuery({
queryKey: ["files", "roots"],
queryFn: fetchRoots,
staleTime: 60_000,
});
}
// --- list ---
export interface FileEntry {
name: string;
type: "dir" | "file" | "other";
size: number | null;
mtime: string | null;
}
export interface FilesListResponse {
root: string;
path: string;
entries: FileEntry[];
}
async function fetchList(root: string, path: string): Promise<FilesListResponse> {
const res = await fetch(`/api/files/list?root=${encodeURIComponent(root)}&path=${encodeURIComponent(path)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as FilesListResponse;
}
export function useFilesList(root: string | null, path: string | null) {
return useQuery({
queryKey: ["files", "list", root, path],
queryFn: () => fetchList(root!, path ?? ""),
enabled: !!root,
staleTime: 15_000,
});
}
// --- read ---
export interface FilesReadResponse {
root: string;
path: string;
absolutePath: string;
content: string;
mtime: string;
contentHash: string;
size: number;
}
async function fetchRead(root: string, path: string): Promise<FilesReadResponse> {
const res = await fetch(`/api/files/read?root=${encodeURIComponent(root)}&path=${encodeURIComponent(path)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as FilesReadResponse;
}
export function useFilesRead(root: string | null, path: string | null) {
return useQuery({
queryKey: ["files", "read", root, path],
queryFn: () => fetchRead(root!, path!),
enabled: !!root && !!path,
staleTime: 0, // always re-read for edit-mode mtime/contentHash freshness
});
}
// --- write (item 4) ---
export interface FileWriteRequest {
root: string;
path: string;
content: string;
expectedMtime: string;
expectedContentHash: string;
actor: string;
}
export interface FileWriteSuccess {
root: string;
path: string;
absolutePath: string;
newMtime: string;
newContentHash: string;
byteCountDelta: number;
}
export interface FileWriteConflict {
conflict: true;
currentMtime: string;
currentContentHash: string;
message: string;
}
export type FileWriteResult = FileWriteSuccess | FileWriteConflict;
async function postWrite(req: FileWriteRequest): Promise<FileWriteResult> {
const res = await fetch("/api/files/write", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
});
if (res.status === 409) {
const body = (await res.json()) as { currentMtime: string; currentContentHash: string; message?: string };
return {
conflict: true,
currentMtime: body.currentMtime,
currentContentHash: body.currentContentHash,
message: body.message ?? "file changed externally",
};
}
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
throw new Error(body.message ?? body.error ?? `HTTP ${res.status}`);
}
return (await res.json()) as FileWriteSuccess;
}
export function useFilesWrite() {
const qc = useQueryClient();
return useMutation({
mutationFn: postWrite,
onSuccess: (result, vars) => {
// Invalidate the read query so the next render gets the new
// mtime + contentHash + content.
qc.invalidateQueries({ queryKey: ["files", "read", vars.root, vars.path] });
qc.invalidateQueries({ queryKey: ["files", "list", vars.root] });
// Result-shape is either success or conflict; conflict isn't
// an error per react-query semantics here — caller inspects.
void result;
},
});
}
export function fileAssetUrl(root: string, path: string): string {
return `/api/files/asset?root=${encodeURIComponent(root)}&path=${encodeURIComponent(path)}`;
}
+57
View File
@@ -0,0 +1,57 @@
// UI Enhancement Pack v0 — progress browse hook.
//
// Wraps GET /api/progress/tree. Surfaces the daemon's
// "progress_scan_roots_not_configured" 503 path as a structured
// `unavailable` sentinel so the UI can render a setup hint.
import { useQuery } from "@tanstack/react-query";
export type CheckboxStatus = "active" | "done" | "blocked" | "unknown";
export interface ProgressRow {
line: number;
depth: number;
status: CheckboxStatus;
text: string;
kind: "checkbox" | "heading";
}
export interface ProgressFileNode {
rootName: string;
relPath: string;
absolutePath: string;
mtime: string;
rows: ProgressRow[];
title: string | null;
counts: { total: number; done: number; blocked: number; active: number };
}
export interface ProgressTreeResult {
files: ProgressFileNode[];
aggregate: { totalFiles: number; totalRows: number; totalDone: number; totalBlocked: number; totalActive: number };
scannedRoots: Array<{ name: string; canonicalPath: string }>;
}
export interface ProgressUnavailable {
unavailable: true;
error: string;
hint?: string;
}
async function fetchProgressTree(): Promise<ProgressTreeResult | ProgressUnavailable> {
const res = await fetch("/api/progress/tree");
if (res.status === 503) {
const body = (await res.json().catch(() => ({}))) as Partial<ProgressUnavailable> & { error?: string; hint?: string };
return { unavailable: true, error: body.error ?? "progress_indexer_unavailable", hint: body.hint };
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as ProgressTreeResult;
}
export function useProgressTree() {
return useQuery({
queryKey: ["progress", "tree"],
queryFn: fetchProgressTree,
staleTime: 30_000,
});
}
+18
View File
@@ -25,6 +25,8 @@ import { LiveNodeDetails } from "./components/LiveNodeDetails.js";
import { DiscoveryOverlay } from "./components/DiscoveryOverlay.js";
import { MissionControlSurface } from "./components/mission-control/MissionControlSurface.js";
import { SliceStoryView } from "./components/slices/SliceStoryView.js";
import { ProgressWorkspace } from "./components/progress/ProgressWorkspace.js";
import { FilesWorkspace } from "./components/files/FilesWorkspace.js";
import { useRigSummary } from "./hooks/useRigSummary.js";
import { useDrawerSelection } from "./components/AppShell.js";
@@ -241,6 +243,20 @@ const sliceDetailRoute = createRoute({
component: SliceStoryView,
});
// UI Enhancement Pack v0: top-level Progress (item 1B) + Files
// (item 3 + item 4) workspaces. Both are read-only pages with their
// own internal layout — no left-sidebar nav coupling.
const progressRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/progress",
component: ProgressWorkspace,
});
const filesRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/files",
component: FilesWorkspace,
});
const routeTree = rootRoute.addChildren([
indexRoute,
rigDetailRoute,
@@ -262,6 +278,8 @@ const routeTree = rootRoute.addChildren([
missionControlRoute,
slicesIndexRoute,
sliceDetailRoute,
progressRoute,
filesRoute,
]);
// Router
@@ -0,0 +1,100 @@
// UI Enhancement Pack v0 — AcceptanceTab item-1A extension tests.
//
// Pins the v0 → v0-enhanced behavior:
// - checkbox pills with status icon + label (not raw [ ]/[x])
// - 4-filter row (All / Active / Done / Blocked) with default All
// - clicking a row expands an inline detail panel
// - filter narrows the visible rows; empty-after-filter has its own
// message; filter unchanged when items array is empty
//
// Slice Story View v0 baseline behavior preserved (progress bar +
// closure callout + source citation + per-row data-done attribute).
import { describe, it, expect, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { AcceptanceTab } from "../src/components/slices/tabs/AcceptanceTab.js";
import type { SliceDetail } from "../src/hooks/useSlices.js";
afterEach(() => cleanup());
function makeAcceptance(items: SliceDetail["acceptance"]["items"]): SliceDetail["acceptance"] {
const done = items.filter((i) => i.done).length;
return {
totalItems: items.length,
doneItems: done,
percentage: items.length === 0 ? 0 : Math.round((done / items.length) * 100),
items,
closureCallout: null,
};
}
describe("UI Enhancement Pack v0 — AcceptanceTab", () => {
it("renders checkbox pills with status icons (not raw [ ]/[x])", () => {
render(<AcceptanceTab acceptance={makeAcceptance([
{ text: "Item 1", done: true, source: { file: "README.md", line: 10 } },
{ text: "Item 2", done: false, source: { file: "README.md", line: 11 } },
])} />);
expect(screen.getByTestId("acceptance-pill-0").textContent).toContain("done");
expect(screen.getByTestId("acceptance-pill-0").textContent).toContain("✓");
expect(screen.getByTestId("acceptance-pill-1").textContent).toContain("active");
expect(screen.getByTestId("acceptance-pill-1").textContent).toContain("◯");
});
it("renders the four-filter row with All as default-active", () => {
render(<AcceptanceTab acceptance={makeAcceptance([
{ text: "x", done: true, source: { file: "x.md", line: 1 } },
])} />);
expect(screen.getByTestId("acceptance-filter-row")).toBeDefined();
for (const f of ["all", "active", "done", "blocked"]) {
expect(screen.getByTestId(`acceptance-filter-${f}`)).toBeDefined();
}
expect(screen.getByTestId("acceptance-filter-all").getAttribute("data-active")).toBe("true");
});
it("filtering to Active narrows the visible rows", () => {
render(<AcceptanceTab acceptance={makeAcceptance([
{ text: "done item", done: true, source: { file: "x.md", line: 1 } },
{ text: "active item", done: false, source: { file: "x.md", line: 2 } },
])} />);
fireEvent.click(screen.getByTestId("acceptance-filter-active"));
expect(screen.queryByText("done item")).toBeNull();
expect(screen.getByText("active item")).toBeDefined();
});
it("filtering to a state with no matching items shows the empty-filter message", () => {
render(<AcceptanceTab acceptance={makeAcceptance([
{ text: "done item", done: true, source: { file: "x.md", line: 1 } },
])} />);
fireEvent.click(screen.getByTestId("acceptance-filter-active"));
expect(screen.getByTestId("acceptance-filter-empty").textContent).toContain("active");
});
it("clicking a row expands the detail panel + clicking again collapses it", () => {
render(<AcceptanceTab acceptance={makeAcceptance([
{ text: "expandable item", done: false, source: { file: "README.md", line: 42 } },
])} />);
fireEvent.click(screen.getByTestId("acceptance-item-0-toggle"));
const detail = screen.getByTestId("acceptance-item-0-detail");
expect(detail).toBeDefined();
expect(screen.getByTestId("acceptance-item-0-citation").textContent).toBe("README.md:42");
fireEvent.click(screen.getByTestId("acceptance-item-0-toggle"));
expect(screen.queryByTestId("acceptance-item-0-detail")).toBeNull();
});
it("preserves v0 progress bar + per-row data-done attributes", () => {
render(<AcceptanceTab acceptance={makeAcceptance([
{ text: "x", done: true, source: { file: "x.md", line: 1 } },
{ text: "y", done: false, source: { file: "x.md", line: 2 } },
])} />);
expect(screen.getByTestId("acceptance-progress-bar")).toBeDefined();
expect(screen.getByTestId("acceptance-progress-fill").getAttribute("data-percentage")).toBe("50");
expect(screen.getByTestId("acceptance-item-0").getAttribute("data-done")).toBe("true");
expect(screen.getByTestId("acceptance-item-1").getAttribute("data-done")).toBe("false");
});
it("empty items array still renders the v0 empty state (no filter row)", () => {
render(<AcceptanceTab acceptance={makeAcceptance([])} />);
expect(screen.getByTestId("acceptance-empty")).toBeDefined();
expect(screen.queryByTestId("acceptance-filter-row")).toBeNull();
});
});
+116
View File
@@ -0,0 +1,116 @@
// UI Enhancement Pack v0 — MarkdownViewer focused tests.
//
// Pins the load-bearing rendering primitives so future refactors of
// the inline parser (e.g., swapping in a marked / react-markdown lib
// later) preserve operator-visible behavior.
import { describe, it, expect, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { MarkdownViewer } from "../src/components/markdown/MarkdownViewer.js";
afterEach(() => cleanup());
describe("UI Enhancement Pack v0 — MarkdownViewer", () => {
it("renders YAML frontmatter as a metadata header above the body", () => {
const md = `---\nslice: my-slice\nstatus: active\n---\n# Title\nbody text`;
render(<MarkdownViewer content={md} />);
const fm = screen.getByTestId("markdown-frontmatter");
expect(fm.textContent).toContain("slice");
expect(fm.textContent).toContain("my-slice");
expect(fm.textContent).toContain("status");
expect(fm.textContent).toContain("active");
});
it("hides frontmatter when hideFrontmatter prop is true", () => {
const md = `---\nslice: x\n---\n# Title`;
render(<MarkdownViewer content={md} hideFrontmatter />);
expect(screen.queryByTestId("markdown-frontmatter")).toBeNull();
});
it("renders headings with proper levels (# / ## / ### / ####)", () => {
const md = `# H1\n\n## H2\n\n### H3\n\n#### H4`;
render(<MarkdownViewer content={md} />);
expect(screen.getByTestId("md-heading-1").textContent).toBe("H1");
expect(screen.getByTestId("md-heading-2").textContent).toBe("H2");
expect(screen.getByTestId("md-heading-3").textContent).toBe("H3");
expect(screen.getByTestId("md-heading-4").textContent).toBe("H4");
});
it("renders bullet lists with depth from indentation", () => {
const md = `- top-level\n - nested-1\n - nested-2`;
render(<MarkdownViewer content={md} />);
const list = screen.getByTestId("md-list-ul");
const items = list.querySelectorAll("li");
expect(items).toHaveLength(3);
});
it("renders ordered lists", () => {
const md = `1. first\n2. second`;
render(<MarkdownViewer content={md} />);
expect(screen.getByTestId("md-list-ol").querySelectorAll("li")).toHaveLength(2);
});
it("renders fenced code blocks with the SyntaxHighlight component (per language)", () => {
const md = "```ts\nconst x = 1;\n```";
render(<MarkdownViewer content={md} />);
const block = screen.getByTestId("syntax-highlight-block");
expect(block.getAttribute("data-language")).toBe("ts");
expect(block.textContent).toContain("const");
expect(block.textContent).toContain("x");
});
it("renders mermaid code blocks as a placeholder per item 2 carve-out (no library bundled at v0)", () => {
const md = "```mermaid\ngraph TD\n A-->B\n```";
render(<MarkdownViewer content={md} />);
const placeholder = screen.getByTestId("md-mermaid-placeholder");
expect(placeholder.textContent).toContain("mermaid");
const btn = screen.getByTestId("md-mermaid-render-btn") as HTMLButtonElement;
expect(btn.disabled).toBe(true);
});
it("renders inline code with `...` syntax", () => {
const md = `paragraph with \`inline code\` here`;
render(<MarkdownViewer content={md} />);
expect(screen.getByTestId("md-inline-code").textContent).toBe("inline code");
});
it("renders inline links with [text](url)", () => {
const md = `see [the docs](https://example.com/docs)`;
render(<MarkdownViewer content={md} />);
const link = screen.getByTestId("md-inline-link") as HTMLAnchorElement;
expect(link.textContent).toBe("the docs");
expect(link.getAttribute("href")).toBe("https://example.com/docs");
});
it("renders inline images with relative src resolved against assetBasePath", () => {
const md = `![diagram](shots/foo.png)`;
render(<MarkdownViewer content={md} assetBasePath="/api/files/asset?root=ws&path=docs" />);
const img = screen.getByTestId("md-inline-image") as HTMLImageElement;
expect(img.getAttribute("src")).toBe("/api/files/asset?root=ws&path=docs/shots/foo.png");
});
it("absolute URLs in image src pass through unchanged", () => {
const md = `![remote](https://example.com/img.png)`;
render(<MarkdownViewer content={md} assetBasePath="/api/files/asset?root=ws" />);
const img = screen.getByTestId("md-inline-image") as HTMLImageElement;
expect(img.getAttribute("src")).toBe("https://example.com/img.png");
});
it("renders tables with header + body rows", () => {
const md = `| col-a | col-b |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |`;
render(<MarkdownViewer content={md} />);
const wrapper = screen.getByTestId("md-table-wrapper");
const headers = wrapper.querySelectorAll("thead th");
expect(headers).toHaveLength(2);
const bodyRows = wrapper.querySelectorAll("tbody tr");
expect(bodyRows).toHaveLength(2);
});
it("renders bold (**) and italic (*) inline emphasis", () => {
const md = `paragraph with **bold** and *italic* text`;
render(<MarkdownViewer content={md} />);
const para = screen.getByTestId("md-paragraph");
expect(para.querySelector("strong")?.textContent).toBe("bold");
expect(para.querySelector("em")?.textContent).toBe("italic");
});
});