merge(post-V1): project-observability — observability surfaces + workspace contract + actionable approve/deny/route controls

Project + For You + Slice tabs evolved into elite project-management observability surface across 8 phases on top of V1 ship: For You feed schema fix; Story body-primary + Queue drawer + Tests diagnostics; Current/Archive Project UX; mission-aware default workspace scaffold + nested mission/slice indexing + auto-rebase settings (SC-29 #5); workspace.dogfood_evidence_root productization + read-only proof indexing wiring (SC-29 #6); founder-walk polish (route-load fix + content quality + Tests proof viewer + Project hierarchy pages with scoped rollups); graphics package across surfaces + DRY proof viewer + actionable approve/deny/route controls + Story rollups + topology fallback + vellum normalization; outcome panels + cache invalidation + drawer outside-click; terminal popover width + flip/clamp positioning + wrap behavior.
This commit is contained in:
mvschwarz committed 2026-05-08 02:32:18 -07:00
commit c7c9751adc
56 files changed
+4721 -558

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
# Project Workspace Contract
OpenRig's Project UI is a file-backed view over a workspace root. The folder
shape is intentionally simple so humans and agents can create or repair it
without daemon-internal knowledge.
## Default Shape
`rig config init-workspace` creates the default workspace at
`~/.openrig/workspace` unless `--root` or `workspace.root` points elsewhere.
```text
workspace/
README.md
STEERING.md
missions/
README.md
idea-ledger/
README.md
PROGRESS.md
slices/
capture-product-ideas/
README.md
PROGRESS.md
IMPLEMENTATION-PRD.md
triage-product-ideas/
README.md
PROGRESS.md
IMPLEMENTATION-PRD.md
handoff-loop/
README.md
PROGRESS.md
slices/
route-work-packets/
README.md
PROGRESS.md
IMPLEMENTATION-PRD.md
verify-loop-evidence/
README.md
PROGRESS.md
IMPLEMENTATION-PRD.md
progress/
field-notes/
specs/
```
## UI Mapping
- `workspace.root` maps to the Project workspace.
- `workspace.root/missions/<mission-id>` maps to a Project mission.
- `workspace.root/missions/<mission-id>/slices/<slice-id>` maps to a Project slice.
- Mission `PROGRESS.md` frontmatter supplies the mission status badge when the
file root is allowlisted.
- Slice `README.md`, `PROGRESS.md`, and `IMPLEMENTATION-PRD.md` frontmatter
supply display name, lifecycle status, mission id, and queue linkage hints.
Mission and slice ids should be stable kebab-case strings. Keep slice ids
unique inside the workspace so `/project/slice/<slice-id>` resolves without
ambiguity.
## Queue Mapping
Queue items attach to a slice when their body or tags mention one of:
- the slice id;
- the mission id;
- the legacy `rail-item` value in slice frontmatter.
For new work, include both mission and slice ids in the queue item body or
tags. Example:
```text
Mission: idea-ledger
Slice: capture-product-ideas
```
This makes Story, Queue, Tests, and Topology tabs line up with the filesystem
slice without adding a separate project database schema.
## Compatibility
The default discovery root is `workspace.slices_root=<workspace.root>/missions`.
The slice indexer also supports legacy flat roots such as
`workspace.slices_root=<workspace.root>/slices`, where each direct child folder
is a slice. Flat roots remain readable, but the mission-aware shape is the
default setup contract.
## Repair Checklist
If Project shows a mission discovery warning:
1. Run `rig config get workspace.root --show-source`.
2. Run `rig config get workspace.slices_root --show-source`.
3. Confirm `workspace.slices_root` points at a folder containing mission
directories with `slices/` children.
4. Confirm `files.allowlist` includes `workspace:<workspace.root>` so the UI
can read mission `PROGRESS.md`.
5. If the workspace is missing, run `rig config init-workspace` after operator
approval.
No daemon restart is required for most config reads. Restart when changing
startup-time roots such as `files.allowlist` or progress scan roots.
@@ -1,14 +1,14 @@
// User Settings v0 — `rig config init-workspace` operator-explicit scaffolding.
//
// Creates the 5-subdir default workspace at ~/.openrig/workspace/ (or a
// caller-supplied --root). Operator-explicit per founder safety stop:
// never auto-runs on `rig setup` / `rig daemon start` / first `rig up`.
// Creates the mission-aware default workspace at ~/.openrig/workspace/ (or a
// caller-supplied --root). Operator-explicit safety stop: never auto-runs on
// `rig setup` / `rig daemon start` / first `rig up`.
//
// Behavior:
// - Reads workspace.root setting (default ~/.openrig/workspace/).
// - Creates the 5 canonical subdirs: slices/ steering/ progress/ field-notes/ specs/.
// - Drops a 1-paragraph README.md in each subdir.
// - Drops a placeholder steering/STEERING.md.
// - Creates canonical subdirs: missions/ progress/ field-notes/ specs/ dogfood-evidence/.
// - Seeds two example missions with multiple slices.
// - Drops workspace README.md + STEERING.md.
// - --dry-run: report what would be created without acting.
// - --force: overwrite existing files (NOT directories — never deletes
// operator content).
@@ -36,25 +36,106 @@ export interface InitWorkspaceResult {
dryRun: boolean;
}
const SUBDIRS = ["slices", "steering", "progress", "field-notes", "specs"] as const;
type DefaultSlice = {
id: string;
title: string;
status: "active" | "draft";
objective: string;
};
function readmeContent(subdir: string): string {
type DefaultMission = {
id: string;
title: string;
status: "active" | "draft";
objective: string;
slices: DefaultSlice[];
};
const DEFAULT_MISSIONS: DefaultMission[] = [
{
id: "idea-ledger",
title: "Idea Ledger",
status: "active",
objective: "Capture, triage, and advance product ideas as queue-backed slices.",
slices: [
{
id: "capture-product-ideas",
title: "Capture Product Ideas",
status: "active",
objective: "Collect useful product ideas into queue items tagged with idea-ledger and this slice id.",
},
{
id: "triage-product-ideas",
title: "Triage Product Ideas",
status: "draft",
objective: "Review captured ideas, select the next slice, and preserve rationale in Project.",
},
],
},
{
id: "handoff-loop",
title: "Handoff Loop",
status: "active",
objective: "Demonstrate queue handoffs moving work through a small agent workflow.",
slices: [
{
id: "route-work-packets",
title: "Route Work Packets",
status: "active",
objective: "Create queue packets that move from orchestration to delivery to review.",
},
{
id: "verify-loop-evidence",
title: "Verify Loop Evidence",
status: "draft",
objective: "Collect proof that handoffs, artifacts, and closure decisions are visible in Project.",
},
],
},
];
const WORKSPACE_DIRS = [
"missions",
"progress",
"field-notes",
"specs",
"dogfood-evidence",
...DEFAULT_MISSIONS.flatMap((mission) => [
`missions/${mission.id}`,
`missions/${mission.id}/slices`,
...mission.slices.map((slice) => `missions/${mission.id}/slices/${slice.id}`),
]),
] as const;
function subdirReadmeContent(subdir: string): string {
switch (subdir) {
case "slices":
return "# slices\n\nYour slice authoring workspace. OpenRig's Slice Story View browses\nthis directory by default. Each slice lives in its own subdirectory\nwith README.md + IMPLEMENTATION-PRD.md + dogfood-evidence.\n";
case "steering":
return "# steering\n\nThe `STEERING.md` priority-stack lives here. OpenRig's Steering surface\ncomposes the 6-panel view (priority stack, roadmap rail, in-motion,\nlane rails, loop state, health gates) from this file.\n";
case "missions":
return "# missions\n\nProject missions live here. Each mission folder maps to one Project mission in the UI and owns a `slices/` child folder.\n\nExpected shape:\n\n```text\nmissions/<mission-id>/README.md\nmissions/<mission-id>/PROGRESS.md\nmissions/<mission-id>/slices/<slice-id>/README.md\n```\n";
case "progress":
return "# progress\n\nPROGRESS.md tree. OpenRig's Progress browse view scans this directory\nrecursively (max depth 6) for PROGRESS.md files and renders them as a\nhierarchical tree.\n";
return "# progress\n\nPROGRESS.md tree. OpenRig's Progress browse view scans this directory recursively for PROGRESS.md files and renders them as a hierarchical tree.\n";
case "field-notes":
return "# field-notes\n\nOperator field notes. Free-form markdown notes from your daily work.\nOpenRig's UI surfaces these alongside slices for context.\n";
return "# field-notes\n\nOperator field notes. Free-form markdown notes from your daily work. OpenRig surfaces these alongside missions and slices for context.\n";
case "specs":
return "# specs\n\nWorkspace specs (rig specs / agent specs / workflow specs / context\npacks). OpenRig's Spec Library browses this directory alongside the\ndaemon's bundled built-in specs.\n";
return "# specs\n\nWorkspace specs (rig specs / agent specs / workflow specs / context packs / skills). OpenRig's Library browses this directory alongside bundled specs.\n";
case "dogfood-evidence":
return "# dogfood-evidence\n\nProof packets live here. Each proof packet folder is matched to a Project slice by folder-name tokens and may contain markdown, screenshots, videos, traces, and other verification artifacts.\n";
default:
return `# ${subdir}\n`;
}
}
const WORKSPACE_README = `# OpenRig Workspace
This workspace is file-backed. The Project UI mirrors this structure:
- \`missions/<mission-id>\` becomes a Project mission.
- \`missions/<mission-id>/slices/<slice-id>\` becomes a Project slice.
- Queue items should mention or tag the mission id and slice id so Project can attach live work to the right slice.
- \`dogfood-evidence/<proof-packet-id>\` becomes Tests proof when the packet id contains the slice id tokens.
Use stable kebab-case ids for mission and slice folders. Keep slice ids unique inside the workspace.
`;
const STEERING_PLACEHOLDER = `---
title: Priority Stack
status: placeholder
@@ -62,9 +143,9 @@ status: placeholder
# OpenRig Priority Stack
This file is a placeholder created by \`rig config init-workspace\`. Edit
it to record your top 3 priorities. OpenRig's Steering surface composes
the 6-panel view from this file alongside your PROGRESS.md tree.
This file is a placeholder created by \`rig config init-workspace\`. Edit it
to record your top priorities. OpenRig's Steering surface reads this file
alongside your Project workspace.
## Top 3
@@ -81,9 +162,130 @@ the 6-panel view from this file alongside your PROGRESS.md tree.
(Health gates + loop diagnostics land here.)
`;
function missionReadme(mission: DefaultMission): string {
return `---
title: ${mission.title}
status: ${mission.status}
mission: ${mission.id}
---
# ${mission.title}
${mission.objective}
## Slices
${mission.slices.map((slice) => `- [${slice.title}](slices/${slice.id}/README.md)`).join("\n")}
`;
}
function missionProgress(mission: DefaultMission): string {
return `---
title: ${mission.title} Progress
status: ${mission.status}
mission: ${mission.id}
---
# ${mission.title} Progress
- [ ] Keep mission README current.
- [ ] Keep active slices queue-backed with mission and slice ids.
`;
}
function sliceReadme(mission: DefaultMission, slice: DefaultSlice): string {
return `---
title: ${slice.title}
status: ${slice.status}
mission: ${mission.id}
rail-item: ${mission.id}
slice: ${slice.id}
---
# ${slice.title}
${slice.objective}
## Queue Mapping
Queue items for this slice should mention or tag:
- mission: \`${mission.id}\`
- slice: \`${slice.id}\`
This lets Project attach queue activity to the slice story, queue, tests, and topology tabs.
`;
}
function sliceProgress(mission: DefaultMission, slice: DefaultSlice): string {
return `---
title: ${slice.title} Progress
status: ${slice.status}
mission: ${mission.id}
rail-item: ${mission.id}
slice: ${slice.id}
---
# ${slice.title} Progress
- [ ] Define the next concrete packet.
- [ ] Attach queue work to this slice id.
- [ ] Capture proof or notes before closing.
`;
}
function slicePrd(mission: DefaultMission, slice: DefaultSlice): string {
return `---
title: ${slice.title} Implementation Notes
status: ${slice.status}
mission: ${mission.id}
rail-item: ${mission.id}
slice: ${slice.id}
---
# ${slice.title} Implementation Notes
## Goal
${slice.objective}
## Acceptance
- [ ] The work is visible in the Project slice.
- [ ] Queue items include enough body or tag context to link back to \`${slice.id}\`.
- [ ] Any proof artifacts are referenced from the slice before closure.
`;
}
function scaffoldFiles(): Array<{ relPath: string; content: string }> {
const files: Array<{ relPath: string; content: string }> = [
{ relPath: "README.md", content: WORKSPACE_README },
{ relPath: "STEERING.md", content: STEERING_PLACEHOLDER },
{ relPath: "missions/README.md", content: subdirReadmeContent("missions") },
{ relPath: "progress/README.md", content: subdirReadmeContent("progress") },
{ relPath: "field-notes/README.md", content: subdirReadmeContent("field-notes") },
{ relPath: "specs/README.md", content: subdirReadmeContent("specs") },
{ relPath: "dogfood-evidence/README.md", content: subdirReadmeContent("dogfood-evidence") },
];
for (const mission of DEFAULT_MISSIONS) {
files.push(
{ relPath: `missions/${mission.id}/README.md`, content: missionReadme(mission) },
{ relPath: `missions/${mission.id}/PROGRESS.md`, content: missionProgress(mission) },
);
for (const slice of mission.slices) {
files.push(
{ relPath: `missions/${mission.id}/slices/${slice.id}/README.md`, content: sliceReadme(mission, slice) },
{ relPath: `missions/${mission.id}/slices/${slice.id}/PROGRESS.md`, content: sliceProgress(mission, slice) },
{ relPath: `missions/${mission.id}/slices/${slice.id}/IMPLEMENTATION-PRD.md`, content: slicePrd(mission, slice) },
);
}
}
return files;
}
export function initWorkspaceCommand(configPath?: string): Command {
const cmd = new Command("init-workspace")
.description("Scaffold the default workspace at ~/.openrig/workspace/ with 5 subdirs")
.description("Scaffold the default workspace at ~/.openrig/workspace/ with mission/slice folders")
.option("--root <path>", "Override workspace root (default: workspace.root setting)")
.option("--force", "Overwrite existing scaffolded files (does NOT remove directories)")
.option("--dry-run", "Show what would be created without writing")
@@ -130,31 +332,22 @@ export function runInitWorkspace(opts: InitWorkspaceOpts & { configPath?: string
if (!rootExists && !dryRun) mkdirSync(root, { recursive: true });
for (const sub of SUBDIRS) {
for (const sub of WORKSPACE_DIRS) {
const subPath = join(root, sub);
const subExists = existsSync(subPath);
if (!subExists && !dryRun) mkdirSync(subPath, { recursive: true });
result.subdirs.push({ name: sub, path: subPath, created: !subExists });
// README.md per subdir.
const readmePath = join(subPath, "README.md");
const readmeExists = existsSync(readmePath);
if (readmeExists && !force) {
result.files.push({ relPath: `${sub}/README.md`, absPath: readmePath, created: false, skipped: "exists" });
} else {
if (!dryRun) writeFileSync(readmePath, readmeContent(sub), "utf-8");
result.files.push({ relPath: `${sub}/README.md`, absPath: readmePath, created: true, skipped: null });
}
}
// steering/STEERING.md placeholder (dropped at the steering subdir).
const steeringPath = join(root, "steering", "STEERING.md");
const steeringExists = existsSync(steeringPath);
if (steeringExists && !force) {
result.files.push({ relPath: "steering/STEERING.md", absPath: steeringPath, created: false, skipped: "exists" });
} else {
if (!dryRun) writeFileSync(steeringPath, STEERING_PLACEHOLDER, "utf-8");
result.files.push({ relPath: "steering/STEERING.md", absPath: steeringPath, created: true, skipped: null });
for (const file of scaffoldFiles()) {
const absPath = join(root, file.relPath);
const exists = existsSync(absPath);
if (exists && !force) {
result.files.push({ relPath: file.relPath, absPath, created: false, skipped: "exists" });
} else {
if (!dryRun) writeFileSync(absPath, file.content, "utf-8");
result.files.push({ relPath: file.relPath, absPath, created: true, skipped: null });
}
}
return result;
+1 -1
View File
@@ -28,7 +28,7 @@ Examples:
rig config set workspace.slices_root /path # configure a workspace path
rig config reset # delete config file, revert all to defaults
rig config reset workspace.slices_root # clear one key, revert to default
rig config init-workspace # scaffold ~/.openrig/workspace/ with 5 subdirs
rig config init-workspace # scaffold ~/.openrig/workspace/ with missions + slices
Keys:
daemon.* port, host
+26 -5
View File
@@ -30,6 +30,7 @@ export interface RiggedConfig {
steeringPath: string;
fieldNotesRoot: string;
specsRoot: string;
dogfoodEvidenceRoot: string;
};
// User Settings v0 — UEP env-var graduation.
// Values are stored as raw named-pair strings ("name:/abs/path,...")
@@ -89,6 +90,7 @@ const DEFAULTS = {
steeringPath: "",
fieldNotesRoot: "",
specsRoot: "",
dogfoodEvidenceRoot: "",
},
files: { allowlist: "" },
progress: { scanRoots: "" },
@@ -140,6 +142,7 @@ export const VALID_KEYS = [
"workspace.steering_path",
"workspace.field_notes_root",
"workspace.specs_root",
"workspace.dogfood_evidence_root",
"files.allowlist",
"progress.scan_roots",
"ui.preview.refresh_interval_seconds",
@@ -173,6 +176,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy: string }> = {
"workspace.steering_path": { primary: "OPENRIG_WORKSPACE_STEERING_PATH", legacy: "RIGGED_WORKSPACE_STEERING_PATH" },
"workspace.field_notes_root": { primary: "OPENRIG_WORKSPACE_FIELD_NOTES_ROOT", legacy: "RIGGED_WORKSPACE_FIELD_NOTES_ROOT" },
"workspace.specs_root": { primary: "OPENRIG_WORKSPACE_SPECS_ROOT", legacy: "RIGGED_WORKSPACE_SPECS_ROOT" },
"workspace.dogfood_evidence_root": { primary: "OPENRIG_DOGFOOD_EVIDENCE_ROOT", legacy: "RIGGED_DOGFOOD_EVIDENCE_ROOT" },
// UEP env-var graduation: existing OPENRIG_FILES_ALLOWLIST /
// OPENRIG_PROGRESS_SCAN_ROOTS become the env override for the new
// typed keys (no breaking change).
@@ -209,6 +213,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"workspace.steering_path": ["workspace", "steeringPath"],
"workspace.field_notes_root": ["workspace", "fieldNotesRoot"],
"workspace.specs_root": ["workspace", "specsRoot"],
"workspace.dogfood_evidence_root": ["workspace", "dogfoodEvidenceRoot"],
"files.allowlist": ["files", "allowlist"],
"progress.scan_roots": ["progress", "scanRoots"],
"ui.preview.refresh_interval_seconds": ["ui", "preview", "refreshIntervalSeconds"],
@@ -250,28 +255,39 @@ function setNestedValue(obj: Record<string, unknown>, parts: string[], value: un
current[parts[parts.length - 1]!] = value;
}
// Per-subdir defaults derived from workspace.root. v0 uses canonical
// subdir names per founder dialog (slices/ steering/ progress/ field-notes/
// specs/). Steering is a FILE path (STEERING.md) under the steering subdir.
// Per-subdir defaults derived from workspace.root. Steering is a FILE path.
// Slice discovery defaults to the mission-aware workspace/missions contract;
// the indexer remains backward-compatible with flat slice roots when an
// operator sets workspace.slices_root explicitly.
// Files and Progress default to the whole workspace so a fresh
// `rig config init-workspace` install is browsable without extra env wiring.
export function deriveWorkspaceDefault(key: ValidKey, workspaceRoot: string): string {
switch (key) {
case "workspace.slices_root": return join(workspaceRoot, "slices");
case "workspace.steering_path": return join(workspaceRoot, "steering", "STEERING.md");
case "workspace.slices_root": return join(workspaceRoot, "missions");
case "workspace.steering_path": return join(workspaceRoot, "STEERING.md");
case "workspace.field_notes_root": return join(workspaceRoot, "field-notes");
case "workspace.specs_root": return join(workspaceRoot, "specs");
case "workspace.dogfood_evidence_root": return join(workspaceRoot, "dogfood-evidence");
case "files.allowlist": return `workspace:${workspaceRoot}`;
case "progress.scan_roots": return `workspace:${workspaceRoot}`;
default: return "";
}
}
function deriveLegacyWorkspaceDefault(key: ValidKey, workspaceRoot: string): string | null {
switch (key) {
case "workspace.slices_root": return join(workspaceRoot, "slices");
case "workspace.steering_path": return join(workspaceRoot, "steering", "STEERING.md");
default: return null;
}
}
const WORKSPACE_DERIVED_KEYS: ReadonlySet<ValidKey> = new Set([
"workspace.slices_root",
"workspace.steering_path",
"workspace.field_notes_root",
"workspace.specs_root",
"workspace.dogfood_evidence_root",
"files.allowlist",
"progress.scan_roots",
]);
@@ -343,6 +359,7 @@ export class ConfigStore {
steeringPath: v("workspace.steering_path") as string,
fieldNotesRoot: v("workspace.field_notes_root") as string,
specsRoot: v("workspace.specs_root") as string,
dogfoodEvidenceRoot: v("workspace.dogfood_evidence_root") as string,
},
files: {
allowlist: v("files.allowlist") as string,
@@ -412,6 +429,10 @@ export class ConfigStore {
// 2. Config file
const fileVal = getNestedValue(fileConfig, KEY_TO_PATH[key]);
if (fileVal !== undefined && fileVal !== null && fileVal !== "") {
const legacyDefault = deriveLegacyWorkspaceDefault(key, workspaceRoot);
if (legacyDefault !== null && fileVal === legacyDefault) {
return { value: defaultValue, source: "default", defaultValue };
}
return { value: fileVal as string | number | boolean, source: "file", defaultValue };
}
// 3. Default
+67 -23
View File
@@ -7,7 +7,7 @@
// - UEP env-var graduation: OPENRIG_FILES_ALLOWLIST + OPENRIG_PROGRESS_SCAN_ROOTS still work
// - reset(key) clears one key; bare reset deletes the file
// - parseNamedPairs decodes the named-pair format
// - init-workspace creates 5 subdirs + READMEs + STEERING placeholder; idempotent
// - init-workspace creates mission-aware workspace files; idempotent
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Command } from "commander";
@@ -29,13 +29,14 @@ function clearEnv(): () => void {
"OPENRIG_TRANSCRIPTS_ENABLED", "OPENRIG_TRANSCRIPTS_PATH",
"OPENRIG_WORKSPACE_ROOT", "OPENRIG_WORKSPACE_SLICES_ROOT",
"OPENRIG_WORKSPACE_STEERING_PATH", "OPENRIG_WORKSPACE_FIELD_NOTES_ROOT",
"OPENRIG_WORKSPACE_SPECS_ROOT",
"OPENRIG_WORKSPACE_SPECS_ROOT", "OPENRIG_DOGFOOD_EVIDENCE_ROOT",
"OPENRIG_FILES_ALLOWLIST", "OPENRIG_PROGRESS_SCAN_ROOTS",
"OPENRIG_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS",
"OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST",
"RIGGED_PORT", "RIGGED_HOST", "RIGGED_DB",
"RIGGED_TRANSCRIPTS_ENABLED", "RIGGED_TRANSCRIPTS_PATH",
"RIGGED_WORKSPACE_ROOT", "RIGGED_WORKSPACE_SLICES_ROOT",
"RIGGED_DOGFOOD_EVIDENCE_ROOT",
"RIGGED_FILES_ALLOWLIST", "RIGGED_PROGRESS_SCAN_ROOTS",
"RIGGED_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS",
"RIGGED_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST",
@@ -76,6 +77,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"transcripts.lines", "transcripts.poll_interval_seconds",
"workspace.root", "workspace.slices_root", "workspace.steering_path",
"workspace.field_notes_root", "workspace.specs_root",
"workspace.dogfood_evidence_root",
"files.allowlist", "progress.scan_roots",
"ui.preview.refresh_interval_seconds", "ui.preview.max_pins", "ui.preview.default_lines",
"recovery.auto_drive_provider_prompts",
@@ -121,10 +123,11 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
const store = new ConfigStore(configPath);
const cfg = store.resolve();
expect(cfg.workspace.root).toMatch(/\.openrig[\/\\]workspace$/);
expect(cfg.workspace.slicesRoot).toBe(join(cfg.workspace.root, "slices"));
expect(cfg.workspace.steeringPath).toBe(join(cfg.workspace.root, "steering", "STEERING.md"));
expect(cfg.workspace.slicesRoot).toBe(join(cfg.workspace.root, "missions"));
expect(cfg.workspace.steeringPath).toBe(join(cfg.workspace.root, "STEERING.md"));
expect(cfg.workspace.fieldNotesRoot).toBe(join(cfg.workspace.root, "field-notes"));
expect(cfg.workspace.specsRoot).toBe(join(cfg.workspace.root, "specs"));
expect(cfg.workspace.dogfoodEvidenceRoot).toBe(join(cfg.workspace.root, "dogfood-evidence"));
expect(cfg.files.allowlist).toBe(`workspace:${cfg.workspace.root}`);
expect(cfg.progress.scanRoots).toBe(`workspace:${cfg.workspace.root}`);
});
@@ -134,21 +137,38 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
store.set("workspace.root", "/custom/ws");
const cfg = store.resolve();
expect(cfg.workspace.root).toBe("/custom/ws");
expect(cfg.workspace.slicesRoot).toBe("/custom/ws/slices");
expect(cfg.workspace.steeringPath).toBe("/custom/ws/steering/STEERING.md");
expect(cfg.workspace.slicesRoot).toBe("/custom/ws/missions");
expect(cfg.workspace.steeringPath).toBe("/custom/ws/STEERING.md");
expect(cfg.workspace.dogfoodEvidenceRoot).toBe("/custom/ws/dogfood-evidence");
expect(cfg.files.allowlist).toBe("workspace:/custom/ws");
expect(cfg.progress.scanRoots).toBe("workspace:/custom/ws");
});
it("treats persisted legacy workspace defaults as default-derived values", () => {
writeFileSync(configPath, JSON.stringify({
workspace: {
root: "/custom/ws",
slicesRoot: "/custom/ws/slices",
steeringPath: "/custom/ws/steering/STEERING.md",
},
}));
const store = new ConfigStore(configPath);
const slices = store.resolveWithSource("workspace.slices_root");
const steering = store.resolveWithSource("workspace.steering_path");
expect(slices).toMatchObject({ value: "/custom/ws/missions", source: "default" });
expect(steering).toMatchObject({ value: "/custom/ws/STEERING.md", source: "default" });
});
it("per-subdir override beats workspace.root cascade", () => {
const store = new ConfigStore(configPath);
store.set("workspace.root", "/ws");
store.set("workspace.slices_root", "/founder/slices");
store.set("workspace.slices_root", "/custom/slices");
const cfg = store.resolve();
// workspace.root cascade applies to OTHER subdirs:
expect(cfg.workspace.fieldNotesRoot).toBe("/ws/field-notes");
expect(cfg.workspace.dogfoodEvidenceRoot).toBe("/ws/dogfood-evidence");
// per-subdir override wins:
expect(cfg.workspace.slicesRoot).toBe("/founder/slices");
expect(cfg.workspace.slicesRoot).toBe("/custom/slices");
});
it("env > file > default for new keys", () => {
@@ -196,6 +216,21 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
}
});
it("dogfood evidence root defaults under workspace.root and supports env override", () => {
const store = new ConfigStore(configPath);
store.set("workspace.root", "/custom/ws");
expect(store.resolve().workspace.dogfoodEvidenceRoot).toBe("/custom/ws/dogfood-evidence");
process.env["OPENRIG_DOGFOOD_EVIDENCE_ROOT"] = "/proof/root";
try {
const r = store.resolveWithSource("workspace.dogfood_evidence_root");
expect(r.value).toBe("/proof/root");
expect(r.source).toBe("env");
} finally {
delete process.env["OPENRIG_DOGFOOD_EVIDENCE_ROOT"];
}
});
it("legacy RIGGED_FILES_ALLOWLIST falls back when OPENRIG_* is unset", () => {
const store = new ConfigStore(configPath);
process.env["RIGGED_FILES_ALLOWLIST"] = "legacy:/old/path";
@@ -354,10 +389,11 @@ describe("parseNamedPairs", () => {
describe("deriveWorkspaceDefault", () => {
it("returns canonical subpaths under workspace root", () => {
expect(deriveWorkspaceDefault("workspace.slices_root", "/ws")).toBe("/ws/slices");
expect(deriveWorkspaceDefault("workspace.steering_path", "/ws")).toBe("/ws/steering/STEERING.md");
expect(deriveWorkspaceDefault("workspace.slices_root", "/ws")).toBe("/ws/missions");
expect(deriveWorkspaceDefault("workspace.steering_path", "/ws")).toBe("/ws/STEERING.md");
expect(deriveWorkspaceDefault("workspace.field_notes_root", "/ws")).toBe("/ws/field-notes");
expect(deriveWorkspaceDefault("workspace.specs_root", "/ws")).toBe("/ws/specs");
expect(deriveWorkspaceDefault("workspace.dogfood_evidence_root", "/ws")).toBe("/ws/dogfood-evidence");
});
});
@@ -381,9 +417,15 @@ describe("init-workspace runner", () => {
it("--dry-run reports what would be created without writing anything", () => {
const result = runInitWorkspace({ dryRun: true, root: workspaceRoot, configPath });
expect(result.dryRun).toBe(true);
expect(result.subdirs.map((s) => s.name)).toEqual([
"slices", "steering", "progress", "field-notes", "specs",
]);
expect(result.subdirs.map((s) => s.name)).toEqual(expect.arrayContaining([
"missions",
"progress",
"field-notes",
"specs",
"dogfood-evidence",
"missions/idea-ledger/slices/capture-product-ideas",
"missions/handoff-loop/slices/route-work-packets",
]));
expect(existsSync(workspaceRoot)).toBe(false);
});
@@ -401,7 +443,7 @@ describe("init-workspace runner", () => {
const parsed = JSON.parse(logs.join("\n")) as { root: string; dryRun: boolean; subdirs: Array<{ name: string }> };
expect(parsed.root).toBe(workspaceRoot);
expect(parsed.dryRun).toBe(true);
expect(parsed.subdirs.map((s) => s.name)).toContain("slices");
expect(parsed.subdirs.map((s) => s.name)).toContain("missions/idea-ledger/slices/capture-product-ideas");
});
it("rig-level --json emits parseable JSON for config init-workspace", async () => {
@@ -431,37 +473,39 @@ describe("init-workspace runner", () => {
const parsed = JSON.parse(logs.join("\n")) as { root: string; dryRun: boolean; subdirs: Array<{ name: string }> };
expect(parsed.root).toBe(workspaceRoot);
expect(parsed.dryRun).toBe(true);
expect(parsed.subdirs.map((s) => s.name)).toContain("slices");
expect(parsed.subdirs.map((s) => s.name)).toContain("missions/handoff-loop/slices/route-work-packets");
});
it("creates 5 subdirs + READMEs + STEERING placeholder", () => {
it("creates mission-aware workspace files + STEERING placeholder", () => {
const result = runInitWorkspace({ root: workspaceRoot, configPath });
expect(result.dryRun).toBe(false);
expect(existsSync(workspaceRoot)).toBe(true);
for (const sub of ["slices", "steering", "progress", "field-notes", "specs"]) {
for (const sub of ["missions", "progress", "field-notes", "specs", "dogfood-evidence"]) {
expect(existsSync(join(workspaceRoot, sub))).toBe(true);
expect(existsSync(join(workspaceRoot, sub, "README.md"))).toBe(true);
}
const steeringMd = readFileSync(join(workspaceRoot, "steering", "STEERING.md"), "utf-8");
expect(existsSync(join(workspaceRoot, "missions", "idea-ledger", "slices", "capture-product-ideas", "README.md"))).toBe(true);
expect(existsSync(join(workspaceRoot, "missions", "handoff-loop", "slices", "verify-loop-evidence", "IMPLEMENTATION-PRD.md"))).toBe(true);
const steeringMd = readFileSync(join(workspaceRoot, "STEERING.md"), "utf-8");
expect(steeringMd).toContain("OpenRig Priority Stack");
});
it("is idempotent: running twice without --force is a no-op for existing files", () => {
runInitWorkspace({ root: workspaceRoot, configPath });
const sliceReadme = join(workspaceRoot, "slices", "README.md");
const sliceReadme = join(workspaceRoot, "missions", "idea-ledger", "slices", "capture-product-ideas", "README.md");
writeFileSync(sliceReadme, "operator-edited content", "utf-8");
const second = runInitWorkspace({ root: workspaceRoot, configPath });
const sliceFile = second.files.find((f) => f.relPath === "slices/README.md");
const sliceFile = second.files.find((f) => f.relPath === "missions/idea-ledger/slices/capture-product-ideas/README.md");
expect(sliceFile?.skipped).toBe("exists");
expect(readFileSync(sliceReadme, "utf-8")).toBe("operator-edited content");
});
it("--force overwrites existing files but never deletes operator content under directories", () => {
runInitWorkspace({ root: workspaceRoot, configPath });
const operatorFile = join(workspaceRoot, "slices", "my-slice.md");
const operatorFile = join(workspaceRoot, "missions", "idea-ledger", "slices", "capture-product-ideas", "operator-note.md");
writeFileSync(operatorFile, "my work", "utf-8");
const operatorReadme = join(workspaceRoot, "slices", "README.md");
const operatorReadme = join(workspaceRoot, "missions", "idea-ledger", "slices", "capture-product-ideas", "README.md");
writeFileSync(operatorReadme, "edited", "utf-8");
runInitWorkspace({ root: workspaceRoot, force: true, configPath });
@@ -469,7 +513,7 @@ describe("init-workspace runner", () => {
expect(existsSync(operatorFile)).toBe(true);
expect(readFileSync(operatorFile, "utf-8")).toBe("my work");
// README is overwritten
expect(readFileSync(operatorReadme, "utf-8")).toContain("# slices");
expect(readFileSync(operatorReadme, "utf-8")).toContain("# Capture Product Ideas");
});
it("--root override beats configured workspace.root", () => {
@@ -36,16 +36,29 @@ rig config reset # delete the whole file
## Initialize a default workspace
```bash
rig config init-workspace # scaffold ~/.openrig/workspace/ + 5 subdirs
rig config init-workspace # scaffold ~/.openrig/workspace/ with missions + slices
rig config init-workspace --dry-run # show what would be created
rig config init-workspace --root /path # use a custom root
rig config init-workspace --force # overwrite scaffolded files (NOT operator content)
```
This creates `slices/ steering/ progress/ field-notes/ specs/` under the
workspace root, with one-paragraph READMEs and a placeholder
`steering/STEERING.md`. It is operator-explicit per founder safety stop —
do NOT run it on your own initiative; only when the operator asks.
This creates a file-backed Project workspace under the workspace root:
```text
README.md
STEERING.md
missions/<mission-id>/README.md
missions/<mission-id>/PROGRESS.md
missions/<mission-id>/slices/<slice-id>/README.md
progress/
field-notes/
specs/
```
It seeds two starter missions with multiple slices so the Project UI has a
real mission/slice map out of the box. It is operator-explicit; do NOT run it
on your own initiative unless the operator asks for workspace initialization
or repair.
## Common keys
@@ -57,8 +70,8 @@ do NOT run it on your own initiative; only when the operator asks.
| `transcripts.enabled` | boolean | Whether the daemon writes transcripts |
| `transcripts.path` | string | Transcripts dir (default ~/.openrig/transcripts) |
| `workspace.root` | string | Single-root override (default ~/.openrig/workspace) |
| `workspace.slices_root` | string | Per-subdir override; default `<root>/slices` |
| `workspace.steering_path` | string | STEERING.md path; default `<root>/steering/STEERING.md` |
| `workspace.slices_root` | string | Slice discovery root; default `<root>/missions` |
| `workspace.steering_path` | string | STEERING.md path; default `<root>/STEERING.md` |
| `workspace.field_notes_root` | string | Field notes dir; default `<root>/field-notes` |
| `workspace.specs_root` | string | Specs dir; default `<root>/specs` |
| `files.allowlist` | string | `name:/abs/path,name:/abs/path` — UI Files browser roots |
@@ -69,11 +82,11 @@ Resolution order for workspace per-subdir keys: per-subdir override >
## Common operator requests → canonical commands
> **"Make the UI work against my substrate layout"** (founder's case):
> **"Make the UI work against an existing workspace layout"**:
> set the per-subdir workspace overrides explicitly:
>
> ```bash
> rig config set workspace.slices_root /Users/me/code/substrate/.../slices
> rig config set workspace.slices_root /Users/me/code/substrate/.../missions
> rig config set workspace.steering_path /Users/me/code/substrate/openrig-work/STEERING.md
> rig config set workspace.progress_scan_roots 'work:/Users/me/code/substrate/openrig-work,missions:/Users/me/code/substrate/openrig-work/missions'
> rig config set workspace.field_notes_root /Users/me/code/substrate/openrig-work/field-notes
@@ -89,6 +102,16 @@ Resolution order for workspace per-subdir keys: per-subdir override >
> rig config init-workspace
> ```
After initialization, the Project UI expects this mapping:
- `workspace.root` maps to the Project workspace.
- `workspace.root/missions/<mission-id>` maps to a Project mission.
- `workspace.root/missions/<mission-id>/slices/<slice-id>` maps to a Project slice.
Queue item bodies or tags should mention the mission id and slice id when the
work belongs to a slice. That lets the Project Story, Queue, Tests, and
Topology tabs attach runtime work to the filesystem slice.
> **"Allow the file browser to see X directory"**:
>
> ```bash
@@ -139,6 +139,8 @@ export interface WorkflowBindingPayload {
export interface SliceDetailPayload {
name: string;
missionId: string | null;
slicePath: string;
displayName: string;
railItem: string | null;
status: string;
@@ -198,6 +200,8 @@ export class SliceDetailProjector {
return {
name: slice.name,
missionId: slice.missionId,
slicePath: slice.slicePath,
displayName: slice.displayName,
railItem: slice.railItem,
status: slice.status,
@@ -379,7 +383,7 @@ export class SliceDetailProjector {
// — these aren't qitem-bound so they don't trace to a spec step.
// UI will group them under "Untagged" or render them without a tag.
try {
const sliceDir = path.join(this.indexer.slicesRoot, slice.name);
const sliceDir = slice.slicePath;
const docEntries = fs.readdirSync(sliceDir, { withFileTypes: true });
for (const entry of docEntries) {
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
@@ -427,7 +431,7 @@ export class SliceDetailProjector {
// Parse README + IMPLEMENTATION-PRD + PROGRESS.md for [ ]/[x] checkbox lines.
// Source citation = file + 1-based line number so the operator can jump.
const candidateFiles = ["README.md", "IMPLEMENTATION-PRD.md", "PROGRESS.md", "IMPLEMENTATION.md"];
const sliceDir = path.join(this.indexer.slicesRoot, slice.name);
const sliceDir = slice.slicePath;
for (const fname of candidateFiles) {
const full = path.join(sliceDir, fname);
if (!fs.existsSync(full)) continue;
@@ -495,7 +499,7 @@ export class SliceDetailProjector {
// --- Docs tab ---
private buildDocsTree(slice: SliceRecord): DocsTreeEntry[] {
const sliceDir = path.join(this.indexer.slicesRoot, slice.name);
const sliceDir = slice.slicePath;
const out: DocsTreeEntry[] = [];
const walk = (dir: string, relPrefix: string): void => {
let entries: fs.Dirent[];
@@ -534,7 +538,9 @@ export class SliceDetailProjector {
/** Read a single doc file from the slice folder for the Docs tab. Returns null if missing or outside the slice folder. */
readDoc(sliceName: string, relPath: string): string | null {
const sliceDir = path.join(this.indexer.slicesRoot, sliceName);
const slice = this.indexer.get(sliceName);
if (!slice) return null;
const sliceDir = slice.slicePath;
const resolved = path.resolve(sliceDir, relPath);
if (!resolved.startsWith(`${path.resolve(sliceDir)}${path.sep}`) && resolved !== path.resolve(sliceDir)) {
// Path-traversal guard.
@@ -1,17 +1,17 @@
// Slice Story View v0 — slice indexer.
//
// Reads slice folders from a configured filesystem root (typically
// substrate-side `shared-docs/openrig-work/.../slices/`), parses each
// slice's frontmatter + acceptance section markers, and exposes a
// Reads slice folders from a configured filesystem root. The default
// workspace contract is `workspace/missions/<mission>/slices/<slice>`;
// explicitly configured flat roots (`workspace/slices/<slice>`) remain
// supported for compatibility. The indexer parses each slice's
// frontmatter + acceptance section markers and exposes a
// normalized Slice record stitched against already-shipped tables
// (queue_items, queue_transitions, mission_control_actions) and
// dogfood-evidence directories.
//
// MVP context: single-developer, single-user, single-host. v0 uses the
// slice folder as the navigable entity (per founder direction —
// workflow_instances are deferred to v1). NO new SQLite migrations,
// NO new event types: read-only projection over existing data + the
// filesystem.
// slice folder as the navigable entity. NO new SQLite migrations, NO new
// event types: read-only projection over existing data + the filesystem.
import * as fs from "node:fs";
import * as path from "node:path";
@@ -48,6 +48,10 @@ export interface SliceProofPacket {
export interface SliceRecord {
/** Folder name (canonical id). */
name: string;
/** Mission folder id when discovered under missions/<mission>/slices/<slice>. */
missionId: string | null;
/** Absolute filesystem path of the slice folder. */
slicePath: string;
/** Display name from frontmatter or first H1; falls back to name. */
displayName: string;
/** Optional rail-item code (PL-005, PL-019, etc.) parsed from frontmatter. */
@@ -70,6 +74,7 @@ export interface SliceRecord {
export interface SliceListEntry {
name: string;
missionId: string | null;
displayName: string;
railItem: string | null;
status: SliceStatus;
@@ -86,6 +91,8 @@ export interface SliceListEntry {
export interface SliceIndexerOpts {
/** Root directory containing slice folders. */
slicesRoot: string;
/** Additional compatible roots to scan after the primary root. */
additionalSliceRoots?: string[];
/** Root directory containing dogfood-evidence directories. */
dogfoodEvidenceRoot: string | null;
/** SQLite handle for read-only joins to queue_items + transitions + actions. */
@@ -104,6 +111,12 @@ interface CachedSlice {
expiresAt: number;
}
interface SliceLocation {
name: string;
missionId: string | null;
slicePath: string;
}
const FRONTMATTER_DELIM = "---";
const DEFAULT_CACHE_TTL_MS = 60_000;
@@ -123,6 +136,7 @@ const STATUS_TO_BUCKET: Record<string, SliceStatus> = {
export class SliceIndexer {
readonly slicesRoot: string;
readonly additionalSliceRoots: string[];
readonly dogfoodEvidenceRoot: string | null;
/** Workflows in Spec Library v0: exposed read-only so the slices
* route's `boundToWorkflow` lens filter can call
@@ -136,19 +150,15 @@ export class SliceIndexer {
constructor(opts: SliceIndexerOpts) {
this.slicesRoot = opts.slicesRoot;
this.additionalSliceRoots = opts.additionalSliceRoots ?? [];
this.dogfoodEvidenceRoot = opts.dogfoodEvidenceRoot;
this.db = opts.db;
this.cacheTtlMs = opts.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
}
/** Returns true when the slicesRoot path is configured + exists on disk. */
/** Returns true when any configured slice root exists on disk. */
isReady(): boolean {
if (!this.slicesRoot) return false;
try {
return fs.statSync(this.slicesRoot).isDirectory();
} catch {
return false;
}
return this.sliceRoots().some((root) => this.isDirectory(root));
}
/** Drops both caches. Used by tests + by a future explicit-refresh route. */
@@ -163,8 +173,8 @@ export class SliceIndexer {
if (this.listingCache && this.listingCache.expiresAt > now) {
return this.listingCache.entries;
}
const folderNames = this.readSliceFolderNames();
const entries: SliceListEntry[] = folderNames.map((name) => this.toListEntry(name));
const locations = this.readSliceLocations();
const entries: SliceListEntry[] = locations.map((location) => this.toListEntry(location));
this.listingCache = { entries, expiresAt: now + this.cacheTtlMs };
return entries;
}
@@ -176,46 +186,100 @@ export class SliceIndexer {
if (cached && cached.expiresAt > now) {
return cached.record;
}
const slicePath = path.join(this.slicesRoot, name);
let stat: fs.Stats;
try {
stat = fs.statSync(slicePath);
} catch {
return null;
}
if (!stat.isDirectory()) return null;
const location = this.findSliceLocation(name);
if (!location) return null;
const record = this.buildRecord(name, slicePath);
const record = this.buildRecord(location);
this.detailCache.set(name, { record, expiresAt: now + this.cacheTtlMs });
return record;
}
// --- internals ---
private readSliceFolderNames(): string[] {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(this.slicesRoot, { withFileTypes: true });
} catch {
return [];
private readSliceLocations(): SliceLocation[] {
const locations: SliceLocation[] = [];
const seen = new Set<string>();
const addLocation = (location: SliceLocation) => {
if (seen.has(location.name)) return;
seen.add(location.name);
locations.push(location);
};
for (const root of this.sliceRoots()) {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(root, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
const entryPath = path.join(root, entry.name);
const nestedSlicesRoot = path.join(entryPath, "slices");
if (this.isDirectory(nestedSlicesRoot)) {
let nestedEntries: fs.Dirent[];
try {
nestedEntries = fs.readdirSync(nestedSlicesRoot, { withFileTypes: true });
} catch {
nestedEntries = [];
}
for (const nested of nestedEntries) {
if (!nested.isDirectory() || nested.name.startsWith(".")) continue;
addLocation({
name: nested.name,
missionId: entry.name,
slicePath: path.join(nestedSlicesRoot, nested.name),
});
}
continue;
}
addLocation({
name: entry.name,
missionId: null,
slicePath: entryPath,
});
}
}
return entries
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
.map((e) => e.name)
.sort();
return locations.sort((a, b) => a.name.localeCompare(b.name));
}
private toListEntry(name: string): SliceListEntry {
const slicePath = path.join(this.slicesRoot, name);
private findSliceLocation(name: string): SliceLocation | null {
return this.readSliceLocations().find((location) => location.name === name) ?? null;
}
private isDirectory(absPath: string): boolean {
try {
return fs.statSync(absPath).isDirectory();
} catch {
return false;
}
}
private sliceRoots(): string[] {
const roots = [this.slicesRoot, ...this.additionalSliceRoots].filter((root) => root.length > 0);
const seen = new Set<string>();
const out: string[] = [];
for (const root of roots) {
const resolved = path.resolve(root);
if (seen.has(resolved)) continue;
seen.add(resolved);
out.push(root);
}
return out;
}
private toListEntry(location: SliceLocation): SliceListEntry {
const { name, missionId, slicePath } = location;
const frontmatter = this.readPrimaryFrontmatter(slicePath);
const status = this.mapStatus(frontmatter["status"] as string | undefined);
const railItem = this.extractRailItem(frontmatter);
const qitemIds = this.matchQitems(name, railItem);
const railItem = this.extractRailItem(frontmatter, missionId);
const qitemIds = this.matchQitems(name, railItem, missionId);
const proofPacket = this.findProofPacket(name);
const lastActivityAt = this.computeLastActivity(slicePath, qitemIds, proofPacket);
return {
name,
missionId,
displayName: this.extractDisplayName(slicePath, frontmatter, name),
railItem,
status,
@@ -227,11 +291,12 @@ export class SliceIndexer {
};
}
private buildRecord(name: string, slicePath: string): SliceRecord {
private buildRecord(location: SliceLocation): SliceRecord {
const { name, missionId, slicePath } = location;
const frontmatter = this.readPrimaryFrontmatter(slicePath);
const status = this.mapStatus(frontmatter["status"] as string | undefined);
const railItem = this.extractRailItem(frontmatter);
const qitemIds = this.matchQitems(name, railItem);
const railItem = this.extractRailItem(frontmatter, missionId);
const qitemIds = this.matchQitems(name, railItem, missionId);
const proofPacket = this.findProofPacket(name);
const lastActivityAt = this.computeLastActivity(slicePath, qitemIds, proofPacket);
const commitRefs = this.extractCommitRefs(frontmatter);
@@ -239,6 +304,8 @@ export class SliceIndexer {
return {
name,
missionId,
slicePath,
displayName: this.extractDisplayName(slicePath, frontmatter, name),
railItem,
status,
@@ -297,7 +364,7 @@ export class SliceIndexer {
return fallback;
}
private extractRailItem(frontmatter: Record<string, unknown>): string | null {
private extractRailItem(frontmatter: Record<string, unknown>, missionId: string | null): string | null {
const raw = frontmatter["rail-item"];
if (typeof raw === "string") {
// Strip array brackets if YAML parser dropped them as a string ("[PL-008]").
@@ -311,7 +378,7 @@ export class SliceIndexer {
if (stripped.length > 0) return stripped.split(",")[0]!.trim();
}
if (Array.isArray(related) && related.length > 0 && typeof related[0] === "string") return related[0];
return null;
return missionId;
}
private extractCommitRefs(frontmatter: Record<string, unknown>): string[] {
@@ -339,23 +406,25 @@ export class SliceIndexer {
return "active";
}
private matchQitems(sliceName: string, railItem: string | null): string[] {
// Strategy: union of (a) qitems whose body text mentions the slice name,
// (b) qitems whose body mentions the rail item code (PL-005, etc.).
// queue_items.tags is also TEXT (likely JSON or comma-list); we don't
// rely on it for v0 — body matching is the high-recall signal in the
// current corpus.
private matchQitems(sliceName: string, railItem: string | null, missionId: string | null): string[] {
// Strategy: union qitems whose body or tags mention the slice id,
// rail item, or mission id. Body matching preserves the legacy corpus;
// tags make the mission-aware default workspace precise for new work.
const ids = new Set<string>();
const terms = Array.from(new Set([sliceName, railItem, missionId].filter((v): v is string => !!v)));
try {
const sliceMatches = this.db.prepare(
`SELECT qitem_id FROM queue_items WHERE body LIKE ? LIMIT 500`
).all(`%${sliceName}%`) as Array<{ qitem_id: string }>;
for (const r of sliceMatches) ids.add(r.qitem_id);
if (railItem) {
const railMatches = this.db.prepare(
`SELECT qitem_id FROM queue_items WHERE body LIKE ? LIMIT 500`
).all(`%${railItem}%`) as Array<{ qitem_id: string }>;
for (const r of railMatches) ids.add(r.qitem_id);
for (const term of terms) {
try {
const rows = this.db.prepare(
`SELECT qitem_id FROM queue_items WHERE body LIKE ? OR tags LIKE ? LIMIT 500`
).all(`%${term}%`, `%${term}%`) as Array<{ qitem_id: string }>;
for (const r of rows) ids.add(r.qitem_id);
} catch {
const rows = this.db.prepare(
`SELECT qitem_id FROM queue_items WHERE body LIKE ? LIMIT 500`
).all(`%${term}%`) as Array<{ qitem_id: string }>;
for (const r of rows) ids.add(r.qitem_id);
}
}
} catch {
// queue_items table absent (test harness without the migration); return empty.
@@ -45,6 +45,7 @@ export const SETTINGS_VALID_KEYS = [
"workspace.steering_path",
"workspace.field_notes_root",
"workspace.specs_root",
"workspace.dogfood_evidence_root",
"files.allowlist",
"progress.scan_roots",
"ui.preview.refresh_interval_seconds",
@@ -86,6 +87,7 @@ const ENV_MAP: Record<SettingsValidKey, { primary: string; legacy: string }> = {
"workspace.steering_path": { primary: "OPENRIG_WORKSPACE_STEERING_PATH", legacy: "RIGGED_WORKSPACE_STEERING_PATH" },
"workspace.field_notes_root": { primary: "OPENRIG_WORKSPACE_FIELD_NOTES_ROOT", legacy: "RIGGED_WORKSPACE_FIELD_NOTES_ROOT" },
"workspace.specs_root": { primary: "OPENRIG_WORKSPACE_SPECS_ROOT", legacy: "RIGGED_WORKSPACE_SPECS_ROOT" },
"workspace.dogfood_evidence_root": { primary: "OPENRIG_DOGFOOD_EVIDENCE_ROOT", legacy: "RIGGED_DOGFOOD_EVIDENCE_ROOT" },
"files.allowlist": { primary: "OPENRIG_FILES_ALLOWLIST", legacy: "RIGGED_FILES_ALLOWLIST" },
"progress.scan_roots": { primary: "OPENRIG_PROGRESS_SCAN_ROOTS", legacy: "RIGGED_PROGRESS_SCAN_ROOTS" },
"ui.preview.refresh_interval_seconds": { primary: "OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS", legacy: "RIGGED_UI_PREVIEW_REFRESH_INTERVAL_SECONDS" },
@@ -115,6 +117,7 @@ const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
"workspace.steering_path": ["workspace", "steeringPath"],
"workspace.field_notes_root": ["workspace", "fieldNotesRoot"],
"workspace.specs_root": ["workspace", "specsRoot"],
"workspace.dogfood_evidence_root": ["workspace", "dogfoodEvidenceRoot"],
"files.allowlist": ["files", "allowlist"],
"progress.scan_roots": ["progress", "scanRoots"],
"ui.preview.refresh_interval_seconds": ["ui", "preview", "refreshIntervalSeconds"],
@@ -174,21 +177,31 @@ function setNestedValue(obj: Record<string, unknown>, parts: string[], value: un
function deriveWorkspaceDefault(key: SettingsValidKey, workspaceRoot: string): string {
switch (key) {
case "workspace.slices_root": return path.join(workspaceRoot, "slices");
case "workspace.steering_path": return path.join(workspaceRoot, "steering", "STEERING.md");
case "workspace.slices_root": return path.join(workspaceRoot, "missions");
case "workspace.steering_path": return path.join(workspaceRoot, "STEERING.md");
case "workspace.field_notes_root": return path.join(workspaceRoot, "field-notes");
case "workspace.specs_root": return path.join(workspaceRoot, "specs");
case "workspace.dogfood_evidence_root": return path.join(workspaceRoot, "dogfood-evidence");
case "files.allowlist": return `workspace:${workspaceRoot}`;
case "progress.scan_roots": return `workspace:${workspaceRoot}`;
default: return "";
}
}
function deriveLegacyWorkspaceDefault(key: SettingsValidKey, workspaceRoot: string): string | null {
switch (key) {
case "workspace.slices_root": return path.join(workspaceRoot, "slices");
case "workspace.steering_path": return path.join(workspaceRoot, "steering", "STEERING.md");
default: return null;
}
}
const WORKSPACE_DERIVED_KEYS: ReadonlySet<SettingsValidKey> = new Set([
"workspace.slices_root",
"workspace.steering_path",
"workspace.field_notes_root",
"workspace.specs_root",
"workspace.dogfood_evidence_root",
"files.allowlist",
"progress.scan_roots",
]);
@@ -252,6 +265,7 @@ export interface ResolvedConfig {
workspaceSteeringPath: string;
workspaceFieldNotesRoot: string;
workspaceSpecsRoot: string;
workspaceDogfoodEvidenceRoot: string;
filesAllowlistRaw: string;
progressScanRootsRaw: string;
// Preview Terminal v0 (PL-018) — UI preview preferences.
@@ -279,6 +293,10 @@ export class SettingsStore {
}
const fileVal = getNestedValue(fc, KEY_TO_PATH[key]);
if (fileVal !== undefined && fileVal !== null && fileVal !== "") {
const legacyDefault = deriveLegacyWorkspaceDefault(key, wr);
if (legacyDefault !== null && fileVal === legacyDefault) {
return { value: defaultValue, source: "default", defaultValue };
}
return { value: fileVal as string | number | boolean, source: "file", defaultValue };
}
return { value: defaultValue, source: "default", defaultValue };
@@ -304,6 +322,7 @@ export class SettingsStore {
workspaceSteeringPath: this.resolveOne("workspace.steering_path", fc, wr).value as string,
workspaceFieldNotesRoot: this.resolveOne("workspace.field_notes_root", fc, wr).value as string,
workspaceSpecsRoot: this.resolveOne("workspace.specs_root", fc, wr).value as string,
workspaceDogfoodEvidenceRoot: this.resolveOne("workspace.dogfood_evidence_root", fc, wr).value as string,
filesAllowlistRaw: this.resolveOne("files.allowlist", fc, wr).value as string,
progressScanRootsRaw: this.resolveOne("progress.scan_roots", fc, wr).value as string,
uiPreviewRefreshIntervalSeconds: this.resolveOne("ui.preview.refresh_interval_seconds", fc, wr).value as number,
@@ -0,0 +1,248 @@
type DefaultSlice = {
id: string;
title: string;
status: "active" | "draft";
objective: string;
};
type DefaultMission = {
id: string;
title: string;
status: "active" | "draft";
objective: string;
slices: DefaultSlice[];
};
const DEFAULT_MISSIONS: DefaultMission[] = [
{
id: "idea-ledger",
title: "Idea Ledger",
status: "active",
objective: "Capture, triage, and advance product ideas as queue-backed slices.",
slices: [
{
id: "capture-product-ideas",
title: "Capture Product Ideas",
status: "active",
objective: "Collect useful product ideas into queue items tagged with idea-ledger and this slice id.",
},
{
id: "triage-product-ideas",
title: "Triage Product Ideas",
status: "draft",
objective: "Review captured ideas, select the next slice, and preserve rationale in Project.",
},
],
},
{
id: "handoff-loop",
title: "Handoff Loop",
status: "active",
objective: "Demonstrate queue handoffs moving work through a small agent workflow.",
slices: [
{
id: "route-work-packets",
title: "Route Work Packets",
status: "active",
objective: "Create queue packets that move from orchestration to delivery to review.",
},
{
id: "verify-loop-evidence",
title: "Verify Loop Evidence",
status: "draft",
objective: "Collect proof that handoffs, artifacts, and closure decisions are visible in Project.",
},
],
},
];
export function workspaceScaffoldDirs(): string[] {
return [
"missions",
"progress",
"field-notes",
"specs",
"dogfood-evidence",
...DEFAULT_MISSIONS.flatMap((mission) => [
`missions/${mission.id}`,
`missions/${mission.id}/slices`,
...mission.slices.map((slice) => `missions/${mission.id}/slices/${slice.id}`),
]),
];
}
function subdirReadmeContent(subdir: string): string {
switch (subdir) {
case "missions":
return "# missions\n\nProject missions live here. Each mission folder maps to one Project mission in the UI and owns a `slices/` child folder.\n\nExpected shape:\n\n```text\nmissions/<mission-id>/README.md\nmissions/<mission-id>/PROGRESS.md\nmissions/<mission-id>/slices/<slice-id>/README.md\n```\n";
case "progress":
return "# progress\n\nPROGRESS.md tree. OpenRig's Progress browse view scans this directory recursively for PROGRESS.md files and renders them as a hierarchical tree.\n";
case "field-notes":
return "# field-notes\n\nOperator field notes. Free-form markdown notes from your daily work. OpenRig surfaces these alongside missions and slices for context.\n";
case "specs":
return "# specs\n\nWorkspace specs (rig specs / agent specs / workflow specs / context packs / skills). OpenRig's Library browses this directory alongside bundled specs.\n";
case "dogfood-evidence":
return "# dogfood-evidence\n\nProof packets live here. Each proof packet folder is matched to a Project slice by folder-name tokens and may contain markdown, screenshots, videos, traces, and other verification artifacts.\n";
default:
return `# ${subdir}\n`;
}
}
const WORKSPACE_README = `# OpenRig Workspace
This workspace is file-backed. The Project UI mirrors this structure:
- \`missions/<mission-id>\` becomes a Project mission.
- \`missions/<mission-id>/slices/<slice-id>\` becomes a Project slice.
- Queue items should mention or tag the mission id and slice id so Project can attach live work to the right slice.
- \`dogfood-evidence/<proof-packet-id>\` becomes Tests proof when the packet id contains the slice id tokens.
Use stable kebab-case ids for mission and slice folders. Keep slice ids unique inside the workspace.
`;
const STEERING_PLACEHOLDER = `---
title: Priority Stack
status: placeholder
---
# OpenRig Priority Stack
This file is a placeholder created by \`rig config init-workspace\`. Edit it
to record your top priorities. OpenRig's Steering surface reads this file
alongside your Project workspace.
## Top 3
1. <priority one>
2. <priority two>
3. <priority three>
## In Motion
(Active slices land here as you push them through the priority rail.)
## Loop State
(Health gates + loop diagnostics land here.)
`;
function missionReadme(mission: DefaultMission): string {
return `---
title: ${mission.title}
status: ${mission.status}
mission: ${mission.id}
---
# ${mission.title}
${mission.objective}
## Slices
${mission.slices.map((slice) => `- [${slice.title}](slices/${slice.id}/README.md)`).join("\n")}
`;
}
function missionProgress(mission: DefaultMission): string {
return `---
title: ${mission.title} Progress
status: ${mission.status}
mission: ${mission.id}
---
# ${mission.title} Progress
- [ ] Keep mission README current.
- [ ] Keep active slices queue-backed with mission and slice ids.
`;
}
function sliceReadme(mission: DefaultMission, slice: DefaultSlice): string {
return `---
title: ${slice.title}
status: ${slice.status}
mission: ${mission.id}
rail-item: ${mission.id}
slice: ${slice.id}
---
# ${slice.title}
${slice.objective}
## Queue Mapping
Queue items for this slice should mention or tag:
- mission: \`${mission.id}\`
- slice: \`${slice.id}\`
This lets Project attach queue activity to the slice story, queue, tests, and topology tabs.
`;
}
function sliceProgress(mission: DefaultMission, slice: DefaultSlice): string {
return `---
title: ${slice.title} Progress
status: ${slice.status}
mission: ${mission.id}
rail-item: ${mission.id}
slice: ${slice.id}
---
# ${slice.title} Progress
- [ ] Define the next concrete packet.
- [ ] Attach queue work to this slice id.
- [ ] Capture proof or notes before closing.
`;
}
function slicePrd(mission: DefaultMission, slice: DefaultSlice): string {
return `---
title: ${slice.title} Implementation Notes
status: ${slice.status}
mission: ${mission.id}
rail-item: ${mission.id}
slice: ${slice.id}
---
# ${slice.title} Implementation Notes
## Goal
${slice.objective}
## Acceptance
- [ ] The work is visible in the Project slice.
- [ ] Queue items include enough body or tag context to link back to \`${slice.id}\`.
- [ ] Any proof artifacts are referenced from the slice before closure.
`;
}
export function workspaceScaffoldFiles(): Array<{ relPath: string; content: string }> {
const files: Array<{ relPath: string; content: string }> = [
{ relPath: "README.md", content: WORKSPACE_README },
{ relPath: "STEERING.md", content: STEERING_PLACEHOLDER },
{ relPath: "missions/README.md", content: subdirReadmeContent("missions") },
{ relPath: "progress/README.md", content: subdirReadmeContent("progress") },
{ relPath: "field-notes/README.md", content: subdirReadmeContent("field-notes") },
{ relPath: "specs/README.md", content: subdirReadmeContent("specs") },
{ relPath: "dogfood-evidence/README.md", content: subdirReadmeContent("dogfood-evidence") },
];
for (const mission of DEFAULT_MISSIONS) {
files.push(
{ relPath: `missions/${mission.id}/README.md`, content: missionReadme(mission) },
{ relPath: `missions/${mission.id}/PROGRESS.md`, content: missionProgress(mission) },
);
for (const slice of mission.slices) {
files.push(
{ relPath: `missions/${mission.id}/slices/${slice.id}/README.md`, content: sliceReadme(mission, slice) },
{ relPath: `missions/${mission.id}/slices/${slice.id}/PROGRESS.md`, content: sliceProgress(mission, slice) },
{ relPath: `missions/${mission.id}/slices/${slice.id}/IMPLEMENTATION-PRD.md`, content: slicePrd(mission, slice) },
);
}
}
return files;
}
+16 -58
View File
@@ -9,9 +9,8 @@
// POST /api/config/init-workspace → scaffold default workspace dirs
//
// The CLI (`rig config get/set/reset/init-workspace`) is the canonical
// edit surface for operators + agents per founder dialog 2026-05-04.
// This route exists for the UI; agents stay on CLI-shell-out per the
// shipped openrig-user-settings skill.
// edit surface for operators + agents. This route exists for the UI;
// agents stay on CLI-shell-out per the shipped openrig-user-settings skill.
import { Hono } from "hono";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
@@ -21,6 +20,10 @@ import {
isSettingsValidKey,
type SettingsStore,
} from "../domain/user-settings/settings-store.js";
import {
workspaceScaffoldDirs,
workspaceScaffoldFiles,
} from "../domain/workspace/default-workspace-scaffold.js";
interface InitWorkspaceBody {
root?: string;
@@ -28,44 +31,6 @@ interface InitWorkspaceBody {
dryRun?: boolean;
}
const SUBDIRS = ["slices", "steering", "progress", "field-notes", "specs"] as const;
function readmeContent(subdir: string): string {
switch (subdir) {
case "slices": return "# slices\n\nYour slice authoring workspace. OpenRig's Slice Story View browses\nthis directory by default.\n";
case "steering": return "# steering\n\nThe `STEERING.md` priority-stack lives here. OpenRig's Steering surface\ncomposes the 6-panel view from this file.\n";
case "progress": return "# progress\n\nPROGRESS.md tree. OpenRig's Progress browse view scans this directory\nrecursively for PROGRESS.md files.\n";
case "field-notes": return "# field-notes\n\nOperator field notes. Free-form markdown notes from your daily work.\n";
case "specs": return "# specs\n\nWorkspace specs (rig / agent / workflow specs / context packs).\n";
default: return `# ${subdir}\n`;
}
}
const STEERING_PLACEHOLDER = `---
title: Priority Stack
status: placeholder
---
# OpenRig Priority Stack
This file is a placeholder created by \`rig config init-workspace\`. Edit
it to record your top 3 priorities.
## Top 3
1. <priority one>
2. <priority two>
3. <priority three>
## In Motion
(Active slices land here.)
## Loop State
(Health gates + loop diagnostics land here.)
`;
export function configRoutes(): Hono {
const router = new Hono();
@@ -89,29 +54,22 @@ export function configRoutes(): Hono {
const rootExists = existsSync(root);
if (!rootExists && !dryRun) mkdirSync(root, { recursive: true });
for (const sub of SUBDIRS) {
for (const sub of workspaceScaffoldDirs()) {
const subPath = join(root, sub);
const subExists = existsSync(subPath);
if (!subExists && !dryRun) mkdirSync(subPath, { recursive: true });
subdirs.push({ name: sub, path: subPath, created: !subExists });
const readmePath = join(subPath, "README.md");
const readmeExists = existsSync(readmePath);
if (readmeExists && !force) {
files.push({ relPath: `${sub}/README.md`, absPath: readmePath, created: false, skipped: "exists" });
} else {
if (!dryRun) writeFileSync(readmePath, readmeContent(sub), "utf-8");
files.push({ relPath: `${sub}/README.md`, absPath: readmePath, created: true, skipped: null });
}
}
const steeringPath = join(root, "steering", "STEERING.md");
const steeringExists = existsSync(steeringPath);
if (steeringExists && !force) {
files.push({ relPath: "steering/STEERING.md", absPath: steeringPath, created: false, skipped: "exists" });
} else {
if (!dryRun) writeFileSync(steeringPath, STEERING_PLACEHOLDER, "utf-8");
files.push({ relPath: "steering/STEERING.md", absPath: steeringPath, created: true, skipped: null });
for (const file of workspaceScaffoldFiles()) {
const absPath = join(root, file.relPath);
const fileExists = existsSync(absPath);
if (fileExists && !force) {
files.push({ relPath: file.relPath, absPath, created: false, skipped: "exists" });
} else {
if (!dryRun) writeFileSync(absPath, file.content, "utf-8");
files.push({ relPath: file.relPath, absPath, created: true, skipped: null });
}
}
return c.json({ root, rootCreated: !rootExists, subdirs, files, dryRun });
+1 -1
View File
@@ -39,7 +39,7 @@ export function slicesRoutes(): Hono {
if (!deps.indexer.isReady()) {
return c.json({
error: "slices_root_not_configured",
hint: "Set OPENRIG_SLICES_ROOT to the directory containing slice folders",
hint: "Run rig config init-workspace, or set workspace.slices_root to workspace/missions. Supported shape: missions/<mission>/slices/<slice>.",
}, 503);
}
const filter = (c.req.query("filter") ?? "all").toLowerCase();
+25 -5
View File
@@ -829,7 +829,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
//
// User Settings v0 graduates `OPENRIG_SLICES_ROOT` env-var into the
// typed `workspace.slices_root` setting (resolution chain: env >
// config-file > default `<workspace.root>/slices`). Backward-compat:
// config-file > default `<workspace.root>/missions`). Backward-compat:
// operators with OPENRIG_SLICES_ROOT set continue to work because the
// settings store reads env in the same resolution slot. Operators
// setting via `rig config set workspace.slices_root <path>` or via the
@@ -841,7 +841,8 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// OPENRIG_WORKSPACE_SLICES_ROOT typed-key env override
// workspace.slices_root typed setting in ~/.openrig/config.json
// workspace.root cascade fallback (default ~/.openrig/workspace)
// OPENRIG_DOGFOOD_EVIDENCE_ROOT absolute path to dogfood-evidence root
// workspace.dogfood_evidence_root typed setting for proof packet assets
// OPENRIG_DOGFOOD_EVIDENCE_ROOT env override for compatibility
//
// When the resolved slicesRoot path doesn't exist on disk, the indexer
// is still constructed but isReady() returns false — the routes return
@@ -855,21 +856,40 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// block ordering further below.
const legacyEnvSlicesRoot = readOpenRigEnv("OPENRIG_SLICES_ROOT", "RIGGED_SLICES_ROOT") ?? "";
let resolvedSlicesRoot = "";
let resolvedWorkspaceRoot = "";
let resolvedDogfoodRoot = "";
if (!legacyEnvSlicesRoot) {
try {
const { SettingsStore: SettingsStoreCtor } = await import("./domain/user-settings/settings-store.js");
resolvedSlicesRoot = new SettingsStoreCtor().resolveConfig().workspaceSlicesRoot;
const resolvedConfig = new SettingsStoreCtor().resolveConfig();
resolvedSlicesRoot = resolvedConfig.workspaceSlicesRoot;
resolvedWorkspaceRoot = resolvedConfig.workspaceRoot;
resolvedDogfoodRoot = resolvedConfig.workspaceDogfoodEvidenceRoot;
} catch {
// SettingsStore unavailable — keep slicesRoot empty; routes return 503.
}
} else {
try {
const { SettingsStore: SettingsStoreCtor } = await import("./domain/user-settings/settings-store.js");
const resolvedConfig = new SettingsStoreCtor().resolveConfig();
resolvedDogfoodRoot = resolvedConfig.workspaceDogfoodEvidenceRoot;
} catch {
// SettingsStore unavailable — proof packets remain disabled.
}
}
const slicesRoot = legacyEnvSlicesRoot || resolvedSlicesRoot;
const dogfoodRoot = readOpenRigEnv("OPENRIG_DOGFOOD_EVIDENCE_ROOT", "RIGGED_DOGFOOD_EVIDENCE_ROOT") ?? "";
const additionalSliceRoots = resolvedWorkspaceRoot
? [
nodePath.join(resolvedWorkspaceRoot, "missions"),
nodePath.join(resolvedWorkspaceRoot, "slices"),
].filter((root) => root !== slicesRoot)
: [];
const { SliceIndexer } = await import("./domain/slices/slice-indexer.js");
const { SliceDetailProjector } = await import("./domain/slices/slice-detail-projector.js");
const sliceIndexer = new SliceIndexer({
slicesRoot,
dogfoodEvidenceRoot: dogfoodRoot || null,
additionalSliceRoots,
dogfoodEvidenceRoot: resolvedDogfoodRoot || null,
db,
});
// Slice Story View v1: pass workflowRuntime.specCache so the
+76 -5
View File
@@ -7,7 +7,7 @@
// - frontmatter parsing + display name fallback
// - status enum mapping (incl. heuristic fallbacks)
// - rail-item extraction from frontmatter
// - qitem matching strategies (slice-name body match + rail-item body match)
// - qitem matching strategies (slice-name/mission body + tags)
// - dogfood-evidence proof packet detection (with screenshots / videos / traces)
// - cache TTL invalidation
// - graceful degradation when slicesRoot is unset / queue_items missing
@@ -44,10 +44,10 @@ function writeSlice(slicesRoot: string, name: string, files: Record<string, stri
}
}
function insertQitem(db: Database.Database, opts: { qitemId: string; body: string; tsCreated?: string; tsUpdated?: string }): void {
function insertQitem(db: Database.Database, opts: { qitemId: string; body: string; tags?: string[]; tsCreated?: string; tsUpdated?: string }): void {
db.prepare(
`INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, priority, body)
VALUES (?, ?, ?, ?, ?, ?, 'routine', ?)`
`INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, priority, tags, body)
VALUES (?, ?, ?, ?, ?, ?, 'routine', ?, ?)`
).run(
opts.qitemId,
opts.tsCreated ?? "2026-05-04T00:00:00.000Z",
@@ -55,6 +55,7 @@ function insertQitem(db: Database.Database, opts: { qitemId: string; body: strin
"src@r",
"dst@r",
"in-progress",
opts.tags ? JSON.stringify(opts.tags) : null,
opts.body,
);
}
@@ -130,6 +131,49 @@ describe("PL-slice-story-view-v0 SliceIndexer", () => {
expect(entries.map((e) => e.name).sort()).toEqual(["alpha-slice", "beta-slice"]);
});
it("enumerates mission-aware workspace layout under missions/<mission>/slices/<slice>", () => {
const missionsRoot = path.join(cleanup, "missions");
writeSlice(path.join(missionsRoot, "idea-ledger", "slices"), "capture-product-ideas", {
"README.md": "---\ntitle: Capture Product Ideas\nstatus: active\n---\n# Capture\n",
});
writeSlice(path.join(missionsRoot, "handoff-loop", "slices"), "route-work-packets", {
"README.md": "---\nstatus: draft\n---\n# Route\n",
});
const indexer = new SliceIndexer({ slicesRoot: missionsRoot, dogfoodEvidenceRoot: null, db });
const entries = indexer.list();
expect(entries.map((e) => [e.name, e.missionId])).toEqual([
["capture-product-ideas", "idea-ledger"],
["route-work-packets", "handoff-loop"],
]);
expect(indexer.get("capture-product-ideas")?.slicePath).toBe(
path.join(missionsRoot, "idea-ledger", "slices", "capture-product-ideas"),
);
});
it("can index legacy flat slices and mission-aware slices from compatibility roots", () => {
const missionsRoot = path.join(cleanup, "missions");
writeSlice(slicesRoot, "legacy-flat-slice", {
"README.md": "---\nstatus: active\n---\n# Legacy\n",
});
writeSlice(path.join(missionsRoot, "demo-seed", "slices"), "idea-ledger-find-ideas-cycle-4", {
"README.md": "---\ntitle: Find Ideas Cycle 4\nstatus: active\n---\n# Cycle 4\n",
});
const indexer = new SliceIndexer({
slicesRoot,
additionalSliceRoots: [missionsRoot],
dogfoodEvidenceRoot: null,
db,
});
const byName = new Map(indexer.list().map((entry) => [entry.name, entry]));
expect(byName.get("legacy-flat-slice")?.missionId).toBeNull();
expect(byName.get("idea-ledger-find-ideas-cycle-4")?.missionId).toBe("demo-seed");
expect(indexer.get("idea-ledger-find-ideas-cycle-4")?.slicePath).toBe(
path.join(missionsRoot, "demo-seed", "slices", "idea-ledger-find-ideas-cycle-4"),
);
});
it("derives displayName from frontmatter title, then first H1, then folder name", () => {
writeSlice(slicesRoot, "from-title", { "README.md": "---\ntitle: Custom Title\n---\n# Heading" });
writeSlice(slicesRoot, "from-h1", { "README.md": "---\nstatus: draft\n---\n# H1 Heading\nbody" });
@@ -210,6 +254,33 @@ describe("PL-slice-story-view-v0 SliceIndexer", () => {
expect(slice.qitemIds.sort()).toEqual(["q-by-name", "q-by-rail"]);
});
it("matches nested mission slices by mission id and tags", () => {
const missionsRoot = path.join(cleanup, "missions");
writeSlice(path.join(missionsRoot, "idea-ledger", "slices"), "triage-product-ideas", {
"README.md": "---\nstatus: active\n---\n# Triage\n",
});
insertQitem(db, {
qitemId: "q-by-mission-body",
body: "Advance the idea-ledger mission.",
});
insertQitem(db, {
qitemId: "q-by-slice-tag",
body: "No visible slice name here.",
tags: ["triage-product-ideas"],
});
insertQitem(db, {
qitemId: "q-none",
body: "Other project",
tags: ["unrelated"],
});
const indexer = new SliceIndexer({ slicesRoot: missionsRoot, dogfoodEvidenceRoot: null, db });
const slice = indexer.get("triage-product-ideas")!;
expect(slice.missionId).toBe("idea-ledger");
expect(slice.railItem).toBe("idea-ledger");
expect(slice.qitemIds.sort()).toEqual(["q-by-mission-body", "q-by-slice-tag"]);
});
it("returns empty qitem set when queue_items table is absent", () => {
// Re-create db without queue_items to simulate the test-harness gap.
const bareDb = createDb();
@@ -262,7 +333,7 @@ describe("PL-slice-story-view-v0 SliceIndexer", () => {
expect(indexer.get("x-slice")!.proofPacket?.dirName).toBe("x-slice-20260601");
});
it("classifies .mp4/.webm files as videos (founder-named load-bearing)", () => {
it("classifies .mp4/.webm files as videos", () => {
writeSlice(slicesRoot, "video-slice", { "README.md": "---\n---\n" });
const proofDir = path.join(dogfoodRoot, "video-slice-20260504");
fs.mkdirSync(proofDir);
+4 -4
View File
@@ -107,7 +107,7 @@ describe("PL-slice-story-view-v0 slices routes", () => {
expect(res.status).toBe(503);
const body = (await res.json()) as { error: string; hint: string };
expect(body.error).toBe("slices_root_not_configured");
expect(body.hint).toContain("OPENRIG_SLICES_ROOT");
expect(body.hint).toContain("workspace/missions");
});
it("returns slices array sorted by lastActivityAt DESC with totalCount + filter echo", async () => {
@@ -156,8 +156,8 @@ describe("PL-slice-story-view-v0 slices routes", () => {
const body = (await res.json()) as Record<string, unknown>;
expect(Object.keys(body).sort()).toEqual([
"acceptance", "commitRefs", "decisions", "displayName", "docs",
"lastActivityAt", "name", "qitemIds", "railItem", "rawStatus",
"status", "story", "tests", "topology", "workflowBinding",
"lastActivityAt", "missionId", "name", "qitemIds", "railItem", "rawStatus",
"slicePath", "status", "story", "tests", "topology", "workflowBinding",
]);
const acc = (body.acceptance as { totalItems: number; doneItems: number; percentage: number });
expect(acc.totalItems).toBe(2);
@@ -230,7 +230,7 @@ describe("PL-slice-story-view-v0 slices routes", () => {
});
});
describe("GET /api/slices/:name/proof-asset/* (founder-named load-bearing)", () => {
describe("GET /api/slices/:name/proof-asset/* (named proof assets)", () => {
it("serves a screenshot file with image/png Content-Type", async () => {
writeSlice(slicesRoot, "proof-slice", { "README.md": "---\n---\n" });
const dir = path.join(dogfoodRoot, "proof-slice-20260504");
@@ -1,11 +1,11 @@
// User Settings v0 — daemon HTTP route tests.
//
// Pins the load-bearing behaviors of /api/config:
// - GET /api/config returns all 12 keys with source + default
// - GET /api/config returns all settings keys with source + default
// - GET /api/config/:key returns one key
// - POST /api/config/:key sets the value, persists to disk
// - DELETE /api/config/:key reverts one key to default
// - POST /api/config/init-workspace creates 5 subdirs + READMEs
// - POST /api/config/init-workspace creates mission-aware workspace files
// - 503 when settingsStore is unavailable
// - 400 on unknown keys / missing body
@@ -20,7 +20,7 @@ import { configRoutes } from "../src/routes/config.js";
function clearEnv(): () => void {
const keys = [
"OPENRIG_PORT", "OPENRIG_FILES_ALLOWLIST", "OPENRIG_PROGRESS_SCAN_ROOTS",
"OPENRIG_WORKSPACE_ROOT",
"OPENRIG_WORKSPACE_ROOT", "OPENRIG_DOGFOOD_EVIDENCE_ROOT",
];
const saved: Record<string, string | undefined> = {};
for (const k of keys) {
@@ -67,15 +67,16 @@ describe("config routes (User Settings v0)", () => {
const res = await app.request("/api/config");
expect(res.status).toBe(200);
const body = await res.json() as { settings: Record<string, { value: unknown; source: string }> };
// 17 v0 keys + 2 Phase 4 (advisor/operator) + 5 Phase 5 (feed.subscriptions.*)
// 18 v0 keys + 2 Phase 4 (advisor/operator) + 5 Phase 5 (feed.subscriptions.*)
// + 2 V1 pre-release Item 1 (transcripts.lines / transcripts.poll_interval_seconds).
expect(Object.keys(body.settings).length).toBe(26);
expect(Object.keys(body.settings).length).toBe(27);
expect(body.settings["daemon.port"]?.source).toBe("default");
expect(body.settings["ui.preview.refresh_interval_seconds"]?.value).toBe(3);
expect(body.settings["ui.preview.max_pins"]?.value).toBe(4);
expect(body.settings["ui.preview.default_lines"]?.value).toBe(50);
expect(body.settings["recovery.auto_drive_provider_prompts"]?.value).toBe(false);
expect(body.settings["recovery.provider_auth_env_allowlist"]?.value).toBe("");
expect(String(body.settings["workspace.dogfood_evidence_root"]?.value)).toMatch(/dogfood-evidence$/);
});
it("GET /api/config/:key returns the resolved value", async () => {
@@ -88,6 +89,24 @@ describe("config routes (User Settings v0)", () => {
expect(body.source).toBe("file");
});
it("GET /api/config rebases persisted legacy workspace defaults", async () => {
store.set("workspace.root", "/custom/ws");
store.set("workspace.slices_root", "/custom/ws/slices");
store.set("workspace.steering_path", "/custom/ws/steering/STEERING.md");
const app = buildApp();
const res = await app.request("/api/config");
expect(res.status).toBe(200);
const body = await res.json() as { settings: Record<string, { value: unknown; source: string }> };
expect(body.settings["workspace.slices_root"]).toMatchObject({
value: "/custom/ws/missions",
source: "default",
});
expect(body.settings["workspace.steering_path"]).toMatchObject({
value: "/custom/ws/STEERING.md",
source: "default",
});
});
it("GET /api/config/:key 400s on unknown key", async () => {
const app = buildApp();
const res = await app.request("/api/config/workspace.bogus");
@@ -101,14 +120,14 @@ describe("config routes (User Settings v0)", () => {
const res = await app.request("/api/config/workspace.slices_root", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value: "/founder/slices" }),
body: JSON.stringify({ value: "/custom/slices" }),
});
expect(res.status).toBe(200);
const body = await res.json() as { ok: boolean; resolved: { value: string } };
expect(body.ok).toBe(true);
expect(body.resolved.value).toBe("/founder/slices");
expect(body.resolved.value).toBe("/custom/slices");
// Disk persisted
expect(JSON.parse(readFileSync(configPath, "utf-8")).workspace.slicesRoot).toBe("/founder/slices");
expect(JSON.parse(readFileSync(configPath, "utf-8")).workspace.slicesRoot).toBe("/custom/slices");
});
it("POST /api/config/:key 400s without value field", async () => {
@@ -130,7 +149,7 @@ describe("config routes (User Settings v0)", () => {
expect(body.resolved.source).toBe("default");
});
it("POST /api/config/init-workspace creates 5 subdirs + READMEs", async () => {
it("POST /api/config/init-workspace creates mission-aware workspace files", async () => {
const root = join(tmpDir, "workspace");
const app = buildApp();
const res = await app.request("/api/config/init-workspace", {
@@ -141,11 +160,19 @@ describe("config routes (User Settings v0)", () => {
expect(res.status).toBe(200);
const body = await res.json() as { root: string; subdirs: Array<{ name: string }>; files: Array<{ relPath: string }> };
expect(body.root).toBe(root);
expect(body.subdirs.map((s) => s.name)).toEqual([
"slices", "steering", "progress", "field-notes", "specs",
]);
expect(existsSync(join(root, "slices", "README.md"))).toBe(true);
expect(existsSync(join(root, "steering", "STEERING.md"))).toBe(true);
expect(body.subdirs.map((s) => s.name)).toEqual(expect.arrayContaining([
"missions",
"progress",
"field-notes",
"specs",
"dogfood-evidence",
"missions/idea-ledger/slices/capture-product-ideas",
"missions/handoff-loop/slices/verify-loop-evidence",
]));
expect(existsSync(join(root, "missions", "README.md"))).toBe(true);
expect(existsSync(join(root, "dogfood-evidence", "README.md"))).toBe(true);
expect(existsSync(join(root, "missions", "idea-ledger", "slices", "capture-product-ideas", "README.md"))).toBe(true);
expect(existsSync(join(root, "STEERING.md"))).toBe(true);
});
it("POST /api/config/init-workspace --dry-run does not write", async () => {
@@ -21,12 +21,12 @@ function clearEnv(): () => void {
"OPENRIG_TRANSCRIPTS_ENABLED", "OPENRIG_TRANSCRIPTS_PATH",
"OPENRIG_WORKSPACE_ROOT", "OPENRIG_WORKSPACE_SLICES_ROOT",
"OPENRIG_WORKSPACE_STEERING_PATH", "OPENRIG_WORKSPACE_FIELD_NOTES_ROOT",
"OPENRIG_WORKSPACE_SPECS_ROOT",
"OPENRIG_WORKSPACE_SPECS_ROOT", "OPENRIG_DOGFOOD_EVIDENCE_ROOT",
"OPENRIG_FILES_ALLOWLIST", "OPENRIG_PROGRESS_SCAN_ROOTS",
"OPENRIG_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS",
"OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST",
"RIGGED_PORT", "RIGGED_HOST", "RIGGED_FILES_ALLOWLIST",
"RIGGED_PROGRESS_SCAN_ROOTS",
"RIGGED_PROGRESS_SCAN_ROOTS", "RIGGED_DOGFOOD_EVIDENCE_ROOT",
"RIGGED_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS",
"RIGGED_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST",
];
@@ -66,6 +66,7 @@ describe("SettingsStore (User Settings v0)", () => {
"transcripts.lines", "transcripts.poll_interval_seconds",
"workspace.root", "workspace.slices_root", "workspace.steering_path",
"workspace.field_notes_root", "workspace.specs_root",
"workspace.dogfood_evidence_root",
"files.allowlist", "progress.scan_roots",
"ui.preview.refresh_interval_seconds", "ui.preview.max_pins", "ui.preview.default_lines",
"recovery.auto_drive_provider_prompts",
@@ -116,10 +117,11 @@ describe("SettingsStore (User Settings v0)", () => {
store.set("workspace.root", "/custom/ws");
const cfg = store.resolveConfig();
expect(cfg.workspaceRoot).toBe("/custom/ws");
expect(cfg.workspaceSlicesRoot).toBe("/custom/ws/slices");
expect(cfg.workspaceSteeringPath).toBe("/custom/ws/steering/STEERING.md");
expect(cfg.workspaceSlicesRoot).toBe("/custom/ws/missions");
expect(cfg.workspaceSteeringPath).toBe("/custom/ws/STEERING.md");
expect(cfg.workspaceFieldNotesRoot).toBe("/custom/ws/field-notes");
expect(cfg.workspaceSpecsRoot).toBe("/custom/ws/specs");
expect(cfg.workspaceDogfoodEvidenceRoot).toBe("/custom/ws/dogfood-evidence");
expect(cfg.filesAllowlistRaw).toBe("workspace:/custom/ws");
expect(cfg.progressScanRootsRaw).toBe("workspace:/custom/ws");
});
@@ -141,11 +143,41 @@ describe("SettingsStore (User Settings v0)", () => {
it("per-subdir override beats the workspace.root cascade", () => {
const store = new SettingsStore(configPath);
store.set("workspace.root", "/ws");
store.set("workspace.slices_root", "/founder/slices");
store.set("workspace.slices_root", "/custom/slices");
const cfg = store.resolveConfig();
expect(cfg.workspaceSlicesRoot).toBe("/founder/slices");
expect(cfg.workspaceSlicesRoot).toBe("/custom/slices");
// Other subdirs still cascade from workspace.root:
expect(cfg.workspaceFieldNotesRoot).toBe("/ws/field-notes");
expect(cfg.workspaceDogfoodEvidenceRoot).toBe("/ws/dogfood-evidence");
});
it("workspace.dogfood_evidence_root defaults from workspace.root and supports env override", () => {
const store = new SettingsStore(configPath);
store.set("workspace.root", "/custom/ws");
expect(store.resolveConfig().workspaceDogfoodEvidenceRoot).toBe("/custom/ws/dogfood-evidence");
process.env["OPENRIG_DOGFOOD_EVIDENCE_ROOT"] = "/proof/root";
try {
const resolved = store.resolveOne("workspace.dogfood_evidence_root");
expect(resolved).toMatchObject({ value: "/proof/root", source: "env" });
} finally {
delete process.env["OPENRIG_DOGFOOD_EVIDENCE_ROOT"];
}
});
it("treats persisted legacy workspace defaults as default-derived values", () => {
writeFileSync(configPath, JSON.stringify({
workspace: {
root: "/ws",
slicesRoot: "/ws/slices",
steeringPath: "/ws/steering/STEERING.md",
},
}));
const store = new SettingsStore(configPath);
const slices = store.resolveOne("workspace.slices_root");
const steering = store.resolveOne("workspace.steering_path");
expect(slices).toMatchObject({ value: "/ws/missions", source: "default" });
expect(steering).toMatchObject({ value: "/ws/STEERING.md", source: "default" });
});
it("UEP env-var graduation: OPENRIG_FILES_ALLOWLIST resolves files.allowlist", () => {
@@ -161,9 +193,9 @@ describe("SettingsStore (User Settings v0)", () => {
it("set + reset round-trips through the file format", () => {
const store = new SettingsStore(configPath);
store.set("workspace.slices_root", "/founder/slices");
store.set("workspace.slices_root", "/custom/slices");
expect(existsSync(configPath)).toBe(true);
expect(store.resolveOne("workspace.slices_root").value).toBe("/founder/slices");
expect(store.resolveOne("workspace.slices_root").value).toBe("/custom/slices");
store.reset("workspace.slices_root");
expect(store.resolveOne("workspace.slices_root").source).toBe("default");
@@ -19,7 +19,7 @@ import { SubSpecPreview, type SubSpecPreviewData } from "./drawer-viewers/SubSpe
import type { ActivityEvent } from "../hooks/useActivityFeed.js";
// V1 polish slice Phase 5.1 P5.1-1 + DRIFT P5.1-D2: 'seat-detail' kind
// RETIRED at V1 polish per founder direction. Graph node click + tree
// RETIRED at V1 polish. Graph node click + tree
// click + table row click all navigate to /topology/seat/$rigId/$logicalId
// center page (canonical agent-detail surface = LiveNodeDetails).
// SeatDetailViewer wrapper component DELETED; SeatDetailTrigger primitive
@@ -86,17 +86,29 @@ export function SharedDetailDrawer({
})();
return (
<VellumSheet
edge="right"
width="wide"
onClose={onClose}
testId="shared-detail-drawer"
// top-14 starts below the universal top bar (h-14, fixed at top); bottom-0
// anchors to viewport bottom so the drawer fills the remaining height.
// Bounce-fix #3 width-coupling: 38rem (lg:w-[38rem]) per VellumSheet wide preset.
className="fixed top-14 right-0 bottom-0 z-30"
<div
data-testid="shared-detail-drawer-layer"
className="fixed top-14 right-0 bottom-0 left-0 z-30 pointer-events-none"
>
{inner}
</VellumSheet>
<button
type="button"
aria-label="Close drawer"
data-testid="shared-detail-drawer-outside"
className="absolute inset-0 cursor-default pointer-events-auto"
onPointerDown={onClose}
/>
<VellumSheet
edge="right"
width="wide"
onClose={onClose}
testId="shared-detail-drawer"
// top-14 starts below the universal top bar (h-14, fixed at top); bottom-0
// anchors to viewport bottom so the drawer fills the remaining height.
// Bounce-fix #3 width-coupling: 38rem (lg:w-[38rem]) per VellumSheet wide preset.
className="absolute top-0 right-0 bottom-0 z-10 pointer-events-auto"
>
{inner}
</VellumSheet>
</div>
);
}
@@ -1 +1 @@
export const dashboardCardSurfaceClass = "h-full bg-white/30 hover:bg-white/40 hover:hard-shadow-hover";
export const dashboardCardSurfaceClass = "h-full bg-white/40 backdrop-blur-[8px] hover:bg-white/50 hover:hard-shadow-hover";
+196 -9
View File
@@ -16,12 +16,30 @@ import { cn } from "../../lib/utils.js";
import { SectionHeader } from "../ui/section-header.js";
import { EmptyState } from "../ui/empty-state.js";
import { useActivityFeed } from "../../hooks/useActivityFeed.js";
import { classifyFeed, type FeedCardKind } from "../../lib/feed-classifier.js";
import {
classifyFeed,
type FeedCard as FeedCardModel,
type FeedCardKind,
} from "../../lib/feed-classifier.js";
import {
useQueueItemMap,
useSliceDetails,
useSlices,
type QueueItemDetail,
type SliceDetail,
type SliceListEntry,
} from "../../hooks/useSlices.js";
import {
useFeedSubscriptions,
isCardKindSubscribed,
} from "../../hooks/useFeedSubscriptions.js";
import { FeedCard } from "./FeedCard.js";
import type { FeedActionOutcome, FeedProofPreview } from "./FeedCard.js";
import {
useMissionControlAudit,
type AuditEntry,
} from "../mission-control/hooks/useMissionControlAudit.js";
import type { MissionControlVerb } from "../mission-control/hooks/useMissionControlAction.js";
const LENS_CHIPS: Array<{ id: FeedCardKind | "all"; label: string }> = [
{ id: "all", label: "All" },
@@ -34,21 +52,177 @@ const LENS_CHIPS: Array<{ id: FeedCardKind | "all"; label: string }> = [
const HISTORY_LIMIT = 50; // per for-you-feed.md L182
function asString(v: unknown): string | undefined {
return typeof v === "string" && v.length > 0 ? v : undefined;
}
function asStringArray(v: unknown): string[] {
return Array.isArray(v) ? v.filter((item): item is string => typeof item === "string") : [];
}
function qitemIdForCard(card: FeedCardModel): string | null {
const payload = (card.source.payload ?? {}) as Record<string, unknown>;
return asString(payload.qitemId) ?? asString(payload.qitem_id) ?? null;
}
const FEED_ACTION_OUTCOME_VERBS = new Set<MissionControlVerb>([
"approve",
"deny",
"route",
"handoff",
"hold",
"drop",
]);
function stringField(record: Record<string, unknown> | null, key: string): string | null {
const value = record?.[key];
return typeof value === "string" && value.length > 0 ? value : null;
}
function actionOutcomeFromAudit(row: AuditEntry): FeedActionOutcome | null {
if (!row.qitemId) return null;
const verb = row.actionVerb as MissionControlVerb;
if (!FEED_ACTION_OUTCOME_VERBS.has(verb)) return null;
const destinationSession =
stringField(row.afterState, "handedOffTo") ??
(stringField(row.afterState, "closureReason") === "handed_off_to"
? stringField(row.afterState, "closureTarget")
: null);
return {
verb,
actorSession: row.actorSession,
actedAt: row.actedAt,
state: stringField(row.afterState, "state"),
destinationSession,
reason: row.reason ?? stringField(row.afterState, "closureTarget"),
};
}
function actionOutcomeMap(rows: AuditEntry[]): Map<string, FeedActionOutcome> {
const byQitemId = new Map<string, FeedActionOutcome>();
for (const row of rows) {
if (!row.qitemId || byQitemId.has(row.qitemId)) continue;
const outcome = actionOutcomeFromAudit(row);
if (outcome) byQitemId.set(row.qitemId, outcome);
}
return byQitemId;
}
function queueTags(card: FeedCardModel, item: QueueItemDetail | undefined): string[] {
const payload = (card.source.payload ?? {}) as Record<string, unknown>;
return [
...asStringArray(payload.tags),
...(item?.tags ?? []),
];
}
function hydratedCardKind(
card: FeedCardModel,
item: QueueItemDetail | undefined,
outcome: FeedActionOutcome | undefined,
): FeedCardKind {
if (outcome && (card.kind === "action-required" || card.kind === "approval")) {
return "approval";
}
if (!item) return card.kind;
const tags = queueTags(card, item).join(" ").toLowerCase();
const state = item.state.toLowerCase();
const destination = item.destinationSession.toLowerCase();
const body = item.body.toLowerCase();
if (
tags.includes("approval") ||
tags.includes("ratify") ||
state.includes("approval") ||
body.includes("approval requested")
) {
return "approval";
}
if (state === "done" || state === "closed" || state === "completed") return "shipped";
if (destination === "human@host" || destination.startsWith("human-")) return "action-required";
return card.kind;
}
function sliceForCard(
card: FeedCardModel,
item: QueueItemDetail | undefined,
slices: SliceListEntry[],
): string | null {
const tags = new Set(queueTags(card, item));
for (const slice of slices) {
if (tags.has(slice.name)) return slice.name;
}
const haystack = [
card.title,
card.body,
item?.body,
...(item?.tags ?? []),
].filter((value): value is string => Boolean(value)).join("\n");
for (const slice of slices) {
if (haystack.includes(slice.name)) return slice.name;
}
return null;
}
function proofPreviewForSlice(detail: SliceDetail | undefined): FeedProofPreview | null {
const packet = detail?.tests.proofPackets.find((candidate) => candidate.screenshots.length > 0)
?? detail?.tests.proofPackets[0];
if (!detail || !packet || packet.screenshots.length === 0) return null;
return {
sliceName: detail.name,
displayName: detail.displayName || detail.name,
passFailBadge: packet.passFailBadge,
screenshots: packet.screenshots,
};
}
export function Feed() {
const { events } = useActivityFeed();
const [lens, setLens] = useState<FeedCardKind | "all">("all");
const subs = useFeedSubscriptions();
const rawCards = useMemo(() => classifyFeed(events).slice(0, HISTORY_LIMIT), [events]);
const qitemIds = useMemo(
() => rawCards.map(qitemIdForCard).filter((id): id is string => Boolean(id)),
[rawCards],
);
const queueItems = useQueueItemMap(qitemIds);
const actionAudit = useMissionControlAudit({ limit: 200 });
const actionOutcomes = useMemo(
() => actionOutcomeMap(actionAudit.data?.rows ?? []),
[actionAudit.data?.rows],
);
const cards = useMemo(() => {
const classified = classifyFeed(events);
const hydrated = rawCards.map((card) => {
const qitemId = qitemIdForCard(card);
const item = qitemId ? queueItems.itemsById.get(qitemId) : undefined;
const outcome = qitemId ? actionOutcomes.get(qitemId) : undefined;
const kind = hydratedCardKind(card, item, outcome);
return kind === card.kind ? card : { ...card, kind };
});
// Filter by subscription state FIRST so the feed honors operator
// configuration, then apply the transient lens filter on top.
const subscribed = classified.filter((c) =>
const subscribed = hydrated.filter((c) =>
isCardKindSubscribed(c.kind, subs.state),
);
const sliced = subscribed.slice(0, HISTORY_LIMIT);
return lens === "all" ? sliced : sliced.filter((c) => c.kind === lens);
}, [events, lens, subs.state]);
return lens === "all" ? subscribed : subscribed.filter((c) => c.kind === lens);
}, [rawCards, lens, queueItems.itemsById, actionOutcomes, subs.state]);
const slicesQuery = useSlices("all");
const sliceRows = useMemo(() => {
if (!slicesQuery.data || "unavailable" in slicesQuery.data) return [];
return slicesQuery.data.slices;
}, [slicesQuery.data]);
const proofSliceNames = useMemo(() => {
const names = new Set<string>();
for (const card of cards) {
if (card.kind !== "shipped") continue;
const qitemId = qitemIdForCard(card);
const item = qitemId ? queueItems.itemsById.get(qitemId) : undefined;
const sliceName = sliceForCard(card, item, sliceRows);
if (sliceName) names.add(sliceName);
}
return Array.from(names);
}, [cards, queueItems.itemsById, sliceRows]);
const proofSlices = useSliceDetails(proofSliceNames);
return (
<div data-testid="for-you-feed" className="mx-auto w-full max-w-[720px] px-6 py-8">
@@ -95,9 +269,22 @@ export function Feed() {
/>
) : (
<div data-testid="for-you-feed-cards">
{cards.map((c) => (
<FeedCard key={c.id} card={c} />
))}
{cards.map((c) => {
const qitemId = qitemIdForCard(c);
const queueItem = qitemId ? queueItems.itemsById.get(qitemId) : undefined;
const sliceName = c.kind === "shipped" ? sliceForCard(c, queueItem, sliceRows) : null;
const proofPreview = sliceName ? proofPreviewForSlice(proofSlices.itemsByName.get(sliceName)) : null;
const actionOutcome = qitemId ? actionOutcomes.get(qitemId) ?? null : null;
return (
<FeedCard
key={c.id}
card={c}
queueItem={queueItem}
proofPreview={proofPreview}
actionOutcome={actionOutcome}
/>
);
})}
</div>
)}
</div>
+261 -33
View File
@@ -1,25 +1,23 @@
// V1 attempt-3 Phase 3 — Feed card — single card-shape that renders
// each of the 5 kinds with kind-specific tone (per for-you-feed.md L82+).
// Five kinds: ACTION REQUIRED / APPROVAL / SHIPPED / PROGRESS / OBSERVATION.
//
// V1 attempt-3 Phase 5 P5-1: "show context" affordance per content-drawer.md
// L42 — when card.source has a qitem_id, render an inline QueueItemTrigger
// that opens QueueItemViewer in the drawer with the underlying qitem.
import { useState } from "react";
import { VellumCard } from "../ui/vellum-card.js";
import { SectionHeader } from "../ui/section-header.js";
import { AuthorAgentTag } from "./AuthorAgentTag.js";
import { QueueItemTrigger } from "../drawer-triggers/QueueItemTrigger.js";
import type { QueueItemViewerData } from "../drawer-viewers/QueueItemViewer.js";
import type { QueueItemDetail } from "../../hooks/useSlices.js";
import type { FeedCard as FeedCardModel, FeedCardKind } from "../../lib/feed-classifier.js";
const KIND_LABEL: Record<FeedCardKind, string> = {
"action-required": "ACTION REQUIRED",
approval: "APPROVAL",
shipped: "SHIPPED",
progress: "PROGRESS",
observation: "OBSERVATION",
};
import { VerbActions } from "../mission-control/components/VerbActions.js";
import {
DateChip,
FlowChips,
ProjectPill,
ProofPacketHeader,
ProofThumbnailGrid,
TagPill,
type ProjectToken,
} from "../project/ProjectMetaPrimitives.js";
import { ProofImageViewer } from "../project/ProofImageViewer.js";
import type { MissionControlVerb } from "../mission-control/hooks/useMissionControlAction.js";
const KIND_ACCENT: Record<FeedCardKind, string> = {
"action-required": "border-l-4 border-l-tertiary",
@@ -37,6 +35,14 @@ const KIND_TESTID: Record<FeedCardKind, string> = {
observation: "feed-card-observation",
};
const KIND_TOKEN: Record<FeedCardKind, ProjectToken> = {
"action-required": { label: "Action required", tone: "danger" },
approval: { label: "Approval", tone: "warning" },
shipped: { label: "Shipped", tone: "success" },
progress: { label: "Progress", tone: "info" },
observation: { label: "Observation", tone: "neutral" },
};
function asString(v: unknown): string | undefined {
return typeof v === "string" && v.length > 0 ? v : undefined;
}
@@ -51,8 +57,15 @@ function extractQitemViewerData(card: FeedCardModel): QueueItemViewerData | null
: undefined;
return {
qitemId,
source: asString(payload.source_session),
destination: asString(payload.destination) ?? asString(payload.destination_session),
source:
asString(payload.source_session) ??
asString(payload.sourceSession) ??
asString(payload.fromSession),
destination:
asString(payload.destination) ??
asString(payload.destination_session) ??
asString(payload.destinationSession) ??
asString(payload.toSession),
state: asString(payload.state),
tags,
createdAt: card.createdAt,
@@ -60,27 +73,238 @@ function extractQitemViewerData(card: FeedCardModel): QueueItemViewerData | null
};
}
export function FeedCard({ card }: { card: FeedCardModel }) {
const qitemViewerData = extractQitemViewerData(card);
export interface FeedProofPreview {
sliceName: string;
displayName: string;
passFailBadge: string;
screenshots: string[];
}
function qitemViewerDataFromItem(card: FeedCardModel, item: QueueItemDetail | undefined): QueueItemViewerData | null {
const fallback = extractQitemViewerData(card);
if (!item) return fallback;
return {
qitemId: item.qitemId,
source: item.sourceSession,
destination: item.destinationSession,
state: item.state,
tags: item.tags ?? undefined,
createdAt: item.tsCreated,
body: item.body || fallback?.body || card.body,
};
}
function compactQueueBody(body: string | undefined): string | undefined {
if (!body) return undefined;
const trimmed = body.trim();
if (trimmed.length <= 520) return trimmed;
return `${trimmed.slice(0, 520).trimEnd()}\n...`;
}
export interface FeedActionOutcome {
verb: MissionControlVerb;
actorSession: string;
actedAt: string;
state?: string | null;
destinationSession?: string | null;
reason?: string | null;
}
const TERMINAL_QUEUE_STATES = new Set([
"done",
"closed",
"completed",
"shipped",
"canceled",
"cancelled",
"denied",
"failed",
"handed-off",
]);
function isTerminalQueueItem(item: QueueItemDetail | undefined): boolean {
if (!item) return false;
return TERMINAL_QUEUE_STATES.has(item.state.toLowerCase());
}
function isActionableCard(
kind: FeedCardKind,
item: QueueItemDetail | undefined,
outcome: FeedActionOutcome | null,
): boolean {
if (kind !== "action-required" && kind !== "approval") return false;
if (outcome) return false;
if (isTerminalQueueItem(item)) return false;
return true;
}
function fallbackOutcomeFromQueueItem(
kind: FeedCardKind,
item: QueueItemDetail | undefined,
): FeedActionOutcome | null {
if (kind !== "action-required" && kind !== "approval") return null;
if (!item || !isTerminalQueueItem(item)) return null;
const reason = item.closureReason ?? null;
const destinationSession = item.handedOffTo ?? item.closureTarget ?? null;
const verb: MissionControlVerb =
reason === "denied" ? "deny"
: reason === "handed_off_to" ? "route"
: reason === "canceled" ? "drop"
: "approve";
return {
verb,
actorSession: item.destinationSession,
actedAt: item.tsUpdated,
state: item.state,
destinationSession,
reason: reason === "no-follow-on" ? null : item.closureTarget ?? reason,
};
}
function outcomeToken(outcome: FeedActionOutcome): ProjectToken {
switch (outcome.verb) {
case "approve":
return { label: "Approved", tone: "success" };
case "deny":
return { label: "Denied", tone: "danger" };
case "route":
case "handoff":
return { label: "Routed", tone: "info" };
case "hold":
return { label: "Held", tone: "warning" };
case "drop":
return { label: "Dropped", tone: "neutral" };
case "annotate":
return { label: "Annotated", tone: "neutral" };
}
}
function outcomeSentence(outcome: FeedActionOutcome): string {
switch (outcome.verb) {
case "approve":
return `Approved by ${outcome.actorSession}.`;
case "deny":
return `Denied by ${outcome.actorSession}${outcome.reason ? `: ${outcome.reason}.` : "."}`;
case "route":
case "handoff":
return outcome.destinationSession
? `Routed by ${outcome.actorSession} to ${outcome.destinationSession}.`
: `Routed by ${outcome.actorSession}.`;
case "hold":
return `Held by ${outcome.actorSession}${outcome.reason ? `: ${outcome.reason}.` : "."}`;
case "drop":
return `Dropped by ${outcome.actorSession}${outcome.reason ? `: ${outcome.reason}.` : "."}`;
case "annotate":
return `Annotated by ${outcome.actorSession}.`;
}
}
function ActionOutcomePanel({ outcome }: { outcome: FeedActionOutcome }) {
return (
<div
data-testid="feed-card-action-outcome"
className="mt-3 border border-outline-variant bg-white/35 p-2 backdrop-blur-sm"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<ProjectPill token={outcomeToken(outcome)} />
<DateChip value={outcome.actedAt} />
</div>
<p className="mt-2 font-mono text-[11px] leading-relaxed text-stone-700">
{outcomeSentence(outcome)}
</p>
{(outcome.verb === "route" || outcome.verb === "handoff") && outcome.destinationSession ? (
<div className="mt-2">
<FlowChips source={outcome.actorSession} destination={outcome.destinationSession} muted />
</div>
) : null}
</div>
);
}
export function FeedCard({
card,
queueItem,
proofPreview,
actionOutcome,
}: {
card: FeedCardModel;
queueItem?: QueueItemDetail;
proofPreview?: FeedProofPreview | null;
actionOutcome?: FeedActionOutcome | null;
}) {
const [selectedScreenshot, setSelectedScreenshot] = useState<string | null>(null);
const qitemViewerData = qitemViewerDataFromItem(card, queueItem);
const body = compactQueueBody(queueItem?.body || card.body);
const tags = queueItem?.tags ?? qitemViewerData?.tags ?? [];
const source = qitemViewerData?.source ?? card.authorSession;
const destination = qitemViewerData?.destination;
const actorSession = destination?.startsWith("human") ? destination : "human@host";
const renderedOutcome = actionOutcome ?? fallbackOutcomeFromQueueItem(card.kind, queueItem);
return (
<VellumCard
as="article"
testId={KIND_TESTID[card.kind]}
accentClass={KIND_ACCENT[card.kind]}
className="mb-3"
className="mb-3 bg-white/50 backdrop-blur-sm"
>
<div className="px-4 py-3">
<SectionHeader tone={card.kind === "action-required" ? "alert" : "muted"}>
{KIND_LABEL[card.kind]}
</SectionHeader>
<h3 className="mt-1 font-mono text-sm text-stone-900 truncate">
{card.title}
</h3>
{card.body ? (
<p className="mt-2 font-mono text-xs text-on-surface-variant whitespace-pre-line">
{card.body}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-2">
<ProjectPill token={KIND_TOKEN[card.kind]} />
<h3 className="font-mono text-sm text-stone-900 truncate">
{card.title}
</h3>
</div>
<DateChip value={card.createdAt} />
</div>
{body ? (
<p className="mt-3 font-mono text-xs leading-relaxed text-on-surface-variant whitespace-pre-line">
{body}
</p>
) : null}
<div className="mt-3">
<FlowChips source={source} destination={destination} muted />
</div>
{tags.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-1.5">
{tags.slice(0, 6).map((tag) => <TagPill key={tag} tag={tag} />)}
</div>
) : null}
{proofPreview && proofPreview.screenshots.length > 0 ? (
<div
data-testid={`feed-card-proof-preview-${card.id}`}
className="mt-3 border border-outline-variant bg-white/35 p-2 backdrop-blur-sm"
>
<ProofPacketHeader
title={`Proof packet · ${proofPreview.displayName}`}
badge={proofPreview.passFailBadge}
/>
<div className="mt-2">
<ProofThumbnailGrid
sliceName={proofPreview.sliceName}
screenshots={proofPreview.screenshots}
onSelect={setSelectedScreenshot}
testIdPrefix="feed-card-proof-screenshot"
/>
</div>
</div>
) : null}
{renderedOutcome ? <ActionOutcomePanel outcome={renderedOutcome} /> : null}
{qitemViewerData && isActionableCard(card.kind, queueItem, renderedOutcome) ? (
<div
data-testid={`feed-card-actions-${card.id}`}
className="mt-3 border border-outline-variant bg-white/35 p-2 backdrop-blur-sm"
>
<div className="mb-2 font-mono text-[9px] uppercase tracking-[0.12em] text-stone-600">
Actions
</div>
<VerbActions
qitemId={qitemViewerData.qitemId}
actorSession={actorSession}
enabledVerbs={["approve", "deny", "route"]}
/>
</div>
) : null}
<div className="mt-3 flex items-center justify-between gap-3 font-mono text-[10px] text-on-surface-variant">
<div className="flex items-center gap-2 min-w-0">
{card.authorSession ? (
@@ -98,11 +322,15 @@ export function FeedCard({ card }: { card: FeedCardModel }) {
</QueueItemTrigger>
) : null}
</div>
<time dateTime={card.createdAt} className="shrink-0">
{card.createdAt ? card.createdAt.slice(11, 19) : ""}
</time>
</div>
</div>
{proofPreview ? (
<ProofImageViewer
sliceName={proofPreview.sliceName}
relPath={selectedScreenshot}
onClose={() => setSelectedScreenshot(null)}
/>
) : null}
</VellumCard>
);
}
@@ -0,0 +1,324 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import {
ArrowRight,
Bot,
CalendarDays,
CheckCircle2,
CircleAlert,
CirclePlus,
Clock,
FileImage,
GitBranch,
History,
MessageSquareText,
PackageCheck,
Route,
Send,
UserRound,
} from "lucide-react";
import { cn } from "../../lib/utils.js";
import { VellumCard } from "../ui/vellum-card.js";
type Tone = "neutral" | "info" | "success" | "warning" | "danger";
interface Token {
label: string;
tone: Tone;
icon?: LucideIcon;
}
const toneClass: Record<Tone, string> = {
neutral: "border-stone-300 bg-white/65 text-stone-700",
info: "border-sky-300 bg-sky-50/70 text-sky-800",
success: "border-emerald-300 bg-emerald-50/70 text-emerald-800",
warning: "border-amber-300 bg-amber-50/80 text-amber-800",
danger: "border-rose-300 bg-rose-50/75 text-rose-800",
};
const eventTokens: Token[] = [
{ label: "Queue created", tone: "info", icon: CirclePlus },
{ label: "Handoff", tone: "neutral", icon: Send },
{ label: "Claimed", tone: "warning", icon: Clock },
{ label: "Completed", tone: "success", icon: CheckCircle2 },
{ label: "Human action", tone: "danger", icon: CircleAlert },
{ label: "Shipped", tone: "success", icon: PackageCheck },
];
const tagTokens: Token[] = [
{ label: "idea-ledger", tone: "neutral" },
{ label: "cycle-4", tone: "info" },
{ label: "proof", tone: "success" },
{ label: "urgent", tone: "danger" },
{ label: "human-review", tone: "warning" },
];
function Pill({ token, compact = false }: { token: Token; compact?: boolean }) {
const Icon = token.icon;
return (
<span
className={cn(
"inline-flex items-center gap-1 border font-mono uppercase tracking-[0.10em]",
compact ? "px-1.5 py-0.5 text-[8px]" : "px-2 py-1 text-[9px]",
toneClass[token.tone],
)}
>
{Icon ? <Icon className={compact ? "h-2.5 w-2.5" : "h-3 w-3"} strokeWidth={1.6} /> : null}
{token.label}
</span>
);
}
function ActorChip({
kind,
label,
muted,
}: {
kind: "human" | "agent";
label: string;
muted?: boolean;
}) {
const Icon = kind === "human" ? UserRound : Bot;
return (
<span
className={cn(
"inline-flex min-w-0 items-center gap-1 border px-1.5 py-0.5 font-mono text-[9px]",
muted ? "border-stone-200 bg-white/45 text-stone-600" : "border-stone-300 bg-white/45 text-stone-800",
)}
>
<Icon className="h-3 w-3 shrink-0" strokeWidth={1.5} />
<span className="truncate">{label}</span>
</span>
);
}
function DateChip({ label }: { label: string }) {
return (
<span className="inline-flex items-center gap-1 border border-stone-200 bg-white/55 px-1.5 py-0.5 font-mono text-[9px] text-stone-600">
<CalendarDays className="h-3 w-3" strokeWidth={1.5} />
{label}
</span>
);
}
function FlowStrip() {
return (
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<ActorChip kind="agent" label="orch.lead@openrig-velocity" />
<ArrowRight className="h-3.5 w-3.5 text-stone-400" strokeWidth={1.4} />
<ActorChip kind="agent" label="driver@openrig-velocity" />
<ArrowRight className="h-3.5 w-3.5 text-stone-400" strokeWidth={1.4} />
<ActorChip kind="human" label="human@host" />
</div>
);
}
function ProofStrip() {
return (
<div className="grid grid-cols-3 gap-2">
{["for-you-human-and-shipped.png", "project-overview-missions.png", "project-triage-slice.png"].map((name) => (
<div key={name} className="border border-stone-200 bg-stone-100/80">
<div className="flex aspect-[4/3] items-center justify-center bg-white/65 text-stone-400">
<FileImage className="h-7 w-7" strokeWidth={1.2} />
</div>
<div className="truncate border-t border-stone-200 px-1 py-0.5 font-mono text-[8px] text-stone-600">
{name}
</div>
</div>
))}
</div>
);
}
function CandidateCard({
label,
description,
children,
}: {
label: string;
description: string;
children: ReactNode;
}) {
return (
<VellumCard
as="section"
className="bg-white/65 backdrop-blur-sm"
header={<span className="uppercase tracking-[0.16em]">{label}</span>}
>
<div className="space-y-4 p-4">
<p className="font-mono text-[10px] leading-relaxed text-stone-700">{description}</p>
{children}
</div>
</VellumCard>
);
}
function FeedTreatment({ dense = false }: { dense?: boolean }) {
return (
<article className={cn("border border-outline-variant bg-white/60 backdrop-blur-sm hard-shadow", dense ? "p-3" : "p-4")}>
<div className="flex items-start justify-between gap-3">
<div className="space-y-2">
<Pill token={{ label: "Action required", tone: "danger", icon: CircleAlert }} compact={dense} />
<h3 className="font-mono text-sm text-stone-950">Review demo-seed triage proof packet</h3>
</div>
<DateChip label="Today 4:18 PM" />
</div>
<p className="mt-3 whitespace-pre-line font-mono text-[11px] leading-relaxed text-stone-700">
Review the proof packet screenshots, confirm the shipped slice is ready, then approve or route back with notes.
</p>
<div className="mt-3">
<FlowStrip />
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
{tagTokens.slice(0, 4).map((token) => <Pill key={token.label} token={token} compact />)}
</div>
</article>
);
}
function StoryTreatment() {
return (
<article className="relative border border-outline-variant bg-white/60 p-4 hard-shadow backdrop-blur-sm">
<div className="absolute left-5 top-12 bottom-5 w-px bg-stone-300" />
<div className="relative space-y-5 pl-8">
{[
{ token: eventTokens[5]!, title: "Slice shipped with proof packet", body: "The triage slice closed with screenshots, queue context, and verification notes attached." },
{ token: eventTokens[3]!, title: "Implementation completed", body: "Driver finished the UI pass and handed verification to guard and QA." },
{ token: eventTokens[1]!, title: "Work routed for review", body: "The queue item moved from driver to guard with targeted checks." },
].map((step) => {
const Icon = step.token.icon ?? History;
return (
<div key={step.title} className="relative">
<span className="absolute -left-[38px] top-0 flex h-5 w-5 items-center justify-center border border-stone-300 bg-white text-stone-700">
<Icon className="h-3 w-3" strokeWidth={1.5} />
</span>
<div className="flex flex-wrap items-center gap-2">
<Pill token={step.token} compact />
<DateChip label="12 min ago" />
</div>
<h4 className="mt-2 font-mono text-[12px] text-stone-950">{step.title}</h4>
<p className="mt-1 font-mono text-[10px] leading-relaxed text-stone-700">{step.body}</p>
</div>
);
})}
</div>
</article>
);
}
function QueueTreatment() {
return (
<div className="divide-y divide-outline-variant border border-outline-variant bg-white/55 backdrop-blur-sm">
{[
{ token: eventTokens[4]!, title: "Approval needed", body: "Review proof screenshots and approve release-readiness.", actor: "human@host" },
{ token: eventTokens[2]!, title: "Claimed by QA", body: "VM verification is in progress with current worktree source.", actor: "velocity-qa" },
{ token: eventTokens[1]!, title: "Route to guard", body: "Run narrow advisory on UI-only diff and source scans.", actor: "redo3-guard-3" },
].map((item) => (
<div key={item.title} className="grid gap-3 p-3 sm:grid-cols-[auto_1fr_auto]">
<Pill token={item.token} compact />
<div className="min-w-0">
<div className="font-mono text-[12px] text-stone-950">{item.title}</div>
<div className="mt-1 font-mono text-[10px] leading-relaxed text-stone-700">{item.body}</div>
</div>
<ActorChip kind={item.actor.includes("human") ? "human" : "agent"} label={item.actor} muted />
</div>
))}
</div>
);
}
export function ProjectGraphicsPreview() {
return (
<div className="paper-grid min-h-full p-8">
<div className="mx-auto max-w-7xl space-y-8">
<header className="border border-outline-variant bg-white/60 p-4 font-mono backdrop-blur-sm hard-shadow">
<div className="text-[10px] uppercase tracking-[0.18em] text-stone-600">
Project graphics package preview
</div>
<h1 className="mt-2 text-xl uppercase tracking-[0.14em] text-stone-950">
Queue, story, and proof card language
</h1>
<p className="mt-2 max-w-3xl text-[11px] leading-relaxed text-stone-700">
Hidden preview route for choosing metadata badges, action chips, flow arrows, and proof-card density before
applying the system to For You, Story, Queue, and scope rollups.
</p>
</header>
<section className="grid gap-4 lg:grid-cols-3">
<CandidateCard
label="A / Compact Ledger"
description="Small pills and restrained icons. Best when density matters and queue cards need to scan like a work ledger."
>
<FeedTreatment dense />
<QueueTreatment />
</CandidateCard>
<CandidateCard
label="B / Narrative Rail"
description="Story-first layout with event icons on a vertical rail. Best for showing how work moved from idea to proof."
>
<StoryTreatment />
<ProofStrip />
</CandidateCard>
<CandidateCard
label="C / Action Board"
description="Bolder cards with explicit status, actors, and proof thumbnails. Best for For You and approval surfaces."
>
<FeedTreatment />
<div className="flex flex-wrap gap-1.5">
{eventTokens.map((token) => <Pill key={token.label} token={token} />)}
</div>
</CandidateCard>
</section>
<section className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<VellumCard as="section" className="bg-white/65 backdrop-blur-sm" header="EVENT BADGE VOCABULARY">
<div className="grid gap-3 p-4 sm:grid-cols-2 lg:grid-cols-3">
{eventTokens.map((token) => (
<div key={token.label} className="border border-outline-variant bg-white/55 p-3">
<Pill token={token} />
<div className="mt-2 font-mono text-[10px] leading-relaxed text-stone-700">
{token.label === "Queue created" ? "A work item entered the system." : null}
{token.label === "Handoff" ? "Ownership moved from one actor to another." : null}
{token.label === "Claimed" ? "An actor is actively working the item." : null}
{token.label === "Completed" ? "The assigned work item closed." : null}
{token.label === "Human action" ? "A person needs to approve, deny, or route." : null}
{token.label === "Shipped" ? "The slice closed with evidence attached." : null}
</div>
</div>
))}
</div>
</VellumCard>
<VellumCard as="section" className="bg-white/65 backdrop-blur-sm" header="SUPPORTING CHIPS">
<div className="space-y-4 p-4">
<div>
<div className="mb-2 font-mono text-[9px] uppercase tracking-[0.14em] text-stone-600">Tags</div>
<div className="flex flex-wrap gap-1.5">
{tagTokens.map((token) => <Pill key={token.label} token={token} />)}
</div>
</div>
<div>
<div className="mb-2 font-mono text-[9px] uppercase tracking-[0.14em] text-stone-600">Actors</div>
<div className="flex flex-wrap gap-1.5">
<ActorChip kind="human" label="human@host" />
<ActorChip kind="agent" label="driver@openrig-velocity" />
<ActorChip kind="agent" label="guard@openrig-velocity" muted />
</div>
</div>
<div>
<div className="mb-2 font-mono text-[9px] uppercase tracking-[0.14em] text-stone-600">Scope</div>
<div className="flex flex-wrap gap-1.5">
<Pill token={{ label: "Workspace", tone: "neutral", icon: Route }} />
<Pill token={{ label: "Mission", tone: "info", icon: GitBranch }} />
<Pill token={{ label: "Slice", tone: "success", icon: MessageSquareText }} />
</div>
</div>
</div>
</VellumCard>
</section>
</div>
</div>
);
}
@@ -63,10 +63,16 @@ export function useMissionControlAction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: postAction,
onSuccess: () => {
onSuccess: (result, input) => {
// Invalidate all Mission Control views so the operator sees the
// post-action state without a manual refresh.
queryClient.invalidateQueries({ queryKey: ["mission-control", "view"] });
queryClient.invalidateQueries({ queryKey: ["mission-control", "audit"] });
queryClient.invalidateQueries({ queryKey: ["queue", "item", input.qitemId] });
if (result.createdQitemId) {
queryClient.invalidateQueries({ queryKey: ["queue", "item", result.createdQitemId] });
}
queryClient.invalidateQueries({ queryKey: ["slices"] });
},
});
}
@@ -113,10 +113,10 @@ export function SessionPreviewPane({
data-testid={`${testIdPrefix}-content`}
onScroll={handleScroll}
className={cn(
"font-mono overflow-y-auto whitespace-pre-wrap break-all",
"font-mono",
compactTerminal
? "max-h-72 bg-transparent px-1 py-0.5 text-[10px] leading-[1.25] text-stone-50"
: "max-h-32 bg-stone-50 px-2 py-1 text-[9px] text-stone-800",
? "max-h-72 overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words bg-transparent px-1 py-0.5 text-[8px] leading-[1.2] text-stone-50"
: "max-h-32 overflow-y-auto whitespace-pre-wrap break-all bg-stone-50 px-2 py-1 text-[9px] text-stone-800",
)}
>
{preview.data.content || "(empty pane)"}
@@ -0,0 +1,283 @@
import type { LucideIcon } from "lucide-react";
import {
ArrowRight,
Bot,
CalendarDays,
CheckCircle2,
CircleAlert,
CirclePlus,
Clock,
FileImage,
GitBranch,
History,
MessageSquareText,
PackageCheck,
Route,
Send,
UserRound,
} from "lucide-react";
import { cn } from "../../lib/utils.js";
import { proofAssetUrl } from "../../hooks/useSlices.js";
export type ProjectMetaTone = "neutral" | "info" | "success" | "warning" | "danger";
export interface ProjectToken {
label: string;
tone: ProjectMetaTone;
icon?: LucideIcon;
}
const toneClass: Record<ProjectMetaTone, string> = {
neutral: "border-stone-300 bg-white/55 text-stone-700",
info: "border-sky-300 bg-sky-50/75 text-sky-800",
success: "border-emerald-300 bg-emerald-50/75 text-emerald-800",
warning: "border-amber-300 bg-amber-50/80 text-amber-800",
danger: "border-rose-300 bg-rose-50/80 text-rose-800",
};
export function eventToken(kind: string): ProjectToken {
const normalized = kind.toLowerCase();
if (normalized.includes("action") || normalized.includes("human") || normalized.includes("overdue")) {
return { label: "Human action", tone: "danger", icon: CircleAlert };
}
if (normalized.includes("approval")) {
return { label: "Approval", tone: "warning", icon: CircleAlert };
}
if (normalized.includes("shipped")) {
return { label: "Shipped", tone: "success", icon: PackageCheck };
}
if (normalized.includes("transition.done") || normalized.includes("transition.complete")) {
return { label: "Marked done", tone: "success", icon: CheckCircle2 };
}
if (normalized.includes("transition.in-progress") || normalized.includes("transition.claim")) {
return { label: "In progress", tone: "info", icon: Clock };
}
if (normalized.includes("transition.pending")) {
return { label: "Pending", tone: "warning", icon: Clock };
}
if (normalized.includes("transition")) {
return { label: "State change", tone: "neutral", icon: History };
}
if (normalized.includes("ship") || normalized.includes("done") || normalized.includes("complete")) {
return { label: "Completed", tone: "success", icon: CheckCircle2 };
}
if (normalized.includes("claim")) {
return { label: "Claimed", tone: "warning", icon: Clock };
}
if (normalized.includes("handoff") || normalized.includes("routed")) {
return { label: "Handoff", tone: "neutral", icon: Send };
}
if (normalized.includes("created")) {
return { label: "New queue item", tone: "info", icon: CirclePlus };
}
if (normalized.includes("progress")) {
return { label: "Progress", tone: "info", icon: History };
}
return { label: humanizeCodeLabel(kind), tone: "neutral", icon: MessageSquareText };
}
export function scopeToken(scope: "workspace" | "mission" | "slice"): ProjectToken {
if (scope === "workspace") return { label: "Workspace", tone: "neutral", icon: Route };
if (scope === "mission") return { label: "Mission", tone: "info", icon: GitBranch };
return { label: "Slice", tone: "success", icon: MessageSquareText };
}
export function stateTone(state: string | undefined): ProjectMetaTone {
const normalized = state?.toLowerCase() ?? "";
if (normalized.includes("done") || normalized.includes("complete") || normalized.includes("closed")) return "success";
if (normalized.includes("fail") || normalized.includes("blocked") || normalized.includes("overdue")) return "danger";
if (normalized.includes("pending") || normalized.includes("human") || normalized.includes("approval")) return "warning";
if (normalized.includes("progress") || normalized.includes("claim")) return "info";
return "neutral";
}
export function humanizeCodeLabel(value: string): string {
return value
.replace(/[_./-]+/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase())
.trim();
}
export function compactSessionLabel(session: string | undefined | null): string {
if (!session) return "unknown";
if (session === "human@host") return "human@host";
const [name, rig] = session.split("@");
if (!rig) return session;
return `${name}@${rig}`;
}
export function formatFriendlyDate(value: string | undefined | null): string {
if (!value) return "unknown";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const now = new Date();
const sameDay = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
const time = new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
}).format(date);
if (sameDay) return `Today ${time}`;
if (date.toDateString() === yesterday.toDateString()) return `Yesterday ${time}`;
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date);
}
export function ProjectPill({
token,
compact = false,
testId,
className,
}: {
token: ProjectToken;
compact?: boolean;
testId?: string;
className?: string;
}) {
const Icon = token.icon;
return (
<span
data-testid={testId}
className={cn(
"inline-flex items-center gap-1 border font-mono uppercase tracking-[0.10em]",
compact ? "px-1.5 py-0.5 text-[8px]" : "px-2 py-1 text-[9px]",
toneClass[token.tone],
className,
)}
>
{Icon ? <Icon className={compact ? "h-2.5 w-2.5" : "h-3 w-3"} strokeWidth={1.6} /> : null}
{token.label}
</span>
);
}
export function EventBadge({ kind, compact, testId }: { kind: string; compact?: boolean; testId?: string }) {
return <ProjectPill token={eventToken(kind)} compact={compact} testId={testId} />;
}
export function TagPill({ tag, compact = true }: { tag: string; compact?: boolean }) {
const tone: ProjectMetaTone =
/urgent|blocked|human/i.test(tag) ? "danger" :
/proof|pass|done|ship/i.test(tag) ? "success" :
/cycle|phase|qa|review/i.test(tag) ? "info" :
"neutral";
return <ProjectPill token={{ label: tag, tone }} compact={compact} />;
}
export function ActorChip({
session,
muted,
}: {
session: string | undefined | null;
muted?: boolean;
}) {
const isHuman = (session ?? "").includes("human@") || session === "human@host";
const Icon = isHuman ? UserRound : Bot;
return (
<span
className={cn(
"inline-flex min-w-0 items-center gap-1 border px-1.5 py-0.5 font-mono text-[9px]",
muted ? "border-stone-200 bg-white/35 text-stone-500" : "border-stone-300 bg-white/55 text-stone-800",
)}
>
<Icon className="h-3 w-3 shrink-0" strokeWidth={1.5} />
<span className="truncate">{compactSessionLabel(session)}</span>
</span>
);
}
export function DateChip({ value }: { value: string | undefined | null }) {
return (
<time
dateTime={value ?? undefined}
className="inline-flex items-center gap-1 border border-stone-200 bg-white/45 px-1.5 py-0.5 font-mono text-[9px] text-stone-600"
>
<CalendarDays className="h-3 w-3" strokeWidth={1.5} />
{formatFriendlyDate(value)}
</time>
);
}
export function FlowChips({
source,
destination,
muted,
}: {
source?: string | null;
destination?: string | null;
muted?: boolean;
}) {
if (!source && !destination) return null;
return (
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<ActorChip session={source ?? "unknown source"} muted={muted} />
<ArrowRight className="h-3.5 w-3.5 text-stone-400" strokeWidth={1.4} />
<ActorChip session={destination ?? "unresolved target"} muted={muted} />
</div>
);
}
export function ProofThumbnailGrid({
sliceName,
screenshots,
onSelect,
max = 4,
testIdPrefix,
}: {
sliceName: string;
screenshots: string[];
onSelect?: (relPath: string) => void;
max?: number;
testIdPrefix?: string;
}) {
if (screenshots.length === 0) return null;
return (
<div className="grid grid-cols-2 gap-2">
{screenshots.slice(0, max).map((rel) => {
const image = (
<img
data-testid={testIdPrefix ? `${testIdPrefix}-${rel}` : undefined}
src={proofAssetUrl(sliceName, rel)}
alt={rel}
className="h-24 w-full border border-stone-200 bg-stone-100 object-cover"
loading="lazy"
/>
);
if (!onSelect) return <div key={rel}>{image}</div>;
return (
<button
key={rel}
type="button"
onClick={() => onSelect(rel)}
className="block w-full text-left focus:outline-none focus:ring-2 focus:ring-stone-900/20"
>
{image}
</button>
);
})}
</div>
);
}
export function ProofPacketHeader({
title,
badge,
}: {
title: string;
badge: string;
}) {
return (
<div className="flex items-center justify-between gap-2 font-mono text-[9px] uppercase tracking-[0.12em] text-stone-600">
<span className="truncate inline-flex items-center gap-1">
<FileImage className="h-3 w-3" strokeWidth={1.5} />
{title}
</span>
<ProjectPill token={{ label: badge, tone: stateTone(badge) }} compact />
</div>
);
}
@@ -5,7 +5,7 @@
// and live MissionStatusBadge derived from PROGRESS.md frontmatter via
// useMissionProgressStatus (over /api/files/read). When the allowlist
// doesn't expose workspace.root, the tree falls back to the legacy
// railItem-grouped slice listing.
// railItem/missionId-grouped slice listing.
import { useState, useMemo } from "react";
import { Link } from "@tanstack/react-router";
@@ -18,28 +18,27 @@ import {
} from "../../hooks/useMissionDiscovery.js";
import { useMissionProgressStatus } from "../../hooks/useMissionProgressStatus.js";
import { MissionStatusBadge, type MissionStatus } from "../MissionStatusBadge.js";
type SliceRow = { name: string; displayName: string; status: string };
import {
deriveMissionStatusFromSlices,
isCurrentProjectSlice,
partitionProjectMissions,
projectSliceFromListEntry,
projectSliceMeta,
type ProjectMissionBucket,
type ProjectSliceRow,
} from "../../lib/project-mission-state.js";
type GroupedMission = {
id: string;
label: string;
status: MissionStatus;
slices: SliceRow[];
slices: ProjectSliceRow[];
// P5-5: filesystem-discovered missions carry root + path so the live
// PROGRESS.md status fetcher knows where to read.
fsRoot?: string;
fsPath?: string;
};
function deriveStatusFromSlices(slices: Array<{ status: string }>): MissionStatus {
if (slices.length === 0) return "unknown";
if (slices.some((s) => s.status === "blocked")) return "blocked";
if (slices.some((s) => s.status === "active")) return "active";
if (slices.every((s) => s.status === "done")) return "shipped";
return "active";
}
/** Live mission-status badge: when the mission was discovered on disk,
* fetches PROGRESS.md frontmatter; otherwise falls back to the heuristic
* derived from constituent slices' statuses. */
@@ -70,63 +69,61 @@ export function ProjectTreeView() {
const sliceList: SliceListEntry[] =
slicesResp && "slices" in slicesResp ? slicesResp.slices : [];
// Group slices by railItem (the "mission" label they self-report).
const slicesByRailItem = useMemo(() => {
const buckets = new Map<string, SliceRow[]>();
// Group slices by missionId first, then railItem for legacy flat roots.
const slicesByMissionKey = useMemo(() => {
const buckets = new Map<string, ProjectSliceRow[]>();
for (const s of sliceList) {
const key = s.railItem ?? "unsorted";
const key = s.missionId ?? s.railItem ?? "unsorted";
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key)!.push({
name: s.name,
displayName: s.displayName,
status: s.status,
});
buckets.get(key)!.push(projectSliceFromListEntry(s));
}
return buckets;
}, [sliceList]);
// P5-5: when filesystem mission discovery is available, surface the
// disk-discovered missions and attach their slices via railItem match.
// Otherwise fall back to railItem grouping alone.
// disk-discovered missions and attach their slices via missionId match.
// Otherwise fall back to missionId/railItem grouping alone.
const missions = useMemo<GroupedMission[]>(() => {
if (!discovery.unavailable && discovery.missions.length > 0) {
const orphanSlices: SliceRow[] = [];
const consumedKeys = new Set<string>();
const discovered: GroupedMission[] = discovery.missions.map((m: DiscoveredMission) => {
const matchedSlices = slicesByRailItem.get(m.name) ?? [];
const matchedSlices = slicesByMissionKey.get(m.name) ?? [];
if (matchedSlices.length > 0) consumedKeys.add(m.name);
return {
id: m.name,
label: m.name,
status: deriveStatusFromSlices(matchedSlices), // baseline; live overrides via badge
status: deriveMissionStatusFromSlices(matchedSlices), // baseline; live overrides via badge
slices: matchedSlices,
fsRoot: m.root,
fsPath: m.path,
};
});
// Any slice whose railItem doesn't match a disk mission goes into
// "unsorted" so it's still reachable.
for (const [railItem, slices] of slicesByRailItem.entries()) {
if (!consumedKeys.has(railItem)) orphanSlices.push(...slices);
}
if (orphanSlices.length > 0) {
// Any slice whose mission key doesn't match a disk mission goes into
// its own indexed group so legacy railItem missions do not get
// collapsed into one mixed current/archive bucket.
for (const [missionKey, slices] of slicesByMissionKey.entries()) {
if (consumedKeys.has(missionKey)) continue;
discovered.push({
id: "unsorted",
label: "Unsorted",
status: deriveStatusFromSlices(orphanSlices),
slices: orphanSlices,
id: missionKey,
label: missionKey === "unsorted" ? "Unsorted" : missionKey,
status: deriveMissionStatusFromSlices(slices),
slices,
});
}
return discovered;
}
// Fallback: railItem grouping only (Phase 3 behavior).
return Array.from(slicesByRailItem.entries()).map(([k, slices]) => ({
// Fallback: missionId/railItem grouping only.
return Array.from(slicesByMissionKey.entries()).map(([k, slices]) => ({
id: k,
label: k === "unsorted" ? "Unsorted" : k,
status: deriveStatusFromSlices(slices),
status: deriveMissionStatusFromSlices(slices),
slices,
}));
}, [discovery.unavailable, discovery.missions, slicesByRailItem]);
}, [discovery.unavailable, discovery.missions, slicesByMissionKey]);
const missionSections = useMemo(() => {
return partitionProjectMissions(missions);
}, [missions]);
// A5 bounce-fix: replace hardcoded "openrig-work" with live-wired
// workspace name from ConfigStore. When unset/unreachable: render an
@@ -160,6 +157,80 @@ export function ProjectTreeView() {
}
const workspaceLabel = workspace.name ?? "loading…";
const isExpanded = (key: string, defaultValue = false) => expanded[key] ?? defaultValue;
const renderMission = (m: GroupedMission, bucket: ProjectMissionBucket) => {
const defaultExpanded = bucket === "current";
const missionExpanded = isExpanded(`mission-${m.id}`, defaultExpanded);
return (
<li
key={m.id}
data-testid={`project-mission-${m.id}`}
data-mission-bucket={bucket}
>
<div className="w-full flex items-center gap-1 px-2 py-0.5 hover:bg-surface-low text-left">
<button
type="button"
aria-label={`${missionExpanded ? "Collapse" : "Expand"} ${m.label}`}
onClick={() =>
setExpanded((p) => ({
...p,
[`mission-${m.id}`]: !missionExpanded,
}))
}
className="flex h-4 w-4 items-center justify-center"
data-testid={`project-mission-toggle-${m.id}`}
>
{missionExpanded ? (
<ChevronDown className="h-3 w-3 text-on-surface-variant" />
) : (
<ChevronRight className="h-3 w-3 text-on-surface-variant" />
)}
</button>
<Link
to="/project/mission/$missionId"
params={{ missionId: m.id }}
data-testid={`project-mission-link-${m.id}`}
className="font-mono text-[11px] text-stone-900 flex-1 truncate hover:underline"
>
{m.label}
</Link>
<LiveMissionStatusBadge mission={m} />
</div>
{missionExpanded ? (
<ul className="ml-4 border-l border-stone-200">
{m.slices.length === 0 ? (
<li className="px-2 py-0.5 font-mono text-[10px] text-on-surface-variant italic">
No slices.
</li>
) : (
m.slices.map((s) => {
const sliceBucket = isCurrentProjectSlice(s) ? "current" : "archive";
return (
<li key={s.name} data-slice-bucket={sliceBucket}>
<Link
to="/project/slice/$sliceId"
params={{ sliceId: s.name }}
data-testid={`project-slice-${s.name}`}
className="block px-2 py-1 font-mono text-xs text-on-surface hover:text-stone-900 hover:bg-surface-low"
>
<span className="block truncate">{s.displayName}</span>
<span
data-testid={`project-slice-${s.name}-meta`}
className="block truncate text-[9px] uppercase tracking-[0.12em] text-stone-500"
>
{projectSliceMeta(s)}
</span>
</Link>
</li>
);
})
)}
</ul>
) : null}
</li>
);
};
return (
<div data-testid="project-tree-view" className="flex-1 overflow-y-auto py-2">
@@ -191,7 +262,7 @@ export function ProjectTreeView() {
open
</Link>
</button>
{expanded.workspace ? (
{isExpanded("workspace") ? (
<ul className="ml-4 border-l border-stone-200">
{discovery.unavailable && discovery.hint ? (
<li
@@ -199,7 +270,7 @@ export function ProjectTreeView() {
className="px-2 py-1 font-mono text-[9px] text-on-surface-variant italic"
title={discovery.hint}
>
Filesystem mission discovery unavailable; showing railItem grouping.
Workspace missions folder unavailable; showing indexed slice grouping. Expected workspace/missions/&lt;mission&gt;/slices/&lt;slice&gt;.
</li>
) : null}
{missions.length === 0 ? (
@@ -207,48 +278,40 @@ export function ProjectTreeView() {
No missions yet.
</li>
) : (
missions.map((m) => (
<li key={m.id} data-testid={`project-mission-${m.id}`}>
<button
type="button"
onClick={() => toggle(`mission-${m.id}`)}
className="w-full flex items-center gap-1 px-2 py-0.5 hover:bg-surface-low text-left"
data-testid={`project-mission-toggle-${m.id}`}
>
{expanded[`mission-${m.id}`] ? (
<ChevronDown className="h-3 w-3 text-on-surface-variant" />
) : (
<ChevronRight className="h-3 w-3 text-on-surface-variant" />
)}
<span className="font-mono text-[11px] text-stone-900 flex-1 truncate">
{m.label}
</span>
<LiveMissionStatusBadge mission={m} />
</button>
{expanded[`mission-${m.id}`] ? (
<ul className="ml-4 border-l border-stone-200">
{m.slices.length === 0 ? (
<li className="px-2 py-0.5 font-mono text-[10px] text-on-surface-variant italic">
No slices.
</li>
) : (
m.slices.map((s) => (
<li key={s.name}>
<Link
to="/project/slice/$sliceId"
params={{ sliceId: s.name }}
data-testid={`project-slice-${s.name}`}
className="block px-2 py-0.5 font-mono text-xs text-on-surface hover:text-stone-900 hover:bg-surface-low truncate"
>
{s.displayName}
</Link>
</li>
))
)}
</ul>
) : null}
<>
<li
data-testid="project-mission-section-current"
className="px-2 pt-2 pb-1 font-mono text-[9px] uppercase tracking-[0.16em] text-stone-500"
>
Current Work · {missionSections.current.length}
</li>
))
{missionSections.current.length > 0 ? (
missionSections.current.map((m) => renderMission(m, "current"))
) : (
<li
data-testid="project-mission-section-current-empty"
className="px-2 py-1 font-mono text-[10px] text-on-surface-variant italic"
>
No current work.
</li>
)}
<li
data-testid="project-mission-section-archive"
className="px-2 pt-3 pb-1 font-mono text-[9px] uppercase tracking-[0.16em] text-stone-500"
>
Archive · {missionSections.archive.length}
</li>
{missionSections.archive.length > 0 ? (
missionSections.archive.map((m) => renderMission(m, "archive"))
) : (
<li
data-testid="project-mission-section-archive-empty"
className="px-2 py-1 font-mono text-[10px] text-on-surface-variant italic"
>
No archived work.
</li>
)}
</>
)}
</ul>
) : null}
@@ -0,0 +1,53 @@
import { X } from "lucide-react";
import { proofAssetUrl } from "../../hooks/useSlices.js";
export function ProofImageViewer({
sliceName,
relPath,
onClose,
testId = "proof-image-viewer",
imageTestId = "proof-image-viewer-image",
closeTestId = "proof-image-viewer-close",
}: {
sliceName: string;
relPath: string | null;
onClose: () => void;
testId?: string;
imageTestId?: string;
closeTestId?: string;
}) {
if (!relPath) return null;
return (
<div
role="dialog"
aria-label="Screenshot preview"
data-testid={testId}
className="fixed bottom-0 right-0 top-14 z-[1000] flex items-center justify-center bg-stone-950/20 p-6 backdrop-blur-[2px] lg:left-[21rem]"
onClick={onClose}
>
<div
className="max-h-full max-w-[92vw] border border-white/20 bg-stone-950/70 p-2 backdrop-blur-sm"
onClick={(event) => event.stopPropagation()}
>
<img
src={proofAssetUrl(sliceName, relPath)}
alt={relPath}
data-testid={imageTestId}
className="max-h-[78vh] max-w-full object-contain"
/>
<div className="mt-2 flex items-center justify-between gap-3 font-mono text-[10px] text-stone-50">
<span className="truncate">{relPath}</span>
<button
type="button"
data-testid={closeTestId}
onClick={onClose}
className="inline-flex h-7 w-7 items-center justify-center border border-white/30 text-stone-50 hover:bg-white/10"
aria-label="Close screenshot preview"
>
<X className="h-3.5 w-3.5" strokeWidth={1.5} />
</button>
</div>
</div>
</div>
);
}
+707 -40
View File
@@ -16,14 +16,29 @@
// Workspace + Mission scope tab piping remains Phase 5 polish (filesystem-walk
// dependent; P5-5 lays the data layer).
import { useState, type ReactNode } from "react";
import { useMemo, useState, type ReactNode } from "react";
import { Link, useParams } from "@tanstack/react-router";
import { cn } from "../../lib/utils.js";
import { SectionHeader } from "../ui/section-header.js";
import { EmptyState } from "../ui/empty-state.js";
import { FilesWorkspace } from "../files/FilesWorkspace.js";
import { useWorkspaceName } from "../../hooks/useWorkspaceName.js";
import { useSliceDetail, type SliceDetail } from "../../hooks/useSlices.js";
import {
useQueueItemMap,
useSliceDetails,
useSlices,
useSliceDetail,
type QueueItemDetail,
type SliceDetail,
type SliceListEntry,
} from "../../hooks/useSlices.js";
import {
deriveMissionStatusFromSlices,
latestProjectMissionActivity,
partitionProjectMissions,
projectSliceFromListEntry,
projectSliceMeta,
type ProjectMissionGroup,
} from "../../lib/project-mission-state.js";
import { StoryTab } from "../slices/tabs/StoryTab.js";
import { AcceptanceTab } from "../slices/tabs/AcceptanceTab.js";
import { DocsTab } from "../slices/tabs/DocsTab.js";
@@ -31,14 +46,29 @@ import { DecisionsTab } from "../slices/tabs/DecisionsTab.js";
import { TestsVerificationTab } from "../slices/tabs/TestsVerificationTab.js";
import { TopologyTab } from "../slices/tabs/TopologyTab.js";
import { QueueItemTrigger } from "../drawer-triggers/QueueItemTrigger.js";
import {
DateChip,
EventBadge,
FlowChips,
ProjectPill,
ProofPacketHeader,
ProofThumbnailGrid,
TagPill,
formatFriendlyDate,
scopeToken,
stateTone,
} from "./ProjectMetaPrimitives.js";
import { ProofImageViewer } from "./ProofImageViewer.js";
type SharedTab = "overview" | "progress" | "artifacts" | "queue" | "topology";
type SharedTab = "overview" | "story" | "progress" | "artifacts" | "tests" | "queue" | "topology";
type SliceTab = SharedTab | "story" | "tests";
const SHARED_TABS: { id: SharedTab; label: string }[] = [
{ id: "overview", label: "Overview" },
{ id: "story", label: "Story" },
{ id: "progress", label: "Progress" },
{ id: "artifacts", label: "Artifacts" },
{ id: "tests", label: "Tests" },
{ id: "queue", label: "Queue" },
{ id: "topology", label: "Topology" },
];
@@ -134,9 +164,480 @@ function PlaceholderTab({ label, description }: { label: string; description?: s
);
}
function formatLastActivity(ts: number): string {
if (ts <= 0) return "no recent activity";
return new Date(ts).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function sliceMissionKey(slice: SliceListEntry): string {
return slice.missionId ?? slice.railItem ?? "unsorted";
}
function rowsForScope(rows: SliceListEntry[], missionId: string | null): SliceListEntry[] {
if (!missionId) return rows;
return rows.filter((slice) => sliceMissionKey(slice) === missionId);
}
function useProjectScopeRollup(missionId: string | null, loadDetails: boolean) {
const list = useSlices("all");
const rows = useMemo(() => {
if (!list.data || "unavailable" in list.data) return [];
return rowsForScope(list.data.slices, missionId);
}, [list.data, missionId]);
const details = useSliceDetails(loadDetails ? rows.map((slice) => slice.name) : []);
const qitemIds = useMemo(() => {
const ids = new Set<string>();
for (const detail of details.itemsByName.values()) {
if (Array.isArray(detail.qitemIds)) {
detail.qitemIds.forEach((qitemId) => ids.add(qitemId));
}
}
return Array.from(ids).sort();
}, [details.itemsByName]);
const queueItems = useQueueItemMap(loadDetails ? qitemIds : []);
return { list, rows, details, qitemIds, queueItems };
}
function ScopeProgressRollup({
rows,
detailsByName,
isLoading,
}: {
rows: SliceListEntry[];
detailsByName: Map<string, SliceDetail>;
isLoading: boolean;
}) {
if (isLoading && rows.length === 0) {
return <PlaceholderTab label="LOADING PROGRESS" description="Reading scoped slice progress." />;
}
if (rows.length === 0) {
return <EmptyState label="NO SCOPED SLICES" description="No slices are indexed for this scope." variant="card" testId="scope-progress-empty" />;
}
return (
<div data-testid="scope-progress-rollup" className="space-y-3">
{rows.map((row) => {
const detail = detailsByName.get(row.name);
return (
<article key={row.name} className="border border-outline-variant bg-white/35 p-3 backdrop-blur-sm">
<div className="flex items-start justify-between gap-3 border-b border-outline-variant pb-2">
<div className="min-w-0">
<Link
to="/project/slice/$sliceId"
params={{ sliceId: row.name }}
className="font-mono text-[12px] uppercase tracking-[0.12em] text-stone-900 hover:underline"
>
{row.displayName}
</Link>
<div className="mt-2 flex flex-wrap gap-1.5">
<ProjectPill token={scopeToken("slice")} compact />
<ProjectPill token={{ label: row.status, tone: stateTone(row.status) }} compact />
<DateChip value={row.lastActivityAt} />
</div>
</div>
</div>
<div className="mt-3 grid gap-2 font-mono text-[10px] text-stone-700 sm:grid-cols-4">
<SliceMetric label="Qitems" value={detail?.qitemIds.length ?? row.qitemCount} />
<SliceMetric label="Proof" value={detail ? detail.tests.proofPackets.length : row.hasProofPacket ? 1 : 0} />
<SliceMetric label="Progress" value={detail ? `${detail.acceptance.percentage}%` : "unknown"} />
<SliceMetric label="Last activity" value={formatMaybeDate(row.lastActivityAt)} />
</div>
</article>
);
})}
</div>
);
}
function ScopeQueueRollup({
qitemIds,
queueItemsById,
isFetching,
}: {
qitemIds: string[];
queueItemsById: Map<string, QueueItemDetail>;
isFetching: boolean;
}) {
if (qitemIds.length === 0) {
return <EmptyState label="NO QITEMS" description="No queue items are indexed for this scope." variant="card" testId="scope-queue-empty" />;
}
return (
<div data-testid="scope-queue-rollup">
{isFetching ? (
<div className="mb-2 font-mono text-[10px] uppercase tracking-[0.12em] text-stone-400">
Loading queue bodies...
</div>
) : null}
<ul className="divide-y divide-outline-variant border border-outline-variant">
{qitemIds.map((qitemId) => {
const item = queueItemsById.get(qitemId);
return (
<li key={qitemId} className="bg-white/35 backdrop-blur-sm">
<QueueItemTrigger
data={queueItemViewerData(qitemId, item)}
testId={`scope-queue-trigger-${qitemId}`}
className="block w-full px-3 py-2 text-left font-mono text-xs transition-colors hover:bg-white/55"
>
<span className="flex flex-wrap items-center gap-2">
<EventBadge kind={item?.state ?? "queue.item"} compact />
<DateChip value={item?.tsCreated} />
</span>
<span className="mt-2 block whitespace-pre-wrap break-words text-stone-900">
{queueBodyPreview(qitemId, item)}
</span>
{item ? (
<span className="mt-2 block space-y-2">
<FlowChips source={item.sourceSession} destination={item.destinationSession} muted />
<span className="flex flex-wrap gap-1.5">
{(item.tags ?? []).slice(0, 5).map((tag) => <TagPill key={tag} tag={tag} />)}
</span>
</span>
) : null}
</QueueItemTrigger>
</li>
);
})}
</ul>
</div>
);
}
function ScopeArtifactsRollup({
rows,
detailsByName,
}: {
rows: SliceListEntry[];
detailsByName: Map<string, SliceDetail>;
}) {
if (rows.length === 0) {
return <EmptyState label="NO ARTIFACTS" description="No slices are indexed for this scope." variant="card" testId="scope-artifacts-empty" />;
}
return (
<div data-testid="scope-artifacts-rollup" className="space-y-3">
{rows.map((row) => {
const detail = detailsByName.get(row.name);
const proofCount = detail?.tests.proofPackets.length ?? (row.hasProofPacket ? 1 : 0);
const screenshotCount = detail?.tests.proofPackets.reduce((count, packet) => count + packet.screenshots.length, 0) ?? 0;
return (
<article key={row.name} className="border border-outline-variant bg-white/35 p-3 backdrop-blur-sm">
<Link
to="/project/slice/$sliceId"
params={{ sliceId: row.name }}
className="font-mono text-[12px] uppercase tracking-[0.12em] text-stone-900 hover:underline"
>
{row.displayName}
</Link>
<div className="mt-2 flex flex-wrap gap-1.5">
<ProjectPill token={scopeToken("slice")} compact />
<ProjectPill token={{ label: row.status, tone: stateTone(row.status) }} compact />
</div>
<div className="mt-3 grid gap-2 font-mono text-[10px] text-stone-700 sm:grid-cols-4">
<SliceMetric label="Files" value={detail?.docs.tree.length ?? "unknown"} />
<SliceMetric label="Commits" value={detail?.commitRefs.length ?? "unknown"} />
<SliceMetric label="Proof packets" value={proofCount} />
<SliceMetric label="Screenshots" value={screenshotCount} />
</div>
</article>
);
})}
</div>
);
}
function ScopeStoryRollup({
rows,
detailsByName,
queueItemsById,
isFetching,
}: {
rows: SliceListEntry[];
detailsByName: Map<string, SliceDetail>;
queueItemsById: Map<string, QueueItemDetail>;
isFetching: boolean;
}) {
const rowByName = useMemo(() => new Map(rows.map((row) => [row.name, row])), [rows]);
const events = useMemo(() => {
return Array.from(detailsByName.values()).flatMap((detail) => {
const row = rowByName.get(detail.name);
const sliceLabel = row?.displayName ?? detail.displayName ?? detail.name;
return storyEventsForDetail(detail).map((event) => ({
...event,
detail: {
...(event.detail ?? {}),
sliceLabel,
sliceName: detail.name,
},
}));
});
}, [detailsByName, rowByName]);
if (isFetching && events.length === 0) {
return <PlaceholderTab label="LOADING STORY" description="Reading scoped story events." />;
}
if (events.length === 0) {
return <EmptyState label="NO STORY EVENTS" description="No story events are indexed for this scope." variant="card" testId="scope-story-empty" />;
}
return (
<div data-testid="scope-story-rollup">
<StoryTab events={events} phaseDefinitions={null} queueItemsById={queueItemsById} />
</div>
);
}
function storyEventsForDetail(detail: SliceDetail) {
const qitemIds = new Set(detail.qitemIds);
return detail.story.events.filter((event) => !event.qitemId || qitemIds.has(event.qitemId));
}
function ScopeTestsRollup({
rows,
detailsByName,
isFetching,
}: {
rows: SliceListEntry[];
detailsByName: Map<string, SliceDetail>;
isFetching: boolean;
}) {
const [selected, setSelected] = useState<{ sliceName: string; relPath: string } | null>(null);
const rowsWithDetails = rows.map((row) => ({ row, detail: detailsByName.get(row.name) }));
const proofRows = rowsWithDetails.filter(({ detail }) => detail && detail.tests.proofPackets.length > 0);
if (isFetching && detailsByName.size === 0) {
return <PlaceholderTab label="LOADING TESTS" description="Reading scoped proof packets." />;
}
if (proofRows.length === 0) {
return <EmptyState label="NO PROOF PACKETS" description="No proof evidence is indexed for this scope." variant="card" testId="scope-tests-empty" />;
}
return (
<div data-testid="scope-tests-rollup" className="space-y-3">
{proofRows.map(({ row, detail }) => {
if (!detail) return null;
const screenshotCount = detail.tests.proofPackets.reduce((count, packet) => count + packet.screenshots.length, 0);
return (
<article key={row.name} className="border border-outline-variant bg-white/35 p-3 backdrop-blur-sm">
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-outline-variant pb-2">
<div className="min-w-0">
<Link
to="/project/slice/$sliceId"
params={{ sliceId: row.name }}
className="font-mono text-[12px] uppercase tracking-[0.12em] text-stone-900 hover:underline"
>
{row.displayName}
</Link>
<div className="mt-2 flex flex-wrap gap-1.5">
<ProjectPill token={scopeToken("slice")} compact />
<ProjectPill token={{ label: `${detail.tests.proofPackets.length} packets`, tone: "info" }} compact />
<ProjectPill token={{ label: `${screenshotCount} screenshots`, tone: "success" }} compact />
</div>
</div>
<DateChip value={row.lastActivityAt} />
</div>
<div className="mt-3 space-y-3">
{detail.tests.proofPackets.map((packet) => (
<div key={packet.dirName} className="border border-outline-variant bg-white/30 p-2 backdrop-blur-sm">
<ProofPacketHeader title={packet.dirName} badge={packet.passFailBadge} />
{packet.primaryMarkdown?.content ? (
<p className="mt-2 line-clamp-3 font-mono text-[10px] leading-relaxed text-stone-700">
{packet.primaryMarkdown.content}
</p>
) : null}
{packet.screenshots.length > 0 ? (
<div className="mt-2">
<ProofThumbnailGrid
sliceName={detail.name}
screenshots={packet.screenshots}
onSelect={(relPath) => setSelected({ sliceName: detail.name, relPath })}
testIdPrefix={`scope-proof-screenshot-${detail.name}`}
/>
</div>
) : null}
</div>
))}
</div>
</article>
);
})}
<ProofImageViewer
sliceName={selected?.sliceName ?? ""}
relPath={selected?.relPath ?? null}
onClose={() => setSelected(null)}
/>
</div>
);
}
function aggregateTopology(detailsByName: Map<string, SliceDetail>): SliceDetail["topology"] {
const rigs = new Map<string, { rigId: string; rigName: string; sessionNames: Set<string> }>();
for (const detail of detailsByName.values()) {
if (!detail.topology || !Array.isArray(detail.topology.affectedRigs)) continue;
for (const rig of detail.topology.affectedRigs) {
const key = rig.rigName || rig.rigId;
if (!rigs.has(key)) {
rigs.set(key, { rigId: rig.rigId, rigName: rig.rigName, sessionNames: new Set() });
}
const aggregate = rigs.get(key)!;
rig.sessionNames.forEach((session) => aggregate.sessionNames.add(session));
}
}
const affectedRigs = Array.from(rigs.values()).map((rig) => ({
rigId: rig.rigId,
rigName: rig.rigName,
sessionNames: Array.from(rig.sessionNames).sort(),
}));
return {
affectedRigs,
totalSeats: affectedRigs.reduce((count, rig) => count + rig.sessionNames.length, 0),
specGraph: null,
};
}
function ScopeTopologyRollup({ detailsByName }: { detailsByName: Map<string, SliceDetail> }) {
return (
<div data-testid="scope-topology-rollup">
<TopologyTab topology={aggregateTopology(detailsByName)} />
</div>
);
}
function WorkspaceOverviewPanel() {
const { data, isLoading } = useSlices("all");
const missions = useMemo<ProjectMissionGroup[]>(() => {
if (!data || "unavailable" in data) return [];
const buckets = new Map<string, ProjectMissionGroup["slices"]>();
for (const slice of data.slices) {
const row = projectSliceFromListEntry(slice);
const key = row.missionId ?? row.railItem ?? "unsorted";
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key)!.push(row);
}
return Array.from(buckets.entries()).map(([key, slices]) => ({
id: key,
label: key === "unsorted" ? "Unsorted" : key,
status: deriveMissionStatusFromSlices(slices),
slices,
}));
}, [data]);
const sections = useMemo(() => partitionProjectMissions(missions), [missions]);
if (isLoading) {
return (
<EmptyState
label="LOADING WORKSPACE"
description="Reading slice index."
variant="card"
testId="workspace-overview-loading"
/>
);
}
if (data && "unavailable" in data) {
return (
<EmptyState
label="WORKSPACE INDEX UNAVAILABLE"
description={data.hint ?? "Slice index is not available from the configured workspace."}
variant="card"
testId="workspace-overview-unavailable"
/>
);
}
const renderMissionCard = (mission: ProjectMissionGroup, bucket: "current" | "archive") => (
<article
key={mission.id}
data-testid={`workspace-overview-mission-${mission.id}`}
data-mission-bucket={bucket}
className="border border-outline-variant bg-white/20 px-3 py-3"
>
<div className="flex items-start justify-between gap-3 border-b border-outline-variant pb-2">
<div className="min-w-0">
<h3 className="font-mono text-[12px] uppercase tracking-[0.12em] text-stone-900 truncate">
{mission.label}
</h3>
<p className="font-mono text-[9px] uppercase tracking-[0.12em] text-stone-500">
{mission.slices.length} slice{mission.slices.length === 1 ? "" : "s"} ·{" "}
{formatLastActivity(latestProjectMissionActivity(mission))}
</p>
</div>
<span className="font-mono text-[9px] uppercase tracking-[0.12em] text-stone-500">
{mission.status}
</span>
</div>
<ul className="mt-2 space-y-1">
{mission.slices.map((slice) => (
<li key={slice.name}>
<Link
to="/project/slice/$sliceId"
params={{ sliceId: slice.name }}
data-testid={`workspace-overview-slice-${slice.name}`}
className="block px-2 py-1 font-mono text-[11px] text-on-surface hover:bg-surface-low hover:text-stone-900"
>
<span className="block truncate">{slice.displayName}</span>
<span className="block truncate text-[9px] uppercase tracking-[0.12em] text-stone-500">
{projectSliceMeta(slice)}
</span>
</Link>
</li>
))}
</ul>
</article>
);
return (
<div data-testid="workspace-overview-panel" className="grid gap-4 lg:grid-cols-2">
<section data-testid="workspace-overview-current" className="space-y-3">
<div className="flex items-center justify-between border-b border-outline-variant pb-2">
<h2 className="font-mono text-[11px] uppercase tracking-[0.16em] text-stone-900">
Current Work
</h2>
<span className="font-mono text-[10px] text-stone-500">
{sections.current.length}
</span>
</div>
{sections.current.length > 0 ? (
sections.current.map((mission) => renderMissionCard(mission, "current"))
) : (
<EmptyState
label="NO CURRENT WORK"
description="No live qitem-backed or recent active slices are indexed."
variant="card"
testId="workspace-overview-current-empty"
/>
)}
</section>
<section data-testid="workspace-overview-archive" className="space-y-3">
<div className="flex items-center justify-between border-b border-outline-variant pb-2">
<h2 className="font-mono text-[11px] uppercase tracking-[0.16em] text-stone-900">
Archive
</h2>
<span className="font-mono text-[10px] text-stone-500">
{sections.archive.length}
</span>
</div>
{sections.archive.length > 0 ? (
sections.archive.map((mission) => renderMissionCard(mission, "archive"))
) : (
<EmptyState
label="NO ARCHIVE"
description="No archived slices are indexed."
variant="card"
testId="workspace-overview-archive-empty"
/>
)}
</section>
</div>
);
}
export function WorkspaceScopePage() {
const [active, setActive] = useState<SharedTab>("overview");
const workspace = useWorkspaceName();
const rollup = useProjectScopeRollup(null, active !== "overview");
// A5 bounce-fix: live-wired workspace name; honest empty-state when unset.
if (!workspace.isLoading && workspace.name === null) {
@@ -162,17 +663,42 @@ export function WorkspaceScopePage() {
onSelect={(id) => setActive(id as SharedTab)}
>
{active === "overview" ? (
<PlaceholderTab label="WORKSPACE OVERVIEW" description="Renders STEERING.md (root attractor) + summary of missions in flight." />
<WorkspaceOverviewPanel />
) : null}
{active === "story" ? (
<ScopeStoryRollup
rows={rollup.rows}
detailsByName={rollup.details.itemsByName}
queueItemsById={rollup.queueItems.itemsById}
isFetching={rollup.details.isFetching || rollup.queueItems.isFetching}
/>
) : null}
{active === "progress" ? (
<PlaceholderTab label="WORKSPACE PROGRESS" description="Cross-mission rollup." />
<ScopeProgressRollup
rows={rollup.rows}
detailsByName={rollup.details.itemsByName}
isLoading={rollup.list.isLoading || rollup.details.isFetching}
/>
) : null}
{active === "artifacts" ? (
<ScopeArtifactsRollup rows={rollup.rows} detailsByName={rollup.details.itemsByName} />
) : null}
{active === "tests" ? (
<ScopeTestsRollup
rows={rollup.rows}
detailsByName={rollup.details.itemsByName}
isFetching={rollup.details.isFetching}
/>
) : null}
{active === "artifacts" ? <FilesWorkspace /> : null}
{active === "queue" ? (
<PlaceholderTab label="WORKSPACE QUEUE" description="All qitems across all rigs in this workspace." />
<ScopeQueueRollup
qitemIds={rollup.qitemIds}
queueItemsById={rollup.queueItems.itemsById}
isFetching={rollup.details.isFetching || rollup.queueItems.isFetching}
/>
) : null}
{active === "topology" ? (
<PlaceholderTab label="WORKSPACE TOPOLOGY" description="Full env topology, scoped to this workspace." />
<ScopeTopologyRollup detailsByName={rollup.details.itemsByName} />
) : null}
</ScopeShell>
);
@@ -181,6 +707,7 @@ export function WorkspaceScopePage() {
export function MissionScopePage() {
const { missionId } = useParams({ from: "/project/mission/$missionId" });
const [active, setActive] = useState<SharedTab>("overview");
const rollup = useProjectScopeRollup(missionId, active !== "overview");
return (
<ScopeShell
eyebrow="Mission"
@@ -189,18 +716,111 @@ export function MissionScopePage() {
active={active}
onSelect={(id) => setActive(id as SharedTab)}
>
<PlaceholderTab label={`MISSION ${active.toUpperCase()}`} description="Mission-scoped view; Phase 5 polish." />
{active === "overview" ? (
<div data-testid="mission-overview-panel" className="space-y-3">
{rollup.rows.length > 0 ? (
rollup.rows.map((slice) => (
<article key={slice.name} className="border border-outline-variant bg-white/35 p-3 backdrop-blur-sm">
<Link
to="/project/slice/$sliceId"
params={{ sliceId: slice.name }}
className="font-mono text-[12px] uppercase tracking-[0.12em] text-stone-900 hover:underline"
>
{slice.displayName}
</Link>
<div className="mt-2 flex flex-wrap gap-1.5">
<ProjectPill token={scopeToken("slice")} compact />
<ProjectPill token={{ label: slice.status, tone: stateTone(slice.status) }} compact />
<DateChip value={slice.lastActivityAt} />
</div>
<div className="mt-2 grid gap-2 font-mono text-[10px] text-stone-700 sm:grid-cols-4">
<SliceMetric label="Status" value={slice.status} />
<SliceMetric label="Qitems" value={slice.qitemCount} />
<SliceMetric label="Proof" value={slice.hasProofPacket ? "yes" : "no"} />
<SliceMetric label="Last activity" value={formatMaybeDate(slice.lastActivityAt)} />
</div>
</article>
))
) : (
<EmptyState
label="NO SLICES"
description="No indexed slices are attached to this mission."
variant="card"
testId="mission-overview-empty"
/>
)}
</div>
) : null}
{active === "story" ? (
<ScopeStoryRollup
rows={rollup.rows}
detailsByName={rollup.details.itemsByName}
queueItemsById={rollup.queueItems.itemsById}
isFetching={rollup.details.isFetching || rollup.queueItems.isFetching}
/>
) : null}
{active === "progress" ? (
<ScopeProgressRollup
rows={rollup.rows}
detailsByName={rollup.details.itemsByName}
isLoading={rollup.list.isLoading || rollup.details.isFetching}
/>
) : null}
{active === "artifacts" ? (
<ScopeArtifactsRollup rows={rollup.rows} detailsByName={rollup.details.itemsByName} />
) : null}
{active === "tests" ? (
<ScopeTestsRollup
rows={rollup.rows}
detailsByName={rollup.details.itemsByName}
isFetching={rollup.details.isFetching}
/>
) : null}
{active === "queue" ? (
<ScopeQueueRollup
qitemIds={rollup.qitemIds}
queueItemsById={rollup.queueItems.itemsById}
isFetching={rollup.details.isFetching || rollup.queueItems.isFetching}
/>
) : null}
{active === "topology" ? (
<ScopeTopologyRollup detailsByName={rollup.details.itemsByName} />
) : null}
</ScopeShell>
);
}
function SliceQueueTab({ qitemIds }: { qitemIds: string[] }) {
function queueItemViewerData(qitemId: string, item: QueueItemDetail | undefined) {
return {
qitemId,
source: item?.sourceSession,
destination: item?.destinationSession,
state: item?.state,
tags: item?.tags ?? undefined,
createdAt: item?.tsCreated,
body: item?.body,
};
}
function queueBodyPreview(qitemId: string, item: QueueItemDetail | undefined): string {
if (!item?.body) return qitemId;
const lines = item.body.split("\n");
if (lines.length <= 8) return item.body;
return `${lines.slice(0, 8).join("\n")}\n... ${lines.length - 8} more lines`;
}
function SliceQueueTab({
qitemIds,
queueItemsById,
queueItemsFetching,
}: {
qitemIds: string[];
queueItemsById: Map<string, QueueItemDetail>;
queueItemsFetching: boolean;
}) {
// V1 attempt-3 Phase 5 P5-2: slice queue tab. Each qitem id is wrapped in
// QueueItemTrigger (P5-1 trigger primitive) — clicking opens the
// QueueItemViewer in the drawer. Full qitem payload (source/dest/state/
// tags/body) is supplied by QueueItemViewer's empty-state when richer
// metadata isn't available; richer per-qitem fetch wiring is Phase 5
// polish if founder feedback requests it.
// QueueItemTrigger (P5-1 trigger primitive). Phase B supplies the body and
// provenance from the existing queue detail endpoint when available.
if (qitemIds.length === 0) {
return (
<EmptyState
@@ -212,34 +832,63 @@ function SliceQueueTab({ qitemIds }: { qitemIds: string[] }) {
);
}
return (
<ul data-testid="slice-queue-list" className="divide-y divide-outline-variant border border-outline-variant">
{qitemIds.map((qitemId) => (
<li key={qitemId}>
<QueueItemTrigger
data={{ qitemId }}
testId={`slice-queue-trigger-${qitemId}`}
className="block w-full px-3 py-2 text-left hover:bg-stone-100/60 transition-colors font-mono text-xs"
>
<span className="text-stone-900 break-all underline decoration-dotted decoration-stone-400">
{qitemId}
</span>
</QueueItemTrigger>
</li>
))}
</ul>
<div>
{queueItemsFetching ? (
<div
data-testid="slice-queue-fetching"
className="mb-2 font-mono text-[10px] uppercase tracking-[0.12em] text-stone-400"
>
Loading queue bodies...
</div>
) : null}
<ul
data-testid="slice-queue-list"
className="divide-y divide-outline-variant border border-outline-variant"
>
{qitemIds.map((qitemId) => {
const item = queueItemsById.get(qitemId);
return (
<li key={qitemId} className="bg-white/35 backdrop-blur-sm">
<QueueItemTrigger
data={queueItemViewerData(qitemId, item)}
testId={`slice-queue-trigger-${qitemId}`}
className="block w-full px-3 py-2 text-left hover:bg-white/55 transition-colors font-mono text-xs"
>
<span className="flex flex-wrap items-center gap-2">
<EventBadge kind={item?.state ?? "queue.item"} compact />
<DateChip value={item?.tsCreated} />
</span>
<span className="mt-2 block whitespace-pre-wrap break-words text-stone-900">
{queueBodyPreview(qitemId, item)}
</span>
{item ? (
<span
data-testid={`slice-queue-meta-${qitemId}`}
className="mt-2 block space-y-2 text-[10px] text-stone-500"
>
<FlowChips source={item.sourceSession} destination={item.destinationSession} muted />
<span className="flex flex-wrap gap-1.5">
<TagPill tag={qitemId} />
{(item.tags ?? []).slice(0, 5).map((tag) => <TagPill key={tag} tag={tag} />)}
</span>
</span>
) : null}
</QueueItemTrigger>
</li>
);
})}
</ul>
</div>
);
}
function formatMaybeDate(ts: string | null): string {
if (!ts) return "unknown";
const date = new Date(ts);
if (Number.isNaN(date.getTime())) return ts;
return date.toLocaleString();
return formatFriendlyDate(ts);
}
function SliceMetric({ label, value }: { label: string; value: string | number }) {
return (
<div className="border border-outline-variant bg-white/30 p-3">
<div className="border border-outline-variant bg-white/35 p-3 backdrop-blur-sm">
<div className="font-mono text-[8px] uppercase tracking-[0.14em] text-on-surface-variant">{label}</div>
<div className="mt-1 font-mono text-sm font-bold text-stone-900">{value}</div>
</div>
@@ -404,6 +1053,8 @@ export function SliceScopePage() {
const { sliceId } = useParams({ from: "/project/slice/$sliceId" });
const [active, setActive] = useState<SliceTab>("story");
const detailQuery = useSliceDetail(sliceId);
const queueItems = useQueueItemMap(detailQuery.data?.qitemIds ?? []);
const queueItemsById = useMemo(() => queueItems.itemsById, [queueItems.itemsById]);
if (detailQuery.isLoading) {
return (
@@ -438,7 +1089,7 @@ export function SliceScopePage() {
description={
detailQuery.error instanceof Error
? detailQuery.error.message
: `Could not load slice "${sliceId}". The slices indexer may not be configured (rig config get workspace.slicesRoot).`
: `Could not load slice "${sliceId}". The slices indexer may not be configured (rig config get workspace.slices_root).`
}
variant="card"
testId="slice-scope-error"
@@ -458,7 +1109,11 @@ export function SliceScopePage() {
onSelect={(id) => setActive(id as SliceTab)}
>
{active === "story" ? (
<StoryTab events={detail.story.events} phaseDefinitions={detail.story.phaseDefinitions} />
<StoryTab
events={storyEventsForDetail(detail)}
phaseDefinitions={detail.story.phaseDefinitions}
queueItemsById={queueItemsById}
/>
) : null}
{active === "overview" ? (
<SliceOverviewTab detail={detail} />
@@ -473,9 +1128,21 @@ export function SliceScopePage() {
<SliceArtifactsTab detail={detail} />
) : null}
{active === "tests" ? (
<TestsVerificationTab sliceName={detail.name} tests={detail.tests} />
<TestsVerificationTab
sliceName={detail.name}
tests={detail.tests}
qitemCount={detail.qitemIds.length}
docsCount={detail.docs.tree.length}
lastActivityAt={detail.lastActivityAt}
/>
) : null}
{active === "queue" ? (
<SliceQueueTab
qitemIds={detail.qitemIds}
queueItemsById={queueItemsById}
queueItemsFetching={queueItems.isFetching}
/>
) : null}
{active === "queue" ? <SliceQueueTab qitemIds={detail.qitemIds} /> : null}
{active === "topology" ? <TopologyTab topology={detail.topology} /> : null}
</ScopeShell>
);
@@ -5,7 +5,20 @@
// timeline, while adding visible flow cues for event-to-event handoff reading.
import { useMemo, useState } from "react";
import type { PhaseDefinition, StoryEvent } from "../../../hooks/useSlices.js";
import type {
PhaseDefinition,
QueueItemDetail,
StoryEvent,
} from "../../../hooks/useSlices.js";
import { QueueItemTrigger } from "../../drawer-triggers/QueueItemTrigger.js";
import {
DateChip,
EventBadge,
FlowChips,
ProjectPill,
TagPill,
type ProjectToken,
} from "../../project/ProjectMetaPrimitives.js";
const PHASE_PALETTE: string[] = [
"bg-amber-100 text-amber-800 border-amber-300",
@@ -18,21 +31,26 @@ const PHASE_PALETTE: string[] = [
"bg-orange-100 text-orange-800 border-orange-300",
];
const UNTAGGED_CLASS = "bg-stone-100 text-stone-700 border-stone-300";
const PAGE_SIZE = 12;
export function StoryTab({
events,
phaseDefinitions,
queueItemsById,
}: {
events: StoryEvent[];
phaseDefinitions: PhaseDefinition[] | null;
queueItemsById?: Map<string, QueueItemDetail>;
}) {
const [page, setPage] = useState(0);
const phaseMeta = useMemo(() => {
const map = new Map<string, { label: string; colorClass: string }>();
const map = new Map<string, { label: string; colorClass: string; token: ProjectToken }>();
if (phaseDefinitions) {
phaseDefinitions.forEach((p, idx) => {
map.set(p.id, {
label: p.label,
colorClass: PHASE_PALETTE[idx % PHASE_PALETTE.length]!,
token: { label: p.label, tone: (["warning", "info", "success", "neutral"] as const)[idx % 4]! },
});
});
}
@@ -43,6 +61,9 @@ export function StoryTab({
() => [...events].sort((a, b) => timestampSortValue(b.ts) - timestampSortValue(a.ts)),
[events],
);
const totalPages = Math.max(1, Math.ceil(sortedEvents.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages - 1);
const pagedEvents = sortedEvents.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE);
if (events.length === 0) {
return <div className="p-4 font-mono text-[10px] text-stone-400" data-testid="story-empty">No events captured for this slice yet.</div>;
@@ -50,16 +71,42 @@ export function StoryTab({
return (
<div data-testid="story-tab" className="space-y-0">
<div data-testid="story-step-tree" data-order="newest-first" className="relative border border-outline-variant bg-white/20 p-3">
{sortedEvents.map((event, idx) => (
<div data-testid="story-step-tree" data-order="newest-first" className="relative border border-outline-variant bg-white/35 p-3 backdrop-blur-sm">
{pagedEvents.map((event, idx) => (
<StoryStepCard
key={`${event.ts}-${event.kind}-${idx}`}
event={event}
phaseMeta={phaseMeta}
isLast={idx === sortedEvents.length - 1}
isLast={idx === pagedEvents.length - 1}
queueItem={event.qitemId ? queueItemsById?.get(event.qitemId) : undefined}
/>
))}
</div>
{totalPages > 1 ? (
<div className="mt-3 flex items-center justify-between border border-outline-variant bg-white/35 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.12em] text-stone-600">
<button
type="button"
data-testid="story-page-prev"
disabled={safePage === 0}
onClick={() => setPage((current) => Math.max(0, current - 1))}
className="border border-outline-variant px-2 py-1 disabled:opacity-40"
>
Newer
</button>
<span data-testid="story-page-status">
Page {safePage + 1} / {totalPages}
</span>
<button
type="button"
data-testid="story-page-next"
disabled={safePage >= totalPages - 1}
onClick={() => setPage((current) => Math.min(totalPages - 1, current + 1))}
className="border border-outline-variant px-2 py-1 disabled:opacity-40"
>
Older
</button>
</div>
) : null}
</div>
);
}
@@ -86,21 +133,86 @@ function flowLabel(event: StoryEvent): { source: string | null; target: string |
return { source, target };
}
function eventBody(event: StoryEvent, queueItem: QueueItemDetail | undefined): string {
const detailBody = detailString(event.detail, ["body", "content", "message"]);
return queueItem?.body ?? detailBody ?? event.summary;
}
function qitemViewerData(event: StoryEvent, queueItem: QueueItemDetail | undefined) {
return {
qitemId: event.qitemId ?? queueItem?.qitemId ?? "",
source: queueItem?.sourceSession ?? flowLabel(event).source ?? undefined,
destination: queueItem?.destinationSession ?? flowLabel(event).target ?? undefined,
state: queueItem?.state ?? detailString(event.detail, ["state", "toState", "fromState"]) ?? undefined,
tags: queueItem?.tags ?? undefined,
createdAt: queueItem?.tsCreated ?? event.ts,
body: queueItem?.body ?? eventBody(event, queueItem),
};
}
function previewText(text: string, maxLines = 12): string {
const lines = text.split("\n");
if (lines.length <= maxLines) return text;
return `${lines.slice(0, maxLines).join("\n")}\n... ${lines.length - maxLines} more lines`;
}
function StoryStepCard({
event,
phaseMeta,
isLast,
queueItem,
}: {
event: StoryEvent;
phaseMeta: Map<string, { label: string; colorClass: string }>;
phaseMeta: Map<string, { label: string; colorClass: string; token: ProjectToken }>;
isLast: boolean;
queueItem?: QueueItemDetail;
}) {
const [expanded, setExpanded] = useState(false);
const tsShort = event.ts.length > 19 ? event.ts.slice(0, 19) : event.ts;
const meta = event.phase ? phaseMeta.get(event.phase) : undefined;
const phaseLabel = meta?.label ?? event.phase ?? "untagged";
const phaseClass = meta?.colorClass ?? UNTAGGED_CLASS;
const phaseToken = meta?.token ?? { label: phaseLabel, tone: "neutral" as const };
const flow = flowLabel(event);
const scopeLabel = detailString(event.detail, ["sliceLabel", "sliceName"]);
const primaryBody = eventBody(event, queueItem);
const bodyIsQitem = Boolean(queueItem?.body);
const rowBody = (
<>
<div className="flex flex-wrap items-center gap-2">
<span
data-testid={`story-row-phase-${event.kind}`}
data-phase-id={event.phase ?? "untagged"}
className={`sr-only ${phaseClass}`}
>
{phaseLabel}
</span>
<ProjectPill token={phaseToken} compact />
{scopeLabel ? <ProjectPill token={{ label: scopeLabel, tone: "neutral" }} compact /> : null}
<EventBadge kind={event.kind} compact />
<DateChip value={event.ts} />
</div>
<pre
data-testid={`story-row-body-${event.kind}`}
data-source={bodyIsQitem ? "qitem" : "event"}
className="mt-2 whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-stone-950"
>
{previewText(primaryBody)}
</pre>
<div className="mt-2 flex flex-wrap items-center gap-2">
{event.qitemId ? <TagPill tag={event.qitemId} /> : null}
{queueItem?.tags?.slice(0, 4).map((tag) => <TagPill key={tag} tag={tag} />)}
</div>
{bodyIsQitem && event.summary !== primaryBody ? (
<div data-testid={`story-row-summary-${event.kind}`} className="mt-2 truncate font-mono text-[9px] text-stone-500">
{event.summary}
</div>
) : null}
{(flow.source || flow.target) && (
<div data-testid={`story-step-flow-${event.kind}`} className="mt-2">
<FlowChips source={flow.source} destination={flow.target} muted />
</div>
)}
</>
);
return (
<div className="relative pl-9 pb-3 last:pb-0">
@@ -114,41 +226,31 @@ function StoryStepCard({
data-testid={`story-step-dot-${event.kind}`}
className="absolute left-[8px] top-4 h-3 w-3 border border-outline-variant bg-white shadow-[1px_1px_0_rgba(46,52,46,0.12)]"
/>
<button
type="button"
data-testid={`story-row-${event.kind}`}
onClick={() => setExpanded((v) => !v)}
className="w-full border border-outline-variant bg-white/30 px-3 py-2 text-left hard-shadow hover:bg-white/40"
>
<div className="flex items-center gap-2">
<span className="font-mono text-[9px] text-stone-500 shrink-0">{tsShort}</span>
<span
data-testid={`story-row-phase-${event.kind}`}
data-phase-id={event.phase ?? "untagged"}
className={`inline-block border px-1 font-mono text-[8px] uppercase tracking-[0.10em] ${phaseClass}`}
>
{phaseLabel}
</span>
<span className="font-mono text-[10px] text-stone-700 truncate">{event.kind}</span>
{event.qitemId && (
<span className="ml-auto shrink-0 font-mono text-[8px] uppercase tracking-[0.10em] text-stone-400">
{event.qitemId}
</span>
)}
{event.qitemId ? (
<QueueItemTrigger
data={qitemViewerData(event, queueItem)}
testId={`story-row-${event.kind}`}
className="block w-full border border-outline-variant bg-white/45 px-3 py-2 text-left hard-shadow backdrop-blur-sm hover:bg-white/60"
>
{rowBody}
</QueueItemTrigger>
) : (
<div
data-testid={`story-row-${event.kind}`}
className="w-full border border-outline-variant bg-white/45 px-3 py-2 text-left hard-shadow backdrop-blur-sm"
>
{rowBody}
</div>
<div className="mt-1 font-mono text-[10px] text-stone-900">{event.summary}</div>
{(flow.source || flow.target) && (
<div data-testid={`story-step-flow-${event.kind}`} className="mt-1 flex items-center gap-2 font-mono text-[9px] text-stone-500">
<span className="truncate">{flow.source ?? "unknown source"}</span>
<span className="text-stone-400">-&gt;</span>
<span className="truncate">{flow.target ?? "unresolved target"}</span>
</div>
)}
</button>
{expanded && event.detail && (
<pre data-testid={`story-row-detail-${event.kind}`} className="mt-1 overflow-x-auto bg-stone-50 p-2 font-mono text-[9px] text-stone-700">
{JSON.stringify(event.detail, null, 2)}
</pre>
)}
{event.detail && (
<details className="mt-1">
<summary className="cursor-pointer font-mono text-[9px] uppercase tracking-[0.12em] text-stone-500">
Event detail
</summary>
<pre data-testid={`story-row-detail-${event.kind}`} className="mt-1 overflow-x-auto bg-stone-50 p-2 font-mono text-[9px] text-stone-700">
{JSON.stringify(event.detail, null, 2)}
</pre>
</details>
)}
</div>
);
@@ -1,9 +1,7 @@
// Slice Story View v0 — Tests / Verification tab.
//
// Founder-named load-bearing capability. Per PRD § Bounce Conditions:
// "Drops Tests tab's screenshot + video embed — bounced." This component
// MUST inline-render screenshot images and provide a working <video>
// player when proof packets carry them.
// Load-bearing capability: inline screenshot images and provide a working
// <video> player when proof packets carry them.
//
// Per PRD audit row 6 amendment: real proof-packet layout differs from
// the original PRD proposal — directories like
@@ -16,8 +14,10 @@
// pass/fail extracted from the primary markdown. The actual canonical
// answer is in the markdown body which is rendered inline.
import { useState } from "react";
import type { SliceDetail, ProofPacketRendered } from "../../../hooks/useSlices.js";
import { proofAssetUrl } from "../../../hooks/useSlices.js";
import { ProofImageViewer } from "../../project/ProofImageViewer.js";
const BADGE_CLASSES: Record<ProofPacketRendered["passFailBadge"], string> = {
pass: "border-emerald-300 bg-emerald-50 text-emerald-900",
@@ -26,11 +26,52 @@ const BADGE_CLASSES: Record<ProofPacketRendered["passFailBadge"], string> = {
unknown: "border-stone-300 bg-stone-50 text-stone-700",
};
export function TestsVerificationTab({ sliceName, tests }: { sliceName: string; tests: SliceDetail["tests"] }) {
export function TestsVerificationTab({
sliceName,
tests,
qitemCount,
docsCount,
lastActivityAt,
}: {
sliceName: string;
tests: SliceDetail["tests"];
qitemCount?: number;
docsCount?: number;
lastActivityAt?: string | null;
}) {
if (tests.proofPackets.length === 0) {
return (
<div className="p-4 font-mono text-[10px] text-stone-400" data-testid="tests-empty">
No proof packet found for this slice yet (looks for a directory under dogfood-evidence/ whose name contains the slice name).
<div
className="border border-outline-variant bg-white/20 p-4 font-mono"
data-testid="tests-empty"
>
<div className="text-[10px] uppercase tracking-[0.14em] text-stone-500">
No proof packet matched
</div>
<p
data-testid="tests-empty-reason"
className="mt-2 max-w-2xl text-[11px] leading-relaxed text-stone-700"
>
The proof matcher did not find a dogfood-evidence directory whose
name contains this slice id. Evidence may still exist under the
configured evidence root or under a related mission folder.
</p>
<div
data-testid="tests-empty-diagnostics"
className="mt-3 grid gap-2 text-[10px] text-stone-600 sm:grid-cols-3"
>
<Metric label="Qitems" value={qitemCount ?? 0} />
<Metric label="Indexed files" value={docsCount ?? 0} />
<Metric label="Last activity" value={formatMaybeDate(lastActivityAt ?? null)} />
</div>
<ul
data-testid="tests-empty-next-steps"
className="mt-3 list-disc space-y-1 pl-4 text-[10px] leading-relaxed text-stone-600"
>
<li>Check Artifacts for slice-local files and commit refs.</li>
<li>Check the evidence root for dogfood screenshots or proof notes with related names.</li>
<li>When a proof packet is added with a matching directory name, this tab will render it inline.</li>
</ul>
</div>
);
}
@@ -52,7 +93,24 @@ export function TestsVerificationTab({ sliceName, tests }: { sliceName: string;
);
}
function Metric({ label, value }: { label: string; value: string | number }) {
return (
<div className="border border-outline-variant bg-white/30 px-2 py-1">
<div className="text-[8px] uppercase tracking-[0.12em] text-stone-400">{label}</div>
<div className="mt-0.5 truncate text-stone-900">{value}</div>
</div>
);
}
function formatMaybeDate(ts: string | null): string {
if (!ts) return "unknown";
const date = new Date(ts);
if (Number.isNaN(date.getTime())) return ts;
return date.toLocaleString();
}
function ProofPacketSection({ sliceName, packet }: { sliceName: string; packet: ProofPacketRendered }) {
const [selectedScreenshot, setSelectedScreenshot] = useState<string | null>(null);
return (
<article
className="border border-stone-200 bg-white"
@@ -87,13 +145,20 @@ function ProofPacketSection({ sliceName, packet }: { sliceName: string; packet:
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{packet.screenshots.map((rel) => (
<figure key={rel} className="border border-stone-200">
<img
data-testid={`tests-packet-screenshot-${rel}`}
src={proofAssetUrl(sliceName, rel)}
alt={rel}
loading="lazy"
className="block w-full bg-stone-100"
/>
<button
type="button"
data-testid={`tests-packet-screenshot-open-${rel}`}
onClick={() => setSelectedScreenshot(rel)}
className="block w-full text-left"
>
<img
data-testid={`tests-packet-screenshot-${rel}`}
src={proofAssetUrl(sliceName, rel)}
alt={rel}
loading="lazy"
className="block w-full bg-stone-100"
/>
</button>
<figcaption className="bg-stone-50 px-2 py-1 font-mono text-[9px] text-stone-500 truncate">
{rel}
</figcaption>
@@ -102,6 +167,14 @@ function ProofPacketSection({ sliceName, packet }: { sliceName: string; packet:
</div>
</section>
)}
<ProofImageViewer
sliceName={sliceName}
relPath={selectedScreenshot}
onClose={() => setSelectedScreenshot(null)}
testId="tests-screenshot-viewer"
imageTestId="tests-screenshot-viewer-image"
closeTestId="tests-screenshot-viewer-close"
/>
{packet.videos.length > 0 && (
<section data-testid={`tests-packet-videos-${packet.dirName}`}>
@@ -20,12 +20,13 @@
import { Link } from "@tanstack/react-router";
import { useState } from "react";
import type { SliceDetail } from "../../../hooks/useSlices.js";
import type { SliceDetail, SpecGraphPayload } from "../../../hooks/useSlices.js";
import { SessionPreviewPane } from "../../preview/SessionPreviewPane.js";
import { SliceWorkflowGraph } from "./SliceWorkflowGraph.js";
export function TopologyTab({ topology }: { topology: SliceDetail["topology"] }) {
const { affectedRigs, totalSeats, specGraph } = topology;
const runtimeGraph = specGraph ?? deriveRuntimeGraph(affectedRigs);
const hasAnything = affectedRigs.length > 0 || specGraph !== null;
if (!hasAnything) {
return (
@@ -53,11 +54,11 @@ export function TopologyTab({ topology }: { topology: SliceDetail["topology"] })
</div>
</header>
{specGraph && <SliceWorkflowGraph specGraph={specGraph} />}
{runtimeGraph && <SliceWorkflowGraph specGraph={runtimeGraph} />}
{affectedRigs.length > 0 && (
<div data-testid="topology-rig-listing" className="space-y-3">
{specGraph && (
{runtimeGraph && (
<div className="font-mono text-[10px] uppercase tracking-[0.10em] text-stone-500">
Active seats
</div>
@@ -92,6 +93,30 @@ export function TopologyTab({ topology }: { topology: SliceDetail["topology"] })
);
}
function deriveRuntimeGraph(affectedRigs: SliceDetail["topology"]["affectedRigs"]): SpecGraphPayload | null {
if (affectedRigs.length === 0) return null;
const nodes = affectedRigs.map((rig, index) => ({
stepId: rig.rigName || rig.rigId || `rig-${index + 1}`,
label: rig.rigName || rig.rigId || `Rig ${index + 1}`,
role: `${rig.sessionNames.length} seat${rig.sessionNames.length === 1 ? "" : "s"}`,
preferredTarget: rig.sessionNames[0] ?? null,
isEntry: index === 0,
isCurrent: index === affectedRigs.length - 1,
isTerminal: index === affectedRigs.length - 1,
}));
return {
specName: "runtime-handoff-map",
specVersion: "derived",
nodes,
edges: nodes.slice(0, -1).map((node, index) => ({
fromStepId: node.stepId,
toStepId: nodes[index + 1]!.stepId,
routingType: "direct",
isLoopBack: false,
})),
};
}
// Preview Terminal v0 (PL-018) — clickable seat row.
// Click toggles inline preview for the seat using the session-keyed
// preview alias. Operator wanting persistent / multi-pin behavior
@@ -6,7 +6,7 @@
// button per setting.
//
// Keep this read+write surface small. The CLI (`rig config get/set/reset`)
// is the canonical agent-edit path per founder dialog.
// is the canonical agent-edit path.
import { useState, type ReactNode } from "react";
import {
@@ -192,7 +192,7 @@ export function SettingsTab() {
<div data-testid="settings-tab" className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
<Section title="Workspace">
<SettingsRow label="Workspace root" settingKey="workspace.root" resolved={s["workspace.root"]} testIdPrefix="setting" />
<SettingsRow label="Slices root" settingKey="workspace.slices_root" resolved={s["workspace.slices_root"]} testIdPrefix="setting" />
<SettingsRow label="Mission/slice root" settingKey="workspace.slices_root" resolved={s["workspace.slices_root"]} testIdPrefix="setting" />
<SettingsRow label="Steering path" settingKey="workspace.steering_path" resolved={s["workspace.steering_path"]} testIdPrefix="setting" />
<SettingsRow label="Field notes root" settingKey="workspace.field_notes_root" resolved={s["workspace.field_notes_root"]} testIdPrefix="setting" />
<SettingsRow label="Specs root" settingKey="workspace.specs_root" resolved={s["workspace.specs_root"]} testIdPrefix="setting" />
@@ -114,7 +114,7 @@ export function HybridAgentNode({ data }: { data: HybridAgentNodeData }) {
data-activity-card-state={activityCard.state}
data-activity-card-flash={activityCard.flash ?? "none"}
className={cn(
"group relative h-full w-full select-none border bg-white/30 hard-shadow transition-[background-color,border-color,box-shadow] duration-300",
"group relative h-full w-full select-none border bg-white/40 backdrop-blur-[8px] hard-shadow transition-[background-color,border-color,box-shadow] duration-300",
getActivityCardClasses({
state: activityCard.state,
flash: activityCard.flash,
@@ -54,8 +54,8 @@ export function RigGroupNode({ data }: { data: RigGroupNodeData }) {
className={cn(
"w-full h-full relative flex flex-col cursor-pointer select-none overflow-visible",
collapsed
? "border border-outline-variant bg-white/30 backdrop-blur-[8px] hard-shadow hover:bg-white/40"
: "border border-outline-variant/70 bg-white/[0.10] backdrop-blur-[2px] shadow-[0_0_0_1px_rgba(84,96,115,0.06)]",
? "border border-outline-variant bg-white/40 backdrop-blur-[8px] hard-shadow hover:bg-white/50"
: "border border-outline-variant/70 bg-white/40 backdrop-blur-[8px] shadow-[0_0_0_1px_rgba(84,96,115,0.06)]",
recentActivity && "rig-activity-frame-pulse",
)}
>
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type React from "react";
import { createPortal } from "react-dom";
import { Terminal } from "lucide-react";
@@ -8,7 +8,7 @@ import { cn } from "../../lib/utils.js";
const TERMINAL_PREVIEW_EVENT = "openrig:topology-terminal-preview";
const POPOVER_GAP = 8;
const POPOVER_MARGIN = 8;
const FALLBACK_POPOVER_WIDTH = 320;
const FALLBACK_POPOVER_WIDTH = 408;
const FALLBACK_POPOVER_HEIGHT = 240;
interface TerminalPreviewEventDetail {
@@ -27,6 +27,11 @@ interface PopoverPosition {
top: number;
}
interface ViewportSize {
width: number;
height: number;
}
interface TerminalPreviewPopoverProps {
rigId: string | null | undefined;
logicalId: string;
@@ -52,15 +57,27 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function computePopoverPosition(anchor: AnchorRect, width: number, height: number): PopoverPosition {
const viewportWidth = window.innerWidth || width + POPOVER_MARGIN * 2;
const viewportHeight = window.innerHeight || height + POPOVER_MARGIN * 2;
export function computeTerminalPopoverPosition(
anchor: AnchorRect,
width: number,
height: number,
viewport?: ViewportSize,
): PopoverPosition {
const viewportWidth = viewport?.width ?? (typeof window === "undefined" ? width + POPOVER_MARGIN * 2 : window.innerWidth);
const viewportHeight = viewport?.height ?? (typeof window === "undefined" ? height + POPOVER_MARGIN * 2 : window.innerHeight);
const rightSideLeft = anchor.right + POPOVER_GAP;
const leftSideLeft = anchor.left - width - POPOVER_GAP;
const left = rightSideLeft + width <= viewportWidth - POPOVER_MARGIN ? rightSideLeft : leftSideLeft;
const preferredTop = anchor.top;
const aboveTop = anchor.top - height - POPOVER_GAP;
const top = preferredTop + height <= viewportHeight - POPOVER_MARGIN
? preferredTop
: aboveTop >= POPOVER_MARGIN
? aboveTop
: viewportHeight - height - POPOVER_MARGIN;
return {
left: clamp(left, POPOVER_MARGIN, Math.max(POPOVER_MARGIN, viewportWidth - width - POPOVER_MARGIN)),
top: clamp(anchor.top, POPOVER_MARGIN, Math.max(POPOVER_MARGIN, viewportHeight - height - POPOVER_MARGIN)),
top: clamp(top, POPOVER_MARGIN, Math.max(POPOVER_MARGIN, viewportHeight - height - POPOVER_MARGIN)),
};
}
@@ -78,9 +95,16 @@ export function TerminalPreviewPopover({
const rootRef = useRef<HTMLDivElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
const [anchorRect, setAnchorRect] = useState<AnchorRect | null>(null);
const [position, setPosition] = useState<PopoverPosition | null>(null);
const updatePosition = useCallback(() => {
if (!open) return;
const nextAnchor = rectFromElement(rootRef.current);
const width = popoverRef.current?.offsetWidth || FALLBACK_POPOVER_WIDTH;
const height = popoverRef.current?.offsetHeight || FALLBACK_POPOVER_HEIGHT;
setPosition(computeTerminalPopoverPosition(nextAnchor, width, height));
}, [open]);
useEffect(() => {
const handleOpen = (event: Event) => {
const detail = (event as CustomEvent<TerminalPreviewEventDetail>).detail;
@@ -88,8 +112,7 @@ export function TerminalPreviewPopover({
setOpen(nextOpen);
if (nextOpen) {
const nextAnchor = rectFromElement(rootRef.current);
setAnchorRect(nextAnchor);
setPosition(computePopoverPosition(nextAnchor, FALLBACK_POPOVER_WIDTH, FALLBACK_POPOVER_HEIGHT));
setPosition(computeTerminalPopoverPosition(nextAnchor, FALLBACK_POPOVER_WIDTH, FALLBACK_POPOVER_HEIGHT));
}
};
window.addEventListener(TERMINAL_PREVIEW_EVENT, handleOpen);
@@ -97,9 +120,27 @@ export function TerminalPreviewPopover({
}, [key]);
useLayoutEffect(() => {
if (!open || !anchorRect || !popoverRef.current) return;
setPosition(computePopoverPosition(anchorRect, popoverRef.current.offsetWidth, popoverRef.current.offsetHeight));
}, [anchorRect, open]);
if (!open || !popoverRef.current) return;
updatePosition();
const frame = window.requestAnimationFrame(updatePosition);
return () => window.cancelAnimationFrame(frame);
}, [open, updatePosition]);
useEffect(() => {
if (!open) return undefined;
const handleViewportChange = () => updatePosition();
const observer = typeof ResizeObserver === "undefined" || !popoverRef.current
? null
: new ResizeObserver(handleViewportChange);
if (popoverRef.current) observer?.observe(popoverRef.current);
window.addEventListener("resize", handleViewportChange);
window.addEventListener("scroll", handleViewportChange, true);
return () => {
observer?.disconnect();
window.removeEventListener("resize", handleViewportChange);
window.removeEventListener("scroll", handleViewportChange, true);
};
}, [open, updatePosition]);
useEffect(() => {
if (!open) return undefined;
@@ -127,8 +168,8 @@ export function TerminalPreviewPopover({
data-testid={`${testIdPrefix}-terminal-popover`}
data-reduced-motion={reducedMotion ? "true" : "false"}
className={cn(
"nodrag nopan fixed z-[1000] w-80 bg-stone-950/65 p-1.5 backdrop-blur-sm",
"cursor-default select-text text-stone-50",
"nodrag nopan fixed z-[1000] max-h-[calc(100vh-1rem)] w-[calc(80ch+24px)] max-w-[calc(100vw-1rem)] overflow-hidden bg-stone-950/65 p-1.5 backdrop-blur-sm",
"cursor-default select-text font-mono text-[8px] text-stone-50",
popoverClassName,
)}
style={{ left: position.left, top: position.top }}
+8
View File
@@ -49,6 +49,14 @@ export function useActivityFeed(): UseActivityFeedResult {
if (event.type === "session.discovered" || event.type === "session.vanished") {
queryClient.invalidateQueries({ queryKey: ["discovery"] });
}
if (event.type === "mission_control.action_executed") {
queryClient.invalidateQueries({ queryKey: ["mission-control", "audit"] });
queryClient.invalidateQueries({ queryKey: ["slices"] });
const qitemId = event.payload["qitemId"] as string | undefined;
if (qitemId) {
queryClient.invalidateQueries({ queryKey: ["queue", "item", qitemId] });
}
}
if (event.type === "node.claimed") {
queryClient.invalidateQueries({ queryKey: ["discovery"] });
const rigId = event.payload["rigId"] as string | undefined;
+105 -1
View File
@@ -5,13 +5,15 @@
// as a structured error object so the UI can render a setup hint
// instead of the raw 503.
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useQueries, useQuery } from "@tanstack/react-query";
export type SliceStatus = "active" | "done" | "blocked" | "draft";
export type SliceFilter = "all" | "active" | "done" | "blocked";
export interface SliceListEntry {
name: string;
missionId: string | null;
displayName: string;
railItem: string | null;
status: SliceStatus;
@@ -194,6 +196,8 @@ export interface TopologyRigEntry {
export interface SliceDetail {
name: string;
missionId: string | null;
slicePath: string;
displayName: string;
railItem: string | null;
status: string;
@@ -247,6 +251,44 @@ export function useSliceDetail(name: string | null) {
});
}
export interface SliceDetailsMapResult {
itemsByName: Map<string, SliceDetail>;
isFetching: boolean;
missingNames: string[];
}
export function useSliceDetails(names: string[]): SliceDetailsMapResult {
const uniqueNames = useMemo(
() => Array.from(new Set(names.filter((name) => name.length > 0))).sort(),
[names],
);
const queries = useQueries({
queries: uniqueNames.map((name) => ({
queryKey: ["slices", "detail", name],
queryFn: () => fetchSliceDetail(name),
staleTime: 30_000,
})),
});
return useMemo(() => {
const itemsByName = new Map<string, SliceDetail>();
const missingNames: string[] = [];
uniqueNames.forEach((name, idx) => {
const item = queries[idx]?.data;
if (item) {
itemsByName.set(name, item);
} else if (queries[idx]?.isError) {
missingNames.push(name);
}
});
return {
itemsByName,
isFetching: queries.some((query) => query.isFetching),
missingNames,
};
}, [queries, uniqueNames]);
}
// --- doc body fetcher (Docs tab; lazy on click) ---
export interface SliceDocResponse {
@@ -272,3 +314,65 @@ export function useSliceDoc(name: string | null, relPath: string | null) {
export function proofAssetUrl(sliceName: string, relPath: string): string {
return `/api/slices/${encodeURIComponent(sliceName)}/proof-asset/${encodeURI(relPath)}`;
}
export interface QueueItemDetail {
qitemId: string;
tsCreated: string;
tsUpdated: string;
sourceSession: string;
destinationSession: string;
state: string;
priority: string;
tier: string | null;
tags: string[] | null;
body: string;
closureReason?: string | null;
closureTarget?: string | null;
handedOffTo?: string | null;
blockedOn?: string | null;
}
export interface QueueItemMapResult {
itemsById: Map<string, QueueItemDetail>;
isFetching: boolean;
missingIds: string[];
}
async function fetchQueueItem(qitemId: string): Promise<QueueItemDetail | null> {
const res = await fetch(`/api/queue/${encodeURIComponent(qitemId)}`);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as QueueItemDetail;
}
export function useQueueItemMap(qitemIds: string[]): QueueItemMapResult {
const uniqueIds = useMemo(
() => Array.from(new Set(qitemIds.filter((id) => id.length > 0))).sort(),
[qitemIds],
);
const queries = useQueries({
queries: uniqueIds.map((qitemId) => ({
queryKey: ["queue", "item", qitemId],
queryFn: () => fetchQueueItem(qitemId),
staleTime: 30_000,
})),
});
return useMemo(() => {
const itemsById = new Map<string, QueueItemDetail>();
const missingIds: string[] = [];
uniqueIds.forEach((qitemId, idx) => {
const item = queries[idx]?.data;
if (item) {
itemsById.set(qitemId, item);
} else if (queries[idx]?.status === "success") {
missingIds.push(qitemId);
}
});
return {
itemsById,
isFetching: queries.some((query) => query.isFetching),
missingIds,
};
}, [queries, uniqueIds]);
}
+157 -19
View File
@@ -30,21 +30,135 @@ function asString(v: unknown): string | undefined {
return typeof v === "string" && v.length > 0 ? v : undefined;
}
function pickString(
payload: Record<string, unknown>,
...keys: string[]
): string | undefined {
for (const key of keys) {
const value = asString(payload[key]);
if (value) return value;
}
return undefined;
}
function shortQitemId(qitemId: string | undefined): string | undefined {
if (!qitemId) return undefined;
if (qitemId.length <= 28) return qitemId;
return `${qitemId.slice(0, 18)}...${qitemId.slice(-6)}`;
}
function queueEventLabel(type: string): string {
switch (type) {
case "queue.created":
case "queue.item.created":
return "Queue item created";
case "queue.updated":
case "queue.item.updated":
return "Queue item updated";
case "queue.handed_off":
return "Queue item handed off";
case "queue.claimed":
return "Queue item claimed";
case "queue.unclaimed":
return "Queue item unclaimed";
case "qitem.fallback_routed":
return "Queue item fallback routed";
case "qitem.closure_overdue":
return "Queue item closure overdue";
case "inbox.absorbed":
return "Inbox item absorbed";
case "inbox.denied":
return "Inbox item denied";
default:
return type;
}
}
function isQueueVisibilityEvent(type: string): boolean {
return (
type === "queue.created" ||
type === "queue.updated" ||
type === "queue.claimed" ||
type === "queue.unclaimed" ||
type === "queue.handed_off" ||
type === "queue.item.created" ||
type === "queue.item.updated" ||
type === "qitem.fallback_routed" ||
type === "qitem.closure_overdue" ||
type === "inbox.absorbed" ||
type === "inbox.denied"
);
}
function queueKind(type: string, state: string | undefined): FeedCardKind {
if (type.startsWith("queue.") && type.endsWith(".closed")) {
return "shipped";
}
if (type === "qitem.closure_overdue" || type === "inbox.denied") {
return "action-required";
}
if (state === "human-gate" || state === "pending-approval") {
return "action-required";
}
if (state === "closeout-pending-ratify") {
return "approval";
}
if (state === "done" || state === "closed" || state === "completed" || state === "shipped") {
return "shipped";
}
return "progress";
}
function queueBody(payload: Record<string, unknown>): string | undefined {
const source = pickString(payload, "sourceSession", "source_session", "fromSession");
const destination = pickString(
payload,
"destinationSession",
"destination_session",
"toSession",
"destination",
);
const route =
source && destination
? `${source} -> ${destination}`
: source
? `Source: ${source}`
: destination
? `Destination: ${destination}`
: undefined;
const meta = [
pickString(payload, "priority") ? `priority=${pickString(payload, "priority")}` : undefined,
pickString(payload, "tier") ? `tier=${pickString(payload, "tier")}` : undefined,
pickString(payload, "state", "toState") ? `state=${pickString(payload, "state", "toState")}` : undefined,
pickString(payload, "closureReason") ? `closure=${pickString(payload, "closureReason")}` : undefined,
].filter((item): item is string => Boolean(item));
return [route, meta.length > 0 ? meta.join(" / ") : undefined]
.filter((item): item is string => Boolean(item))
.join("\n") || undefined;
}
function isHumanSeat(session: string | undefined): boolean {
return /^human(?:-[A-Za-z0-9._-]+)?@(kernel|host)$/.test(session ?? "");
}
function classifyEvent(evt: ActivityEvent): FeedCard | null {
const payload = (evt.payload ?? {}) as Record<string, unknown>;
const author =
asString(payload.actor_session) ??
asString(payload.source_session) ??
asString(payload.sender);
const rigId = asString(payload.rig_id) ?? asString(payload.rigId);
const summary =
asString(payload.summary) ??
asString(payload.body) ??
asString(payload.title);
const author = pickString(
payload,
"actor_session",
"actorSession",
"source_session",
"sourceSession",
"fromSession",
"sender",
);
const rigId = pickString(payload, "rig_id", "rigId");
const summary = pickString(payload, "summary", "body", "title");
const base = {
id: `${evt.type}-${evt.seq}`,
title: summary ?? evt.type,
body: undefined as string | undefined,
body: asString(payload.body),
authorSession: author,
rigId,
receivedAt: evt.receivedAt,
@@ -56,15 +170,39 @@ function classifyEvent(evt: ActivityEvent): FeedCard | null {
if (evt.type.startsWith("queue.") && evt.type.endsWith(".closed")) {
return { ...base, kind: "shipped" };
}
if (evt.type === "queue.item.created" || evt.type === "queue.item.updated") {
const state = asString(payload.state);
if (state === "human-gate" || state === "pending-approval") {
return { ...base, kind: "action-required" };
}
if (state === "closeout-pending-ratify") {
return { ...base, kind: "approval" };
}
return { ...base, kind: "progress" };
if (isQueueVisibilityEvent(evt.type)) {
const qitemId = pickString(payload, "qitemId", "qitem_id");
const destination = pickString(
payload,
"destinationSession",
"destination_session",
"toSession",
"destination",
);
const state = pickString(payload, "state", "toState");
const classifiedKind = queueKind(evt.type, state);
const kind =
classifiedKind === "approval"
? "approval"
: isHumanSeat(destination)
? "action-required"
: classifiedKind;
const explicitTitle = pickString(payload, "summary", "title");
const label =
kind === "shipped" && evt.type === "queue.updated"
? "Queue item shipped"
: queueEventLabel(evt.type);
const title =
explicitTitle ??
[label, shortQitemId(qitemId)]
.filter((item): item is string => Boolean(item))
.join(": ");
return {
...base,
title,
body: asString(payload.body) ?? queueBody(payload),
kind,
};
}
if (evt.type.startsWith("workflow.")) {
return { ...base, kind: "progress" };
@@ -0,0 +1,126 @@
import type { MissionStatus } from "../components/MissionStatusBadge.js";
import type { SliceListEntry } from "../hooks/useSlices.js";
export type ProjectSliceRow = {
name: string;
displayName: string;
status: string;
rawStatus: string | null;
qitemCount: number;
hasProofPacket: boolean;
lastActivityAt: string | null;
missionId?: string | null;
railItem?: string | null;
};
export type ProjectMissionBucket = "current" | "archive";
export type ProjectMissionGroup = {
id: string;
label: string;
status: MissionStatus;
slices: ProjectSliceRow[];
};
export const PROJECT_CURRENT_ACTIVITY_WINDOW_MS = 36 * 60 * 60 * 1000;
export function projectSliceFromListEntry(slice: SliceListEntry): ProjectSliceRow {
return {
name: slice.name,
displayName: slice.displayName,
status: slice.status,
rawStatus: slice.rawStatus,
qitemCount: slice.qitemCount,
hasProofPacket: slice.hasProofPacket,
lastActivityAt: slice.lastActivityAt,
missionId: slice.missionId,
railItem: slice.railItem,
};
}
export function isRecentProjectActivity(
lastActivityAt: string | null,
now = Date.now(),
): boolean {
if (!lastActivityAt) return false;
const ts = Date.parse(lastActivityAt);
if (Number.isNaN(ts)) return false;
return now - ts <= PROJECT_CURRENT_ACTIVITY_WINDOW_MS;
}
export function isCurrentProjectSlice(slice: ProjectSliceRow, now = Date.now()): boolean {
if (slice.qitemCount > 0) return true;
if (slice.status === "blocked") return true;
if (slice.status === "active" || slice.status === "draft") {
return !slice.lastActivityAt || isRecentProjectActivity(slice.lastActivityAt, now);
}
return false;
}
export function deriveMissionStatusFromSlices(slices: ProjectSliceRow[]): MissionStatus {
if (slices.length === 0) return "unknown";
const now = Date.now();
if (slices.some((s) => s.status === "blocked" && isCurrentProjectSlice(s, now))) {
return "blocked";
}
if (slices.some((s) => isCurrentProjectSlice(s, now))) return "active";
if (slices.every((s) => s.status === "done")) return "shipped";
return "unknown";
}
export function projectMissionBucket(mission: ProjectMissionGroup): ProjectMissionBucket {
if (mission.slices.some((s) => isCurrentProjectSlice(s))) return "current";
if (mission.slices.length === 0 && mission.status !== "shipped") return "current";
return "archive";
}
export function latestProjectMissionActivity(mission: ProjectMissionGroup): number {
return mission.slices.reduce((latest, slice) => {
if (!slice.lastActivityAt) return latest;
const ts = Date.parse(slice.lastActivityAt);
if (Number.isNaN(ts)) return latest;
return Math.max(latest, ts);
}, 0);
}
export function sortProjectMissions(
a: ProjectMissionGroup,
b: ProjectMissionGroup,
): number {
const activityDelta = latestProjectMissionActivity(b) - latestProjectMissionActivity(a);
if (activityDelta !== 0) return activityDelta;
return a.label.localeCompare(b.label);
}
export function partitionProjectMissions<T extends ProjectMissionGroup>(
missions: T[],
): { current: T[]; archive: T[] } {
const current: T[] = [];
const archive: T[] = [];
for (const mission of missions) {
if (projectMissionBucket(mission) === "current") current.push(mission);
else archive.push(mission);
}
return {
current: current.sort(sortProjectMissions),
archive: archive.sort(sortProjectMissions),
};
}
export function projectSliceMeta(slice: ProjectSliceRow): string {
const parts: string[] = [];
if (slice.qitemCount > 0) {
parts.push(`${slice.qitemCount} qitem${slice.qitemCount === 1 ? "" : "s"}`);
}
const staticStatus =
(slice.status === "active" || slice.status === "draft") && !isCurrentProjectSlice(slice)
? `stale ${slice.status}`
: slice.status;
if (slice.rawStatus && slice.rawStatus !== slice.status) {
parts.push(`${staticStatus} from ${slice.rawStatus}`);
} else {
parts.push(staticStatus);
}
if (slice.hasProofPacket) parts.push("proof");
return parts.join(" · ");
}
+15
View File
@@ -14,17 +14,27 @@ type TopologyEventListener = (event: TopologyEvent, rawData: string) => void;
type TopologyStatusListener = (status: TopologyEventStatus) => void;
const GLOBAL_EVENTS_URL = "/api/events";
const MAX_REPLAY_EVENTS = 100;
interface CachedTopologyEvent {
event: TopologyEvent;
rawData: string;
}
class TopologyEventHub {
private eventSource: EventSource | null = null;
private eventListeners = new Set<TopologyEventListener>();
private statusListeners = new Set<TopologyStatusListener>();
private replayEvents: CachedTopologyEvent[] = [];
private connected = false;
private reconnecting = false;
subscribe(listener: TopologyEventListener): () => void {
this.eventListeners.add(listener);
this.ensureConnected();
for (const cached of this.replayEvents) {
listener(cached.event, cached.rawData);
}
return () => {
this.eventListeners.delete(listener);
this.closeIfIdle();
@@ -77,6 +87,10 @@ class TopologyEventHub {
}
if (!parsed || typeof parsed !== "object") return;
const topologyEvent = parsed as TopologyEvent;
this.replayEvents = [
...this.replayEvents,
{ event: topologyEvent, rawData: data },
].slice(-MAX_REPLAY_EVENTS);
for (const listener of [...this.eventListeners]) {
listener(topologyEvent, data);
}
@@ -89,6 +103,7 @@ class TopologyEventHub {
this.eventSource.close();
this.eventSource = null;
}
this.replayEvents = [];
this.connected = false;
this.reconnecting = false;
}
+8
View File
@@ -52,6 +52,7 @@ import {
MissionScopePage,
SliceScopePage,
} from "./components/project/ScopePages.js";
import { ProjectGraphicsPreview } from "./components/lab/ProjectGraphicsPreview.js";
// Root route — wraps everything in AppShell
const rootRoute = createRootRoute({
@@ -162,6 +163,12 @@ const searchRoute = createRoute({
component: AuditHistoryView,
});
const projectGraphicsPreviewRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/lab/project-graphics-preview",
component: ProjectGraphicsPreview,
});
// =====================================================================
// Existing routes preserved per code-map AFTER tree (not in delete list)
// =====================================================================
@@ -359,6 +366,7 @@ const routeTree = rootRoute.addChildren([
specsKindRoute,
settingsRoute,
searchRoute,
projectGraphicsPreviewRoute,
// Preserved existing routes
rigDetailRoute,
liveNodeDetailsRoute,
+41
View File
@@ -94,6 +94,11 @@ function HookHarness() {
);
}
function ReplayHarness({ testId }: { testId: string }) {
const { events } = useActivityFeed();
return <span data-testid={testId}>{events.map((event) => event.type).join(",")}</span>;
}
function renderHookHarness() {
const queryClient = createTestQueryClient();
return render(
@@ -343,6 +348,42 @@ describe("Activity Feed", () => {
});
});
it("replays recent events to Feed consumers mounted after the shared hub is already connected", async () => {
const queryClient = createTestQueryClient();
const { rerender } = render(
<QueryClientProvider client={queryClient}>
<ReplayHarness testId="early-feed" />
</QueryClientProvider>
);
await waitFor(() => expect(instances).toHaveLength(1));
act(() => {
getLastInstance().simulateMessage(JSON.stringify({
type: "queue.created",
qitemId: "qitem-late-feed",
sourceSession: "orch@rig",
destinationSession: "driver@rig",
seq: 91,
createdAt: "2026-05-08T00:00:00.000Z",
}));
});
await waitFor(() => {
expect(screen.getByTestId("early-feed").textContent).toContain("queue.created");
});
rerender(
<QueryClientProvider client={queryClient}>
<ReplayHarness testId="early-feed" />
<ReplayHarness testId="late-feed" />
</QueryClientProvider>
);
await waitFor(() => {
expect(screen.getByTestId("late-feed").textContent).toContain("queue.created");
});
});
// Test 10: Package entries navigate to /bootstrap because package installs are bootstrap-adjacent legacy tools
it("package.installed entry navigates to /bootstrap on click", async () => {
const events = [
@@ -15,8 +15,9 @@ describe("Dashboard card vellum surface", () => {
"SettingsCard.tsx",
];
expect(dashboardCardSurfaceClass).toContain("bg-white/30");
expect(dashboardCardSurfaceClass).toContain("hover:bg-white/40");
expect(dashboardCardSurfaceClass).toContain("bg-white/40");
expect(dashboardCardSurfaceClass).toContain("backdrop-blur-[8px]");
expect(dashboardCardSurfaceClass).toContain("hover:bg-white/50");
expect(dashboardCardSurfaceClass).toContain("hover:hard-shadow-hover");
for (const file of cardFiles) {
@@ -294,6 +294,23 @@ describe("SharedDetailDrawer (Phase 4) routes selection.type to the correct view
expect(getByTestId("sub-spec-preview")).toBeTruthy();
});
it("outside click closes the drawer, while inside click keeps it open", () => {
const onClose = vi.fn();
const selection: DrawerSelection = {
type: "qitem",
data: { qitemId: "qitem-routing-test", body: "x" },
};
const { getByTestId } = render(
<SharedDetailDrawer selection={selection} onClose={onClose} {...NOOP_PROPS} />,
);
fireEvent.pointerDown(getByTestId("shared-detail-drawer"));
expect(onClose).not.toHaveBeenCalled();
fireEvent.pointerDown(getByTestId("shared-detail-drawer-outside"));
expect(onClose).toHaveBeenCalledTimes(1);
});
// V1 polish slice Phase 5.1 P5.1-D2: 'seat-detail' kind RETIRED from
// DrawerSelection union; navigation to /topology/seat/$rigId/$logicalId
// center page (LiveNodeDetails) replaces the drawer-mounted variant.
+91
View File
@@ -31,6 +31,90 @@ describe("classifyFeed — SC-15 5 card types", () => {
expect(cards[0]?.kind).toBe("approval");
});
it("queue.created daemon event → visible progress card", () => {
const cards = classifyFeed([
evt("queue.created", {
qitemId: "qitem-20260507-abcdef12",
sourceSession: "orch-lead@openrig-velocity",
destinationSession: "driver@openrig-velocity",
priority: "routine",
tier: "mode2",
}),
]);
expect(cards[0]?.kind).toBe("progress");
expect(cards[0]?.title).toBe("Queue item created: qitem-20260507-abcdef12");
expect(cards[0]?.body).toContain(
"orch-lead@openrig-velocity -> driver@openrig-velocity",
);
expect(cards[0]?.body).toContain("priority=routine / tier=mode2");
expect(cards[0]?.authorSession).toBe("orch-lead@openrig-velocity");
});
it("queue.created for a human seat → action-required card", () => {
const cards = classifyFeed([
evt("queue.created", {
qitemId: "qitem-human-review",
sourceSession: "qa@openrig-dogfood",
destinationSession: "human@host",
priority: "urgent",
}),
]);
expect(cards[0]?.kind).toBe("action-required");
expect(cards[0]?.body).toContain("qa@openrig-dogfood -> human@host");
});
it("queue.created closeout-pending-ratify for a human seat → approval card", () => {
const cards = classifyFeed([
evt("queue.created", {
qitemId: "qitem-approval",
sourceSession: "qa@openrig-dogfood",
destinationSession: "human@host",
state: "closeout-pending-ratify",
priority: "urgent",
}),
]);
expect(cards[0]?.kind).toBe("approval");
});
it("queue.updated state=done → shipped card", () => {
const cards = classifyFeed([
evt("queue.updated", {
qitemId: "qitem-shipped",
actorSession: "driver@openrig-velocity",
toState: "done",
}),
]);
expect(cards[0]?.kind).toBe("shipped");
expect(cards[0]?.title).toBe("Queue item shipped: qitem-shipped");
expect(cards[0]?.body).toContain("state=done");
});
it("queue.handed_off daemon event → visible progress card", () => {
const cards = classifyFeed([
evt("queue.handed_off", {
qitemId: "qitem-20260507-handoff",
fromSession: "driver@openrig-velocity",
toSession: "guard@openrig-velocity",
}),
]);
expect(cards[0]?.kind).toBe("progress");
expect(cards[0]?.title).toBe("Queue item handed off: qitem-20260507-handoff");
expect(cards[0]?.body).toContain(
"driver@openrig-velocity -> guard@openrig-velocity",
);
expect(cards[0]?.authorSession).toBe("driver@openrig-velocity");
});
it("qitem.closure_overdue daemon event → action-required", () => {
const cards = classifyFeed([
evt("qitem.closure_overdue", {
qitemId: "qitem-20260507-overdue",
destinationSession: "driver@openrig-velocity",
}),
]);
expect(cards[0]?.kind).toBe("action-required");
});
it("queue.item.shipped.closed → SHIPPED (client-synthesize per SC-17)", () => {
const cards = classifyFeed([evt("queue.item.shipped.closed", {})]);
expect(cards[0]?.kind).toBe("shipped");
@@ -76,4 +160,11 @@ describe("classifyFeed — SC-15 5 card types", () => {
]);
expect(cards[0]?.authorSession).toBe("driver@vel");
});
it("extracts authorSession from camelCase actor payload", () => {
const cards = classifyFeed([
evt("queue.updated", { actorSession: "driver@vel" }),
]);
expect(cards[0]?.authorSession).toBe("driver@vel");
});
});
@@ -8,6 +8,7 @@ import path from "node:path";
import { ActivityRing } from "../src/components/topology/ActivityRing.js";
import { HotPotatoEdge } from "../src/components/topology/HotPotatoEdge.js";
import { HybridAgentNode } from "../src/components/topology/HybridTopologyNodes.js";
import { computeTerminalPopoverPosition } from "../src/components/topology/TerminalPreviewPopover.js";
import { fallbackActivityCardState, getActivityCardClasses } from "../src/components/topology/activity-card-visuals.js";
import {
HOT_POTATO_WITHIN_RIG_DURATION_MS,
@@ -206,7 +207,13 @@ describe("P5.3 ActivityRing and HotPotatoEdge", () => {
expect(driverPopover.parentElement).toBe(document.body);
expect(driverPopover.className).toContain("fixed");
expect(driverPopover.className).toContain("z-[1000]");
expect(driverPopover.className).toContain("w-[calc(80ch+24px)]");
expect(driverPopover.className).toContain("max-w-[calc(100vw-1rem)]");
expect(driverPopover.className).toContain("max-h-[calc(100vh-1rem)]");
expect(driverPopover.className).toContain("overflow-hidden");
expect(driverPopover.className).toContain("bg-stone-950/65");
expect(driverPopover.className).toContain("font-mono");
expect(driverPopover.className).toContain("text-[8px]");
expect(driverPopover.className).toContain("text-stone-50");
expect(driverPopover.className).toContain("backdrop-blur-sm");
expect(driverPopover.className).not.toContain("border");
@@ -225,6 +232,32 @@ describe("P5.3 ActivityRing and HotPotatoEdge", () => {
expect(screen.queryByTestId("hybrid-driver-terminal-popover")).toBeNull();
});
it("terminal popover flips above the anchor when bottom viewport space would clip it", () => {
const position = computeTerminalPopoverPosition(
{ left: 1040, right: 1080, top: 582, bottom: 610 },
400,
300,
{ width: 1280, height: 720 },
);
expect(position.top).toBe(274);
expect(position.top + 300).toBeLessThanOrEqual(712);
expect(position.left + 400).toBeLessThanOrEqual(1272);
});
it("terminal popover clamps vertically when neither side has full room", () => {
const position = computeTerminalPopoverPosition(
{ left: 24, right: 64, top: 120, bottom: 148 },
400,
700,
{ width: 640, height: 720 },
);
expect(position.top).toBe(12);
expect(position.top + 700).toBeLessThanOrEqual(712);
expect(position.left).toBe(72);
});
it("source scan keeps activity and packet layers production-reachable", () => {
const srcRoot = path.resolve(__dirname, "../src");
const host = readFileSync(path.join(srcRoot, "components/topology/HostMultiRigGraph.tsx"), "utf8");
@@ -265,12 +298,13 @@ describe("P5.3 ActivityRing and HotPotatoEdge", () => {
expect(terminalPopover).not.toMatch(/absolute left-full/);
expect(terminalPopover).not.toMatch(/terminal preview/);
expect(terminalPopover).not.toMatch(/terminal-close/);
expect(rigGroupNode).toMatch(/bg-white\/30/);
expect(rigGroupNode).toMatch(/hover:bg-white\/40/);
expect(rigGroupNode).toMatch(/bg-white\/\[0\.10\]/);
expect(rigGroupNode).toMatch(/bg-white\/40/);
expect(rigGroupNode).toMatch(/hover:bg-white\/50/);
expect(rigGroupNode).toMatch(/backdrop-blur-\[8px\]/);
expect(rigGroupNode).not.toMatch(/bg-white\/\[0\.14\]/);
expect(rigGroupNode).not.toMatch(/hover:bg-white\/60/);
expect(hybridNodes).toMatch(/bg-white\/30/);
expect(hybridNodes).toMatch(/bg-white\/40/);
expect(hybridNodes).toMatch(/backdrop-blur-\[8px\]/);
expect(hybridNodes).toMatch(/bg-stone-50\/25/);
expect(activityCards).toMatch(/activity-card-active/);
expect(css).toMatch(/background-color: rgba\(255, 255, 255, 0\.72\)/);
+113 -7
View File
@@ -81,7 +81,7 @@ interface RenderTreeOpts {
// Map "<root>:<path>" → file content.
reads?: Record<string, { content: string; mtime?: string }>;
// useSlices response.
slices?: Array<{ name: string; displayName: string; railItem: string | null; status: string; rawStatus: string | null; qitemCount: number; hasProofPacket: boolean; lastActivityAt: string | null }>;
slices?: Array<{ name: string; missionId?: string | null; displayName: string; railItem: string | null; status: string; rawStatus: string | null; qitemCount: number; hasProofPacket: boolean; lastActivityAt: string | null }>;
}
function setupFetch(opts: RenderTreeOpts) {
@@ -197,6 +197,7 @@ describe("ProjectTreeView P5-5/P5-6 mission discovery", () => {
slices: [],
});
expect(await findByTestId("project-mission-rsi-v2")).toBeTruthy();
expect((await findByTestId("project-mission-link-rsi-v2")).getAttribute("href")).toBe("/project/mission/rsi-v2");
expect(await findByTestId("project-mission-shell-redesign-v1")).toBeTruthy();
});
@@ -216,7 +217,7 @@ describe("ProjectTreeView P5-5/P5-6 mission discovery", () => {
expect(queryByTestId("project-mission-README.md")).toBeNull();
});
it("falls back to railItem grouping when no allowlist root contains workspace.root", async () => {
it("falls back to indexed slice grouping when no allowlist root contains workspace.root", async () => {
const { findByTestId } = renderTree({
workspaceRoot: "/Users/admin/.openrig/workspace",
roots: [{ name: "elsewhere", path: "/Users/admin/code/elsewhere" }],
@@ -237,7 +238,7 @@ describe("ProjectTreeView P5-5/P5-6 mission discovery", () => {
expect(await findByTestId("project-mission-rsi-v2")).toBeTruthy();
});
it("falls back to railItem grouping when allowlist is empty", async () => {
it("falls back to indexed slice grouping when allowlist is empty", async () => {
const { findByTestId } = renderTree({
workspaceRoot: "/Users/admin/.openrig/workspace",
roots: [],
@@ -258,6 +259,53 @@ describe("ProjectTreeView P5-5/P5-6 mission discovery", () => {
expect(await findByTestId("project-mission-unsorted")).toBeTruthy();
});
it("separates live qitem-backed work from stale archived seed slices", async () => {
const { findByTestId, queryByTestId } = renderTree({
workspaceRoot: "/Users/admin/.openrig/workspace",
roots: [],
slices: [
{
name: "idea-ledger",
displayName: "Idea Ledger RSI v2 proof slice",
railItem: "RSI-V2-PROOF",
status: "done",
rawStatus: "done",
qitemCount: 78,
hasProofPacket: false,
lastActivityAt: "2026-05-07T22:06:36.083Z",
},
{
name: "seed-slice-active",
displayName: "seed-slice-active",
railItem: null,
status: "active",
rawStatus: "active",
qitemCount: 0,
hasProofPacket: false,
lastActivityAt: "2000-01-01T00:00:00.000Z",
},
],
});
expect(await findByTestId("project-discovery-degraded")).toBeTruthy();
expect((await findByTestId("project-mission-section-current")).textContent).toContain(
"Current Work · 1",
);
expect((await findByTestId("project-mission-section-archive")).textContent).toContain(
"Archive · 1",
);
const liveMission = await findByTestId("project-mission-RSI-V2-PROOF");
expect(liveMission.getAttribute("data-mission-bucket")).toBe("current");
expect((await findByTestId("project-slice-idea-ledger-meta")).textContent).toContain(
"78 qitems",
);
const archiveMission = await findByTestId("project-mission-unsorted");
expect(archiveMission.getAttribute("data-mission-bucket")).toBe("archive");
expect(queryByTestId("project-slice-seed-slice-active")).toBeNull();
});
it("workspace.root unconfigured renders the no-workspace empty-state (Phase 3 A5 behavior preserved)", async () => {
const { findByTestId } = renderTree({
workspaceRoot: null,
@@ -267,8 +315,8 @@ describe("ProjectTreeView P5-5/P5-6 mission discovery", () => {
expect(await findByTestId("project-no-workspace")).toBeTruthy();
});
it("matches slices to filesystem missions by railItem; orphans go to 'unsorted'", async () => {
const { findByTestId, queryByTestId } = renderTree({
it("matches slices to filesystem missions by missionId first; unmatched mission keys stay reachable", async () => {
const { findByTestId } = renderTree({
workspaceRoot: "/Users/admin/.openrig/workspace",
roots: [{ name: "workspace", path: "/Users/admin/.openrig/workspace" }],
listings: {
@@ -280,8 +328,9 @@ describe("ProjectTreeView P5-5/P5-6 mission discovery", () => {
slices: [
{
name: "slice-rsi",
missionId: "rsi-v2",
displayName: "RSI Slice",
railItem: "rsi-v2",
railItem: null,
status: "active",
rawStatus: "active",
qitemCount: 0,
@@ -301,6 +350,63 @@ describe("ProjectTreeView P5-5/P5-6 mission discovery", () => {
],
});
expect(await findByTestId("project-mission-rsi-v2")).toBeTruthy();
expect(await findByTestId("project-mission-unsorted")).toBeTruthy();
expect(await findByTestId("project-mission-no-such-mission")).toBeTruthy();
});
it("preserves unmatched legacy rail groups when filesystem missions are available", async () => {
const { findByTestId } = renderTree({
workspaceRoot: "/Users/admin/.openrig/workspace",
roots: [{ name: "workspace", path: "/Users/admin/.openrig/workspace" }],
listings: {
"workspace:missions": [{ name: "demo-seed", type: "dir" }],
},
reads: {
"workspace:missions/demo-seed/PROGRESS.md": { content: "---\nstatus: active\n---" },
},
slices: [
{
name: "idea-ledger-find-ideas-cycle-4",
missionId: "demo-seed",
displayName: "Find Ideas Cycle 4",
railItem: null,
status: "active",
rawStatus: "active",
qitemCount: 1,
hasProofPacket: false,
lastActivityAt: "2026-05-08T00:00:00.000Z",
},
{
name: "idea-ledger",
displayName: "Idea Ledger RSI v2 proof slice",
railItem: "RSI-V2-PROOF",
status: "done",
rawStatus: "done",
qitemCount: 78,
hasProofPacket: false,
lastActivityAt: "2026-05-07T22:06:36.083Z",
},
{
name: "seed-slice-active",
displayName: "seed-slice-active",
railItem: null,
status: "active",
rawStatus: "active",
qitemCount: 0,
hasProofPacket: false,
lastActivityAt: "2000-01-01T00:00:00.000Z",
},
],
});
expect((await findByTestId("project-mission-section-current")).textContent).toContain(
"Current Work · 2",
);
expect((await findByTestId("project-mission-section-archive")).textContent).toContain(
"Archive · 1",
);
expect(await findByTestId("project-slice-idea-ledger-find-ideas-cycle-4")).toBeTruthy();
expect((await findByTestId("project-mission-demo-seed")).getAttribute("data-mission-bucket")).toBe("current");
expect((await findByTestId("project-mission-RSI-V2-PROOF")).getAttribute("data-mission-bucket")).toBe("current");
expect((await findByTestId("project-mission-unsorted")).getAttribute("data-mission-bucket")).toBe("archive");
});
});
+36 -2
View File
@@ -40,12 +40,21 @@ function renderSliceScope(opts: {
const setSelection = vi.fn();
// Mock /api/slices/:name response.
mockFetch.mockImplementation(async (url: string) => {
if (url.includes(`/api/slices/${opts.sliceId}/doc/`)) {
return new Response(JSON.stringify({ relPath: "README.md", content: "# Readme" }), { status: 200 });
}
if (url.includes(`/api/slices/${opts.sliceId}`)) {
if (opts.status && opts.status !== 200) {
return new Response("not found", { status: opts.status });
}
return new Response(JSON.stringify(opts.detail), { status: 200 });
}
if (url.includes("/api/queue/qitem-A")) {
return new Response(JSON.stringify(makeQueueItem("qitem-A", "Body for qitem-A")));
}
if (url.includes("/api/queue/qitem-B")) {
return new Response(JSON.stringify(makeQueueItem("qitem-B", "Body for qitem-B")));
}
return new Response("[]");
});
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -78,6 +87,21 @@ function renderSliceScope(opts: {
return { setSelection, ...utils };
}
function makeQueueItem(qitemId: string, body: string) {
return {
qitemId,
tsCreated: "2026-05-06T18:00:00Z",
tsUpdated: "2026-05-06T18:00:00Z",
sourceSession: "source@rig",
destinationSession: "dest@rig",
state: "in-progress",
priority: "routine",
tier: "mode2",
tags: ["demo"],
body,
};
}
function makeDetail(overrides: Partial<SliceDetail> = {}): SliceDetail {
return {
name: "idea-ledger",
@@ -186,10 +210,19 @@ describe("SliceScopePage P5-2 tab content piping", () => {
await findByTestId("project-tab-nav");
fireEvent.click(container.querySelector("[data-testid='project-tab-queue']")!);
const trigger = await findByTestId("slice-queue-trigger-qitem-A");
await waitFor(() => expect(trigger.textContent).toContain("Body for qitem-A"));
fireEvent.click(trigger);
expect(setSelection).toHaveBeenCalledWith({
type: "qitem",
data: { qitemId: "qitem-A" },
data: {
qitemId: "qitem-A",
source: "source@rig",
destination: "dest@rig",
state: "in-progress",
tags: ["demo"],
createdAt: "2026-05-06T18:00:00Z",
body: "Body for qitem-A",
},
});
});
@@ -224,7 +257,8 @@ describe("SliceScopePage P5-2 tab content piping", () => {
});
await findByTestId("project-tab-nav");
fireEvent.click(container.querySelector("[data-testid='project-tab-tests']")!);
// TestsVerificationTab renders empty-state when no proofPackets.
// TestsVerificationTab renders a diagnostic empty-state when no proofPackets.
expect(await findByTestId("tests-empty")).toBeTruthy();
expect(container.querySelector("[data-testid='tests-empty-diagnostics']")).toBeTruthy();
});
});
+221
View File
@@ -154,6 +154,227 @@ describe("FeedCard P5-1 wiring: show-context QueueItemTrigger", () => {
expect(arg.data.state).toBe("human-gate");
expect(arg.data.body).toBe("Authorize v0.3.0 RC tag/push?");
});
it("show-context handles queue.created camelCase daemon payloads", async () => {
const card = makeCard({
id: "queue-created-1",
kind: "progress",
title: "Queue item created",
body: "orch-lead@openrig-velocity -> driver@openrig-velocity",
authorSession: "orch-lead@openrig-velocity",
source: {
seq: 3,
type: "queue.created",
payload: {
qitemId: "qitem-20260507-daemon",
sourceSession: "orch-lead@openrig-velocity",
destinationSession: "driver@openrig-velocity",
priority: "routine",
tier: "mode2",
},
createdAt: "2026-05-07T21:18:52Z",
receivedAt: 1_000_000,
} as FeedCardModel["source"],
});
const { setSelection, findByTestId } = renderWithRouterAndQuery(
<FeedCard card={card} />,
);
const trigger = await findByTestId(`feed-card-show-context-${card.id}`);
fireEvent.click(trigger);
expect(setSelection).toHaveBeenCalledOnce();
const arg = setSelection.mock.calls[0][0];
expect(arg.type).toBe("qitem");
expect(arg.data.qitemId).toBe("qitem-20260507-daemon");
expect(arg.data.source).toBe("orch-lead@openrig-velocity");
expect(arg.data.destination).toBe("driver@openrig-velocity");
expect(arg.data.body).toBe(
"orch-lead@openrig-velocity -> driver@openrig-velocity",
);
});
it("prefers hydrated queue body and renders proof screenshots for shipped cards", async () => {
const card = makeCard({
id: "queue-shipped-1",
kind: "shipped",
title: "Queue item shipped: qitem-demo",
body: "metadata-only fallback",
source: {
seq: 4,
type: "queue.updated",
payload: { qitemId: "qitem-demo", toState: "done" },
createdAt: "2026-05-07T21:18:52Z",
receivedAt: 1_000_000,
} as FeedCardModel["source"],
});
const { findByTestId, findByText } = renderWithRouterAndQuery(
<FeedCard
card={card}
queueItem={{
qitemId: "qitem-demo",
tsCreated: "2026-05-07T21:18:52Z",
tsUpdated: "2026-05-07T21:20:00Z",
sourceSession: "driver@rig",
destinationSession: "human@host",
state: "done",
priority: "urgent",
tier: "fast",
tags: ["idea-ledger-triage-ideas-cycle-4"],
body: "Review the completed proof packet and screenshots for the triage slice.",
}}
proofPreview={{
sliceName: "idea-ledger-triage-ideas-cycle-4",
displayName: "Idea Ledger triage ideas cycle 4",
passFailBadge: "pass",
screenshots: ["screenshots/for-you-live-human-pending-final.png"],
}}
/>,
);
expect(await findByText("Review the completed proof packet and screenshots for the triage slice.")).toBeTruthy();
expect(await findByTestId(`feed-card-proof-preview-${card.id}`)).toBeTruthy();
const img = await findByTestId("feed-card-proof-screenshot-screenshots/for-you-live-human-pending-final.png") as HTMLImageElement;
expect(img.getAttribute("src")).toContain("/api/slices/idea-ledger-triage-ideas-cycle-4/proof-asset/");
});
it("renders approve, deny, and route actions for actionable human queue cards", async () => {
const card = makeCard();
const { findByTestId, queryByTestId } = renderWithRouterAndQuery(
<FeedCard
card={card}
queueItem={{
qitemId: "qitem-20260506-test",
tsCreated: "2026-05-06T18:00:00Z",
tsUpdated: "2026-05-06T18:00:00Z",
sourceSession: "orch-lead@openrig-velocity",
destinationSession: "human@host",
state: "human-gate",
priority: "urgent",
tier: "fast",
tags: ["human-review"],
body: "Review this proof packet and choose a queue action.",
}}
/>,
);
expect(await findByTestId(`feed-card-actions-${card.id}`)).toBeTruthy();
expect(await findByTestId("mc-verb-approve")).toBeTruthy();
expect(await findByTestId("mc-verb-deny")).toBeTruthy();
expect(await findByTestId("mc-verb-route")).toBeTruthy();
expect(queryByTestId("mc-verb-handoff")).toBeNull();
});
it("renders approval outcome instead of action controls after an action lands", async () => {
const card = makeCard();
const { findByTestId, findByText, queryByTestId } = renderWithRouterAndQuery(
<FeedCard
card={card}
queueItem={{
qitemId: "qitem-20260506-test",
tsCreated: "2026-05-06T18:00:00Z",
tsUpdated: "2026-05-06T18:05:00Z",
sourceSession: "orch-lead@openrig-velocity",
destinationSession: "human-wrandom@kernel",
state: "pending",
priority: "urgent",
tier: "fast",
tags: ["approval"],
body: "Review this proof packet and choose a queue action.",
}}
actionOutcome={{
verb: "approve",
actorSession: "human-wrandom@kernel",
actedAt: "2026-05-06T18:06:00Z",
state: "done",
}}
/>,
);
expect(await findByTestId("feed-card-action-outcome")).toBeTruthy();
expect(await findByText("Approved by human-wrandom@kernel.")).toBeTruthy();
expect(queryByTestId(`feed-card-actions-${card.id}`)).toBeNull();
expect(queryByTestId("mc-verb-approve")).toBeNull();
});
it("renders route outcome with the destination and hides stale action controls", async () => {
const card = makeCard();
const { findByText, queryByTestId } = renderWithRouterAndQuery(
<FeedCard
card={card}
actionOutcome={{
verb: "route",
actorSession: "human@host",
actedAt: "2026-05-06T18:06:00Z",
state: "handed-off",
destinationSession: "driver@openrig-velocity",
}}
/>,
);
expect(await findByText("Routed by human@host to driver@openrig-velocity.")).toBeTruthy();
expect(queryByTestId(`feed-card-actions-${card.id}`)).toBeNull();
});
it("hides actions for terminal handed-off queue state even before audit hydration", async () => {
const card = makeCard();
const { findByTestId, findByText, queryByTestId } = renderWithRouterAndQuery(
<FeedCard
card={card}
queueItem={{
qitemId: "qitem-20260506-test",
tsCreated: "2026-05-06T18:00:00Z",
tsUpdated: "2026-05-06T18:05:00Z",
sourceSession: "orch-lead@openrig-velocity",
destinationSession: "human@host",
state: "handed-off",
priority: "urgent",
tier: "fast",
tags: ["approval"],
body: "Review this proof packet and choose a queue action.",
closureReason: "handed_off_to",
closureTarget: "driver@openrig-velocity",
handedOffTo: "driver@openrig-velocity",
}}
/>,
);
expect(await findByTestId("feed-card-action-outcome")).toBeTruthy();
expect(await findByText("Routed by human@host to driver@openrig-velocity.")).toBeTruthy();
expect(queryByTestId(`feed-card-actions-${card.id}`)).toBeNull();
});
it("does not render action controls for completed shipped cards", async () => {
const card = makeCard({
id: "queue-shipped-2",
kind: "shipped",
title: "Queue item shipped: qitem-demo",
source: {
seq: 5,
type: "queue.updated",
payload: { qitemId: "qitem-demo", toState: "done" },
createdAt: "2026-05-07T21:18:52Z",
receivedAt: 1_000_000,
} as FeedCardModel["source"],
});
const { findByTestId, queryByTestId } = renderWithRouterAndQuery(
<FeedCard
card={card}
queueItem={{
qitemId: "qitem-demo",
tsCreated: "2026-05-07T21:18:52Z",
tsUpdated: "2026-05-07T21:20:00Z",
sourceSession: "driver@rig",
destinationSession: "human@host",
state: "done",
priority: "urgent",
tier: "fast",
tags: ["proof"],
body: "Completed proof packet.",
}}
/>,
);
expect(await findByTestId("feed-card-shipped")).toBeTruthy();
expect(queryByTestId(`feed-card-actions-${card.id}`)).toBeNull();
});
});
// V1 polish slice Phase 5.1 P5.1-D2: TopologyTreeView SeatLeaf details
@@ -32,7 +32,9 @@ describe("polish-8 demo-readiness source guards", () => {
it("TopologyTab reaches the React Flow workflow graph instead of the adjacency panel", () => {
const topologySrc = read("../src/components/slices/tabs/TopologyTab.tsx");
const graphSrc = read("../src/components/slices/tabs/SliceWorkflowGraph.tsx");
expect(topologySrc).toContain("<SliceWorkflowGraph specGraph={specGraph} />");
expect(topologySrc).toContain("const runtimeGraph = specGraph ?? deriveRuntimeGraph(affectedRigs);");
expect(topologySrc).toContain("{runtimeGraph && <SliceWorkflowGraph specGraph={runtimeGraph} />}");
expect(topologySrc).toContain("function deriveRuntimeGraph");
expect(topologySrc).not.toContain("function SpecGraphPanel");
expect(graphSrc).toContain("ReactFlow");
expect(graphSrc).toContain("dagre.layout");
+1 -1
View File
@@ -43,7 +43,7 @@ function settingsResponse(overrides: Record<string, { value: unknown; source: st
"transcripts.enabled": { value: true, source: "default", defaultValue: true },
"transcripts.path": { value: "/tmp", source: "default", defaultValue: "/tmp" },
"workspace.root": { value: "/tmp/ws", source: "default", defaultValue: "/tmp/ws" },
"workspace.slices_root": { value: "/tmp/ws/slices", source: "default", defaultValue: "/tmp/ws/slices" },
"workspace.slices_root": { value: "/tmp/ws/missions", source: "default", defaultValue: "/tmp/ws/missions" },
"workspace.steering_path": { value: "/tmp/ws/STEERING.md", source: "default", defaultValue: "/tmp/ws/STEERING.md" },
"workspace.field_notes_root": { value: "/tmp/ws/field-notes", source: "default", defaultValue: "/tmp/ws/field-notes" },
"workspace.specs_root": { value: "/tmp/ws/specs", source: "default", defaultValue: "/tmp/ws/specs" },
@@ -0,0 +1,242 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
createMemoryHistory,
RouterProvider,
createRouter,
createRootRoute,
createRoute,
Outlet,
} from "@tanstack/react-router";
import { MissionScopePage, WorkspaceScopePage } from "../src/components/project/ScopePages.js";
import type { SliceDetail } from "../src/hooks/useSlices.js";
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;
beforeEach(() => {
mockFetch.mockReset();
});
afterEach(() => {
cleanup();
});
function makeDetail(name: string, missionId: string | null, qitemIds: string[]): SliceDetail {
return {
name,
missionId,
slicePath: `/workspace/${name}`,
displayName: name,
railItem: missionId,
status: "active",
rawStatus: "active",
qitemIds,
commitRefs: ["abc1234"],
lastActivityAt: "2026-05-07T22:06:36.083Z",
workflowBinding: null,
story: {
events: qitemIds[0] ? [{
ts: "2026-05-07T22:06:36.083Z",
kind: "queue.created",
actorSession: "driver@rig",
qitemId: qitemIds[0],
phase: null,
summary: `Created ${qitemIds[0]} for ${name}.`,
detail: { sourceSession: "driver@rig", destinationSession: "human@host" },
}] : [],
phaseDefinitions: null,
},
acceptance: { totalItems: 1, doneItems: 1, percentage: 100, items: [], closureCallout: null, currentStep: null },
decisions: { rows: [] },
docs: { tree: [{ name: "README.md", relPath: "README.md", type: "file", size: 100, mtime: null }] },
tests: {
proofPackets: [{
dirName: `${name}-proof`,
primaryMarkdown: { relPath: "proof.md", content: "PASS" },
additionalMarkdown: [],
screenshots: ["screenshots/proof.png"],
videos: [],
traces: [],
passFailBadge: "pass",
}],
aggregate: { passCount: 1, failCount: 0 },
},
topology: { affectedRigs: [{ rigId: "rig-1", rigName: "rig-1", sessionNames: ["driver@rig-1"] }], totalSeats: 1, specGraph: null },
};
}
function installFetchMock() {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("/api/config")) {
return new Response(
JSON.stringify({ settings: { "workspace.root": { value: "/Users/admin/.openrig/workspace" } } }),
{ status: 200 },
);
}
if (url.includes("/api/slices/idea-ledger")) {
return new Response(JSON.stringify(makeDetail("idea-ledger", "RSI-V2-PROOF", ["qitem-A"])), { status: 200 });
}
if (url.includes("/api/slices/seed-slice-active")) {
return new Response(JSON.stringify(makeDetail("seed-slice-active", null, [])), { status: 200 });
}
if (url.includes("/api/queue/qitem-A")) {
return new Response(JSON.stringify({
qitemId: "qitem-A",
tsCreated: "2026-05-07T22:06:36.083Z",
tsUpdated: "2026-05-07T22:06:36.083Z",
sourceSession: "driver@rig",
destinationSession: "human@host",
state: "done",
priority: "urgent",
tier: "fast",
tags: ["RSI-V2-PROOF"],
body: "Full queue body for workspace rollup.",
}), { status: 200 });
}
if (url.includes("/api/slices?")) {
return new Response(
JSON.stringify({
slices: [
{
name: "idea-ledger",
displayName: "Idea Ledger RSI v2 proof slice",
railItem: "RSI-V2-PROOF",
status: "done",
rawStatus: "done",
qitemCount: 78,
hasProofPacket: false,
lastActivityAt: "2026-05-07T22:06:36.083Z",
},
{
name: "seed-slice-active",
displayName: "seed-slice-active",
railItem: null,
status: "active",
rawStatus: "active",
qitemCount: 0,
hasProofPacket: false,
lastActivityAt: "2000-01-01T00:00:00.000Z",
},
],
totalCount: 2,
filter: "all",
}),
{ status: 200 },
);
}
return new Response("[]");
});
}
function renderWorkspaceScope(): ReturnType<typeof render> {
installFetchMock();
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const rootRoute = createRootRoute({ component: () => <Outlet /> });
const projectRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/project",
component: () => <WorkspaceScopePage />,
});
const fallbackRoute = createRoute({
getParentRoute: () => rootRoute,
path: "$",
component: () => null,
});
const router = createRouter({
routeTree: rootRoute.addChildren([projectRoute, fallbackRoute]),
history: createMemoryHistory({ initialEntries: ["/project"] }),
});
return render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
}
function renderMissionScope(): ReturnType<typeof render> {
installFetchMock();
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const rootRoute = createRootRoute({ component: () => <Outlet /> });
const missionRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/project/mission/$missionId",
component: () => <MissionScopePage />,
});
const sliceRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/project/slice/$sliceId",
component: () => null,
});
const fallbackRoute = createRoute({
getParentRoute: () => rootRoute,
path: "$",
component: () => null,
});
const router = createRouter({
routeTree: rootRoute.addChildren([missionRoute, sliceRoute, fallbackRoute]),
history: createMemoryHistory({ initialEntries: ["/project/mission/RSI-V2-PROOF"] }),
});
return render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
}
describe("WorkspaceScopePage overview", () => {
it("summarizes current qitem-backed work separately from archived slices", async () => {
const { findByTestId } = renderWorkspaceScope();
expect(await findByTestId("workspace-overview-panel")).toBeTruthy();
const currentMission = await findByTestId("workspace-overview-mission-RSI-V2-PROOF");
expect(currentMission.getAttribute("data-mission-bucket")).toBe("current");
expect((await findByTestId("workspace-overview-slice-idea-ledger")).textContent).toContain(
"78 qitems",
);
const archivedMission = await findByTestId("workspace-overview-mission-unsorted");
expect(archivedMission.getAttribute("data-mission-bucket")).toBe("archive");
});
it("workspace progress, queue, and topology tabs render aggregate scoped data", async () => {
const { findByTestId } = renderWorkspaceScope();
fireEvent.click(await findByTestId("project-tab-story"));
expect(await findByTestId("scope-story-rollup")).toBeTruthy();
expect((await findByTestId("story-row-queue.created")).textContent).toContain("Full queue body");
fireEvent.click(await findByTestId("project-tab-progress"));
expect(await findByTestId("scope-progress-rollup")).toBeTruthy();
fireEvent.click(await findByTestId("project-tab-tests"));
expect(await findByTestId("scope-tests-rollup")).toBeTruthy();
fireEvent.click(await findByTestId("project-tab-queue"));
expect(await findByTestId("scope-queue-rollup")).toBeTruthy();
expect((await findByTestId("scope-queue-trigger-qitem-A")).textContent).toContain("Full queue body");
fireEvent.click(await findByTestId("project-tab-topology"));
expect(await findByTestId("scope-topology-rollup")).toBeTruthy();
});
it("mission scope page filters workspace data to that mission", async () => {
const { findByTestId, queryByText } = renderMissionScope();
expect(await findByTestId("mission-overview-panel")).toBeTruthy();
expect((await findByTestId("mission-overview-panel")).textContent).toContain("Idea Ledger RSI v2 proof slice");
expect(queryByText("seed-slice-active")).toBeNull();
fireEvent.click(await findByTestId("project-tab-queue"));
expect((await findByTestId("scope-queue-trigger-qitem-A")).textContent).toContain("Full queue body");
fireEvent.click(await findByTestId("project-tab-story"));
expect(await findByTestId("scope-story-rollup")).toBeTruthy();
expect(queryByText("seed-slice-active")).toBeNull();
});
});
@@ -89,8 +89,12 @@ describe("SessionPreviewPane", () => {
const content = await screen.findByTestId("compact-terminal-test-content");
expect(pane.getAttribute("data-variant")).toBe("compact-terminal");
expect(content.className).toContain("text-[10px]");
expect(content.className).toContain("text-[8px]");
expect(content.className).toContain("text-stone-50");
expect(content.className).toContain("whitespace-pre-wrap");
expect(content.className).toContain("break-words");
expect(content.className).toContain("overflow-x-hidden");
expect(content.className).not.toContain("break-all");
expect(screen.queryByText(/live preview/i)).toBeNull();
expect(screen.queryByText(/captured/i)).toBeNull();
expect(screen.queryByText(/1 lines/i)).toBeNull();
+7 -8
View File
@@ -30,8 +30,8 @@ function makeSettingsResponse() {
"transcripts.enabled": { value: true, source: "default", defaultValue: true },
"transcripts.path": { value: "/Users/me/.openrig/transcripts", source: "default", defaultValue: "/Users/me/.openrig/transcripts" },
"workspace.root": { value: "/Users/me/.openrig/workspace", source: "default", defaultValue: "/Users/me/.openrig/workspace" },
"workspace.slices_root": { value: "/Users/me/.openrig/workspace/slices", source: "default", defaultValue: "/Users/me/.openrig/workspace/slices" },
"workspace.steering_path": { value: "/Users/me/.openrig/workspace/steering/STEERING.md", source: "default", defaultValue: "/Users/me/.openrig/workspace/steering/STEERING.md" },
"workspace.slices_root": { value: "/Users/me/.openrig/workspace/missions", source: "default", defaultValue: "/Users/me/.openrig/workspace/missions" },
"workspace.steering_path": { value: "/Users/me/.openrig/workspace/STEERING.md", source: "default", defaultValue: "/Users/me/.openrig/workspace/STEERING.md" },
"workspace.field_notes_root": { value: "/Users/me/.openrig/workspace/field-notes", source: "default", defaultValue: "/Users/me/.openrig/workspace/field-notes" },
"workspace.specs_root": { value: "/Users/me/.openrig/workspace/specs", source: "default", defaultValue: "/Users/me/.openrig/workspace/specs" },
"files.allowlist": { value: "", source: "default", defaultValue: "" },
@@ -64,7 +64,7 @@ describe("SettingsTab — User Settings v0", () => {
mockFetch.mockImplementation(async (url: RequestInfo | URL, init?: RequestInit) => {
calls.push({ url: String(url), init });
if (init?.method === "POST" && String(url).includes("/api/config/workspace.slices_root")) {
return jsonResponse({ ok: true, key: "workspace.slices_root", resolved: { value: "/founder/slices", source: "file", defaultValue: "" } });
return jsonResponse({ ok: true, key: "workspace.slices_root", resolved: { value: "/custom/slices", source: "file", defaultValue: "" } });
}
return jsonResponse(makeSettingsResponse());
});
@@ -74,14 +74,14 @@ describe("SettingsTab — User Settings v0", () => {
fireEvent.click(screen.getByTestId("setting-workspace.slices_root-edit"));
const input = screen.getByTestId("setting-workspace.slices_root-input") as HTMLInputElement;
fireEvent.change(input, { target: { value: "/founder/slices" } });
fireEvent.change(input, { target: { value: "/custom/slices" } });
fireEvent.click(screen.getByTestId("setting-workspace.slices_root-save"));
await waitFor(() => {
expect(calls.some((c) =>
c.url.includes("/api/config/workspace.slices_root")
&& c.init?.method === "POST"
&& (c.init.body as string).includes("/founder/slices")
&& (c.init.body as string).includes("/custom/slices")
)).toBe(true);
});
});
@@ -106,8 +106,7 @@ describe("SettingsTab — User Settings v0", () => {
root: "/Users/me/.openrig/workspace",
rootCreated: true,
subdirs: [
{ name: "slices", path: "/x/slices", created: true },
{ name: "steering", path: "/x/steering", created: true },
{ name: "missions", path: "/x/missions", created: true },
{ name: "progress", path: "/x/progress", created: true },
{ name: "field-notes", path: "/x/field-notes", created: true },
{ name: "specs", path: "/x/specs", created: true },
@@ -125,7 +124,7 @@ describe("SettingsTab — User Settings v0", () => {
await waitFor(() => expect(initCalled).toBe(true));
await waitFor(() => expect(screen.getByTestId("settings-init-result")).toBeDefined());
expect(screen.getByTestId("settings-init-result").textContent).toContain("created 5 subdir");
expect(screen.getByTestId("settings-init-result").textContent).toContain("created 4 subdir");
});
it("renders a friendly error when the daemon route 503s", async () => {
+54 -8
View File
@@ -10,16 +10,19 @@
// - TopologyTab: per-rig listing still renders alongside spec graph
// - TopologyTab: routingType="direct" carved-out — every edge has it
import { describe, it, expect, afterEach } from "vitest";
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, cleanup, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { createTestRouter } from "./helpers/test-router.js";
import { StoryTab } from "../src/components/slices/tabs/StoryTab.js";
import { DrawerSelectionContext } from "../src/components/AppShell.js";
import { AcceptanceTab } from "../src/components/slices/tabs/AcceptanceTab.js";
import { TopologyTab } from "../src/components/slices/tabs/TopologyTab.js";
import type {
StoryEvent,
PhaseDefinition,
CurrentStepPayload,
QueueItemDetail,
SpecGraphPayload,
SliceDetail,
} from "../src/hooks/useSlices.js";
@@ -45,6 +48,14 @@ const SPEC_PHASES: PhaseDefinition[] = [
{ id: "qa", label: "qa-tester", role: "qa-tester" },
];
function renderStory(ui: ReactNode) {
return render(
<DrawerSelectionContext.Provider value={{ selection: null, setSelection: vi.fn() }}>
{ui}
</DrawerSelectionContext.Provider>,
);
}
describe("PL-slice-story-view-v1 StoryTab", () => {
it("groups events by spec-declared phase labels (NOT v0 hardcoded RSI-v2 names)", () => {
const events = [
@@ -52,7 +63,7 @@ describe("PL-slice-story-view-v1 StoryTab", () => {
event({ kind: "queue.handed_off", phase: "delivery", qitemId: "q-x" }),
event({ kind: "transition.in-progress", phase: "qa", qitemId: "q-q" }),
];
render(<StoryTab events={events} phaseDefinitions={SPEC_PHASES} />);
renderStory(<StoryTab events={events} phaseDefinitions={SPEC_PHASES} />);
// Each row's phase chip uses the spec-declared label (which equals
// the actor_role for v1's projector default).
expect(screen.getByTestId("story-row-phase-queue.created").textContent).toBe("discovery-router");
@@ -62,21 +73,21 @@ describe("PL-slice-story-view-v1 StoryTab", () => {
it("untagged events render with the neutral 'untagged' label + stone palette", () => {
const events = [event({ kind: "doc.edited", phase: null, qitemId: null })];
render(<StoryTab events={events} phaseDefinitions={SPEC_PHASES} />);
renderStory(<StoryTab events={events} phaseDefinitions={SPEC_PHASES} />);
const chip = screen.getByTestId("story-row-phase-doc.edited");
expect(chip.textContent).toBe("untagged");
expect(chip.getAttribute("data-phase-id")).toBe("untagged");
expect(chip.className).toContain("stone-100");
});
it("when phaseDefinitions is null (unbound slice), spec phase ids on events fall through verbatim", () => {
it("when phaseDefinitions is null (unbound slice), spec phase ids on events fall through exactly", () => {
// Edge case: an event somehow carries a phase id but the slice isn't
// bound. The chip falls back to displaying the raw phase id rather
// than crashing. (Realistic v1 scenario when a previously-bound slice
// loses its workflow_instance binding — events were tagged at fetch
// time, definitions weren't.)
const events = [event({ kind: "queue.created", phase: "step-x" })];
render(<StoryTab events={events} phaseDefinitions={null} />);
renderStory(<StoryTab events={events} phaseDefinitions={null} />);
expect(screen.getByTestId("story-row-phase-queue.created").textContent).toBe("step-x");
});
@@ -85,12 +96,46 @@ describe("PL-slice-story-view-v1 StoryTab", () => {
event({ kind: "old.event", ts: "2026-05-04T00:00:00.000Z", summary: "older" }),
event({ kind: "new.event", ts: "2026-05-04T01:00:00.000Z", summary: "newer" }),
];
render(<StoryTab events={events} phaseDefinitions={null} />);
renderStory(<StoryTab events={events} phaseDefinitions={null} />);
expect(screen.getByTestId("story-step-tree").getAttribute("data-order")).toBe("newest-first");
const rows = screen.getAllByTestId(/story-row-/);
expect(rows[0]?.getAttribute("data-testid")).toBe("story-row-new.event");
expect(screen.getByTestId("story-step-connector-new.event")).toBeDefined();
});
it("renders qitem body as the primary story content when queue details are loaded", () => {
const queueItem: QueueItemDetail = {
qitemId: "q-1",
tsCreated: "2026-05-04T00:00:00.000Z",
tsUpdated: "2026-05-04T00:00:00.000Z",
sourceSession: "src@r",
destinationSession: "dest@r",
state: "in-progress",
priority: "routine",
tier: "mode2",
tags: ["demo"],
body: "Implement the observability body-first story view.\nInclude acceptance evidence.",
};
renderStory(
<StoryTab
events={[
event({
kind: "queue.created",
qitemId: "q-1",
summary: "src@r -> dest@r: truncated metadata summary",
}),
]}
phaseDefinitions={null}
queueItemsById={new Map([["q-1", queueItem]])}
/>,
);
const body = screen.getByTestId("story-row-body-queue.created");
expect(body.getAttribute("data-source")).toBe("qitem");
expect(body.textContent).toContain("Implement the observability body-first story view.");
expect(screen.getByTestId("story-row-summary-queue.created").textContent).toContain(
"truncated metadata summary",
);
});
});
// --- AcceptanceTab ---
@@ -213,10 +258,11 @@ describe("PL-slice-story-view-v1 TopologyTab", () => {
expect(screen.getByTestId("topology-rig-demo")).toBeDefined();
});
it("does NOT render the spec graph panel when unbound (specGraph=null) — v0 fallback", async () => {
it("renders a derived runtime graph when unbound but affected seats are present", async () => {
render(makeRouter(topologyShape(null)));
await waitFor(() => expect(screen.getByTestId("topology-rig-demo")).toBeDefined());
expect(screen.queryByTestId("topology-spec-graph")).toBeNull();
expect(screen.getByTestId("topology-spec-graph").getAttribute("data-spec-name")).toBe("runtime-handoff-map");
expect(screen.getByTestId("spec-node-demo")).toBeDefined();
});
it("v1 carve-out: every spec edge carries data-routing-type='direct' (Phase D has no routing_type field yet)", async () => {
@@ -1,11 +1,10 @@
// Slice Story View v0 — TestsVerificationTab focused tests.
//
// This is the founder-named load-bearing tab; orch will bounce drops
// of the inline screenshot rendering or video player. These tests
// pin both behaviors as a regression gate.
// This is a load-bearing tab. These tests pin inline screenshot rendering
// and the video player as regression gates.
import { describe, it, expect, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { fireEvent, render, screen, cleanup } from "@testing-library/react";
import { TestsVerificationTab } from "../src/components/slices/tabs/TestsVerificationTab.js";
import type { SliceDetail, ProofPacketRendered } from "../src/hooks/useSlices.js";
@@ -32,9 +31,25 @@ function makeTests(packets: ProofPacketRendered[], aggregate?: { passCount: numb
describe("PL-slice-story-view-v0 TestsVerificationTab", () => {
afterEach(() => cleanup());
it("renders empty state when slice has no proof packet (graceful — pre-dogfood slices)", () => {
render(<TestsVerificationTab sliceName="x" tests={{ proofPackets: [], aggregate: { passCount: 0, failCount: 0 } }} />);
it("renders useful diagnostics when slice has no proof packet", () => {
render(
<TestsVerificationTab
sliceName="x"
tests={{ proofPackets: [], aggregate: { passCount: 0, failCount: 0 } }}
qitemCount={3}
docsCount={2}
lastActivityAt="2026-05-07T21:00:00Z"
/>,
);
expect(screen.getByTestId("tests-empty")).toBeDefined();
expect(screen.getByTestId("tests-empty-reason").textContent).toContain(
"proof matcher",
);
expect(screen.getByTestId("tests-empty-diagnostics").textContent).toContain("3");
expect(screen.getByTestId("tests-empty-diagnostics").textContent).toContain("2");
expect(screen.getByTestId("tests-empty-next-steps").textContent).toContain(
"Check Artifacts",
);
});
it("renders header aggregate '<pass> pass, <fail> fail · N packets'", () => {
@@ -54,7 +69,7 @@ describe("PL-slice-story-view-v0 TestsVerificationTab", () => {
expect(md.textContent).toContain("PL019-dogfood.md");
});
it("MANDATORY (founder-named): renders <img> tag inline for each screenshot path", () => {
it("renders <img> tag inline for each screenshot path", () => {
const tests = makeTests([makePacket({
screenshots: ["screenshots/mission-control-active-work.png", "headed-browser/screenshots/edge-fired.png"],
})]);
@@ -68,7 +83,21 @@ describe("PL-slice-story-view-v0 TestsVerificationTab", () => {
expect(img2.getAttribute("src")).toContain("edge-fired.png");
});
it("MANDATORY (founder-named): renders <video controls> player for each video path", () => {
it("opens a large screenshot viewer when a proof screenshot is clicked", () => {
const tests = makeTests([makePacket({
screenshots: ["screenshots/mission-control-active-work.png"],
})]);
render(<TestsVerificationTab sliceName="my-slice" tests={tests} />);
fireEvent.click(screen.getByTestId("tests-packet-screenshot-open-screenshots/mission-control-active-work.png"));
const viewer = screen.getByTestId("tests-screenshot-viewer");
expect(viewer.getAttribute("role")).toBe("dialog");
const img = screen.getByTestId("tests-screenshot-viewer-image") as HTMLImageElement;
expect(img.getAttribute("src")).toContain("/api/slices/my-slice/proof-asset/screenshots/mission-control-active-work.png");
fireEvent.click(screen.getByTestId("tests-screenshot-viewer-close"));
expect(screen.queryByTestId("tests-screenshot-viewer")).toBeNull();
});
it("renders <video controls> player for each video path", () => {
const tests = makeTests([makePacket({
videos: ["videos/demo.mp4", "videos/walkthrough.webm"],
})]);