mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
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:
commit
c7c9751adc
56 files changed
+4721
-558
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
|
||||
@@ -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/<mission>/slices/<slice>.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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">-></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 }}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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(" · ");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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\)/);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"],
|
||||
})]);
|
||||
|
||||
Reference in new issue
Block a user