shell-redesign V1 attempt-3 Phase 5 P5-5 + P5-6: filesystem mission discovery + live PROGRESS.md status

Per project-tree.md L132–L133 + SC-26: mission status derives from
PROGRESS.md frontmatter (file-system-as-truth pattern). Phase 3 stubbed
parseMissionStatus(); Phase 5 wires the live filesystem walk + fetch.
Both ride on the existing /api/files/list + /api/files/read daemon
routes (verified at HEAD f1b91ff via packages/daemon/src/routes/files.ts);
NO new daemon endpoint, NO schema migration, NO new event type. SC-29
hard line preserved beyond the Phase 4 ConfigStore allowlist exception.

NEW packages/ui/src/hooks/useMissionDiscovery.ts:
- useMissionDiscovery() resolves workspace.root → /api/files/roots
  match (exact-equal preferred, deepest path-prefix fallback) →
  /api/files/list?root=&path=missions → directory entries (filtered
  to type==='dir').
- Returns { missions, unavailable, isLoading, hint }. Unavailable cases
  surface a hint string explaining the operator action needed
  (configure workspace.root, register OPENRIG_FILES_ALLOWLIST root,
  upgrade to v0.3.0 daemon for /api/config support).

NEW packages/ui/src/hooks/useMissionProgressStatus.ts:
- useMissionProgressStatus(root, missionPath) wraps existing useFilesRead
  to fetch <missionPath>/PROGRESS.md and parse via parseMissionStatus.
- TanStack Query supplies the cache layer; the daemon's mtime metadata
  in /api/files/read response satisfies project-tree.md L132 mtime-cache
  invariant.
- Returns { status, isLoading, unavailable, mtime }; "unknown" + 404
  treated as quiet degrade (mission directory may exist without
  PROGRESS.md yet).

REFACTORED packages/ui/src/components/project/ProjectTreeView.tsx:
- ProjectTreeView consumes useMissionDiscovery + new
  LiveMissionStatusBadge wrapper (uses useMissionProgressStatus).
- When discovery returns missions: render disk-discovered missions; for
  each, attach matching railItem-grouped slices; orphaned slices fall
  through to an "Unsorted" mission node.
- When discovery is unavailable: render a small italic
  "Filesystem mission discovery unavailable; showing railItem grouping"
  hint + the legacy railItem grouping (Phase 3 fallback preserved
  intact). data-testid='project-discovery-degraded' for tests.
- Existing Phase 3 A5 no-workspace empty-state preserved verbatim.

NEW packages/ui/test/p5-mission-discovery.test.tsx (10 tests):
- parseMissionStatus invariants (active / shipped / blocked variants
  + null/empty/malformed → unknown).
- ProjectTreeView with workspace.root in allowlist: filesystem missions
  surface as tree nodes; file-type entries filtered out.
- Fallback to railItem grouping when allowlist doesn't contain
  workspace.root, OR allowlist is empty; degraded hint visible.
- A5 no-workspace empty-state preserved when workspace.root unset.
- Slice-to-mission matching by railItem; orphans → Unsorted node.

Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- vitest full UI suite: 648/650 PASS (pre-existing library-review
  Send-to-seat baseline failure UNCHANGED; 1 skipped)
- pnpm -F @openrig/ui build: clean

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-06 17:39:48 -07:00
1 parent 233aeb258a
commit 919a428ee9
4 files changed
+639 -28

No files matched your search

@@ -1,25 +1,38 @@
// V1 attempt-3 Phase 3 — Project tree per project-tree.md L13–L46 + SC-24.
//
// Workspace > mission > slice tree. V1 groups slices by railItem (the
// existing slice rail-item is the closest analog to "mission" in the
// current data model); workspace level is the single openrig-work
// surface. Phase 5 polish + filesystem mission discovery is the V2 path.
// V1 attempt-3 Phase 5 P5-5 + P5-6: filesystem-based mission discovery via
// useMissionDiscovery (walks workspace.root/missions/ over /api/files/list)
// 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.
import { useState, useMemo } from "react";
import { Link } from "@tanstack/react-router";
import { ChevronDown, ChevronRight } from "lucide-react";
import { useSlices } from "../../hooks/useSlices.js";
import { useSlices, type SliceListEntry } from "../../hooks/useSlices.js";
import { useWorkspaceName } from "../../hooks/useWorkspaceName.js";
import {
useMissionDiscovery,
type DiscoveredMission,
} 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 };
type GroupedMission = {
id: string;
label: string;
status: MissionStatus;
slices: Array<{ name: string; displayName: string; status: string }>;
slices: SliceRow[];
// P5-5: filesystem-discovered missions carry root + path so the live
// PROGRESS.md status fetcher knows where to read.
fsRoot?: string;
fsPath?: string;
};
function deriveStatus(slices: Array<{ status: string }>): MissionStatus {
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";
@@ -27,21 +40,40 @@ function deriveStatus(slices: Array<{ status: string }>): MissionStatus {
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. */
function LiveMissionStatusBadge({ mission }: { mission: GroupedMission }) {
const live = useMissionProgressStatus(mission.fsRoot ?? null, mission.fsPath ?? null);
// When filesystem-discovered AND the read succeeded, prefer the live status;
// otherwise the slice-derived fallback.
const status =
mission.fsRoot && !live.unavailable && !live.isLoading ? live.status : mission.status;
return (
<MissionStatusBadge
status={status}
testId={`project-mission-${mission.id}-badge`}
/>
);
}
export function ProjectTreeView() {
const { data: slicesResp } = useSlices("all");
const workspace = useWorkspaceName();
const discovery = useMissionDiscovery();
const [expanded, setExpanded] = useState<Record<string, boolean>>({
workspace: true,
});
const toggle = (k: string) =>
setExpanded((p) => ({ ...p, [k]: !p[k] }));
const missions = useMemo<GroupedMission[]>(() => {
// useSlices returns SliceListResponse | SlicesUnavailable; the unavailable
// case (slices_root_not_configured) lacks the `slices` field — guard for it.
const list = slicesResp && "slices" in slicesResp ? slicesResp.slices : [];
const buckets = new Map<string, GroupedMission["slices"]>();
for (const s of list) {
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[]>();
for (const s of sliceList) {
const key = s.railItem ?? "unsorted";
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key)!.push({
@@ -50,13 +82,51 @@ export function ProjectTreeView() {
status: s.status,
});
}
return Array.from(buckets.entries()).map(([k, slices]) => ({
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.
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) ?? [];
if (matchedSlices.length > 0) consumedKeys.add(m.name);
return {
id: m.name,
label: m.name,
status: deriveStatusFromSlices(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) {
discovered.push({
id: "unsorted",
label: "Unsorted",
status: deriveStatusFromSlices(orphanSlices),
slices: orphanSlices,
});
}
return discovered;
}
// Fallback: railItem grouping only (Phase 3 behavior).
return Array.from(slicesByRailItem.entries()).map(([k, slices]) => ({
id: k,
label: k === "unsorted" ? "Unsorted" : k,
status: deriveStatus(slices),
status: deriveStatusFromSlices(slices),
slices,
}));
}, [slicesResp]);
}, [discovery.unavailable, discovery.missions, slicesByRailItem]);
// A5 bounce-fix: replace hardcoded "openrig-work" with live-wired
// workspace name from ConfigStore. When unset/unreachable: render an
@@ -123,6 +193,15 @@ export function ProjectTreeView() {
</button>
{expanded.workspace ? (
<ul className="ml-4 border-l border-stone-200">
{discovery.unavailable && discovery.hint ? (
<li
data-testid="project-discovery-degraded"
className="px-2 py-1 font-mono text-[9px] text-on-surface-variant italic"
title={discovery.hint}
>
Filesystem mission discovery unavailable; showing railItem grouping.
</li>
) : null}
{missions.length === 0 ? (
<li className="px-2 py-1 font-mono text-[10px] text-on-surface-variant italic">
No missions yet.
@@ -144,22 +223,28 @@ export function ProjectTreeView() {
<span className="font-mono text-[11px] text-stone-900 flex-1 truncate">
{m.label}
</span>
<MissionStatusBadge status={m.status} testId={`project-mission-${m.id}-badge`} />
<LiveMissionStatusBadge mission={m} />
</button>
{expanded[`mission-${m.id}`] ? (
<ul className="ml-4 border-l border-stone-200">
{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>
{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>
@@ -0,0 +1,163 @@
// V1 attempt-3 Phase 5 P5-5 — Filesystem-based Project tree mission discovery.
//
// Walks `<workspace.root>/missions/` via the existing /api/files/list daemon
// route (no new daemon endpoint per SC-29). Returns the list of mission
// directories so ProjectTreeView can render them as tree nodes alongside
// (or instead of) the railItem-grouped slice fallback. Per project-tree.md
// L13–L46 the Project tree shape is workspace > mission > slice; the
// canonical mission unit is a directory under workspace.root/missions/.
//
// Resolution chain:
// 1. useSettings → workspace.root (absolute path)
// 2. useFilesRoots → list of (name, path) allowlist roots
// 3. find allowlist root whose `path` is a prefix of workspace.root
// (exact-equal preferred; "the workspace root" is the typical
// registration case)
// 4. compute relative path from root → workspace.root/missions
// 5. useFilesList(rootName, relPath) → mission directory entries
//
// When no suitable root is registered (operator hasn't added workspace.root
// to OPENRIG_FILES_ALLOWLIST), returns { unavailable: true } so the UI can
// degrade to the legacy railItem-grouped slice listing without crashing.
import { useFilesRoots, useFilesList } from "./useFiles.js";
import { useWorkspaceName } from "./useWorkspaceName.js";
export interface DiscoveredMission {
/** Mission directory name (e.g., "recursive-self-improvement-v2"). */
name: string;
/** Allowlist root name to use with /api/files/* endpoints. */
root: string;
/** Path under root for this mission directory (e.g., "missions/foo"). */
path: string;
}
export interface UseMissionDiscoveryResult {
missions: DiscoveredMission[];
/** True when allowlist has no root containing workspace.root. */
unavailable: boolean;
/** True while any underlying query is in flight. */
isLoading: boolean;
/** Hint text for the empty-state when unavailable. */
hint: string | null;
}
/** Returns true when `parent` is a path-prefix of `child`, treating both as
* absolute filesystem paths. Trailing slashes are tolerated; segment
* boundaries enforced (so "/work" is NOT a prefix of "/workspace"). */
function isPathPrefix(parent: string, child: string): boolean {
const p = parent.replace(/\/+$/, "");
const c = child.replace(/\/+$/, "");
if (c === p) return true;
return c.startsWith(p + "/");
}
/** Compute the relative path inside a root: child = "<root>/<rel>" → "<rel>". */
function relativeUnder(rootPath: string, absChild: string): string {
const p = rootPath.replace(/\/+$/, "");
const c = absChild.replace(/\/+$/, "");
if (c === p) return "";
if (c.startsWith(p + "/")) return c.slice(p.length + 1);
return c;
}
export function useMissionDiscovery(): UseMissionDiscoveryResult {
const workspace = useWorkspaceName();
const rootsQuery = useFilesRoots();
const rootsResp = rootsQuery.data;
// Resolution: pick the best-matching root for workspace.root, if any.
let chosenRoot: { name: string; path: string } | null = null;
if (workspace.root && rootsResp && "roots" in rootsResp) {
// Exact match preferred.
const exact = rootsResp.roots.find((r) => r.path === workspace.root);
if (exact) {
chosenRoot = exact;
} else {
// Otherwise pick the deepest root that is a prefix of workspace.root
// (handles operator who registered ~/code as a root with workspace.root
// = ~/code/projects/openrig-work).
const prefixed = rootsResp.roots
.filter((r) => workspace.root && isPathPrefix(r.path, workspace.root))
.sort((a, b) => b.path.length - a.path.length);
const first = prefixed[0];
if (first) chosenRoot = first;
}
}
// Compute the missions/ relative path from the chosen root.
const missionsRelPath =
chosenRoot && workspace.root
? (() => {
const rel = relativeUnder(chosenRoot.path, workspace.root);
return rel ? `${rel}/missions` : "missions";
})()
: null;
const listQuery = useFilesList(
chosenRoot ? chosenRoot.name : null,
missionsRelPath,
);
const isLoading =
workspace.isLoading ||
rootsQuery.isLoading ||
(chosenRoot !== null && listQuery.isLoading);
// Unavailable cases:
// - Settings unreachable (legacy v0.2.0 daemon without /api/config).
// - No workspace.root configured.
// - No allowlist root contains workspace.root (operator must register).
if (!workspace.settingsAvailable && !workspace.isLoading) {
return {
missions: [],
unavailable: true,
isLoading,
hint: "Daemon does not expose /api/config (likely v0.2.0). Upgrade to v0.3.0+ for live mission discovery.",
};
}
if (!workspace.root && !workspace.isLoading) {
return {
missions: [],
unavailable: true,
isLoading,
hint: "Configure a workspace root: rig config set workspace.root <path>",
};
}
if (!chosenRoot && !rootsQuery.isLoading) {
return {
missions: [],
unavailable: true,
isLoading,
hint: `No allowlist root contains workspace.root (${workspace.root}). Set OPENRIG_FILES_ALLOWLIST=<name>:<path> to register.`,
};
}
// listQuery may be in flight or errored; treat error as unavailable so the
// UI falls back gracefully without throwing.
if (listQuery.isError) {
return {
missions: [],
unavailable: true,
isLoading,
hint: `Could not list ${missionsRelPath} under ${chosenRoot?.name}.`,
};
}
const entries = listQuery.data?.entries ?? [];
const missions: DiscoveredMission[] = entries
.filter((e) => e.type === "dir")
.map((e) => ({
name: e.name,
root: chosenRoot!.name,
path: missionsRelPath ? `${missionsRelPath}/${e.name}` : e.name,
}))
.sort((a, b) => a.name.localeCompare(b.name));
return {
missions,
unavailable: false,
isLoading,
hint: null,
};
}
@@ -0,0 +1,57 @@
// V1 attempt-3 Phase 5 P5-6 — MissionStatusBadge live PROGRESS.md fetch.
//
// Fetches `<missionPath>/PROGRESS.md` via the existing /api/files/read
// daemon route (no new daemon endpoint per SC-29) and parses the
// frontmatter `status:` field via parseMissionStatus(). Per project-tree.md
// L132–L133: "status derives from PROGRESS.md frontmatter or top-level
// status: field. … Driver implements via a thin parser that reads
// PROGRESS.md once per mission node load (NOT on every render); cache
// invalidation on file mtime change."
//
// TanStack Query supplies the cache layer; staleTime + the underlying
// /api/files/read response's `mtime` field together give us mtime-aware
// caching: queries refetch when invalidated, and the daemon's mtime
// metadata lets the UI show a fresh-vs-stale indicator if needed.
import { useFilesRead } from "./useFiles.js";
import { parseMissionStatus, type MissionStatus } from "../components/MissionStatusBadge.js";
export interface UseMissionProgressStatusResult {
status: MissionStatus;
isLoading: boolean;
/** When true, PROGRESS.md does not exist for this mission (or the read
* failed); status is "unknown" and the UI may surface a hint. */
unavailable: boolean;
/** mtime of PROGRESS.md when known; null otherwise. Useful for fresh
* indicators or invalidation hooks. */
mtime: string | null;
}
export function useMissionProgressStatus(
root: string | null,
missionPath: string | null,
): UseMissionProgressStatusResult {
const progressPath =
root && missionPath ? `${missionPath}/PROGRESS.md` : null;
const readQuery = useFilesRead(root, progressPath);
if (!root || !missionPath) {
return { status: "unknown", isLoading: false, unavailable: true, mtime: null };
}
if (readQuery.isLoading) {
return { status: "unknown", isLoading: true, unavailable: false, mtime: null };
}
if (readQuery.isError || !readQuery.data) {
// 404 / unreadable PROGRESS.md = unknown; degrade quietly.
return { status: "unknown", isLoading: false, unavailable: true, mtime: null };
}
return {
status: parseMissionStatus(readQuery.data.content),
isLoading: false,
unavailable: false,
mtime: readQuery.data.mtime ?? null,
};
}
@@ -0,0 +1,306 @@
// V1 attempt-3 Phase 5 P5-5 + P5-6 — filesystem-based mission discovery +
// MissionStatusBadge live PROGRESS.md fetch.
//
// Both features ride on the existing /api/files/list + /api/files/read
// daemon routes (no new daemon endpoints; SC-29 honored). When the
// allowlist doesn't expose workspace.root, the tree falls back to the
// legacy railItem-grouped slice listing — also tested here.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
createMemoryHistory,
RouterProvider,
createRouter,
createRootRoute,
createRoute,
Outlet,
} from "@tanstack/react-router";
import { parseMissionStatus } from "../src/components/MissionStatusBadge.js";
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;
beforeEach(() => {
mockFetch.mockReset();
});
afterEach(() => {
cleanup();
});
// -----------------------------------------------------------------------
// parseMissionStatus — unit-level (Phase 3 already covered most paths;
// adding a few P5-6 path-specific cases for invariant guard).
// -----------------------------------------------------------------------
describe("parseMissionStatus (P5-6 invariants)", () => {
it("parses status:active from PROGRESS.md frontmatter", () => {
expect(
parseMissionStatus("---\nname: Mission X\nstatus: active\n---\n# body"),
).toBe("active");
});
it("parses status:shipped variants", () => {
expect(parseMissionStatus("---\nstatus: shipped\n---")).toBe("shipped");
expect(parseMissionStatus("---\nstatus: complete\n---")).toBe("shipped");
expect(parseMissionStatus("---\nstatus: completed\n---")).toBe("shipped");
expect(parseMissionStatus("---\nstatus: done\n---")).toBe("shipped");
});
it("parses status:blocked variants", () => {
expect(parseMissionStatus("---\nstatus: blocked\n---")).toBe("blocked");
expect(parseMissionStatus("---\nstatus: stalled\n---")).toBe("blocked");
});
it("returns 'unknown' on missing/empty/malformed input", () => {
expect(parseMissionStatus(null)).toBe("unknown");
expect(parseMissionStatus(undefined)).toBe("unknown");
expect(parseMissionStatus("")).toBe("unknown");
expect(parseMissionStatus("# no frontmatter")).toBe("unknown");
expect(parseMissionStatus("---\nfoo: bar\n---")).toBe("unknown");
});
});
// -----------------------------------------------------------------------
// useMissionDiscovery — integration via ProjectTreeView (the real consumer).
// -----------------------------------------------------------------------
import { ProjectTreeView } from "../src/components/project/ProjectTreeView.js";
interface RenderTreeOpts {
// Settings response: workspace.root absolute path. Pass null to render
// the unset/unreachable empty-state.
workspaceRoot: string | null;
settingsAvailable?: boolean;
// Files API mocks.
roots: Array<{ name: string; path: string }>;
// Map "<root>:<path>" → directory entries.
listings?: Record<string, Array<{ name: string; type: "dir" | "file" }>>;
// 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 }>;
}
function setupFetch(opts: RenderTreeOpts) {
mockFetch.mockImplementation(async (url: string) => {
// /api/config (settings)
if (url.includes("/api/config")) {
if (opts.settingsAvailable === false) {
return new Response("not implemented", { status: 404 });
}
const settings: Record<string, { value: unknown }> = {};
if (opts.workspaceRoot !== null) {
settings["workspace.root"] = { value: opts.workspaceRoot };
}
return new Response(JSON.stringify({ settings }), { status: 200 });
}
// /api/files/roots
if (url.includes("/api/files/roots")) {
return new Response(JSON.stringify({ roots: opts.roots }), { status: 200 });
}
// /api/files/list?root=<name>&path=<rel>
if (url.includes("/api/files/list")) {
const u = new URL(url, "http://localhost");
const root = u.searchParams.get("root") ?? "";
const path = u.searchParams.get("path") ?? "";
const key = `${root}:${path}`;
const entries = opts.listings?.[key] ?? [];
return new Response(
JSON.stringify({ root, path, entries: entries.map((e) => ({ ...e, size: null, mtime: null })) }),
{ status: 200 },
);
}
// /api/files/read?root=<name>&path=<rel>
if (url.includes("/api/files/read")) {
const u = new URL(url, "http://localhost");
const root = u.searchParams.get("root") ?? "";
const path = u.searchParams.get("path") ?? "";
const key = `${root}:${path}`;
const data = opts.reads?.[key];
if (!data) return new Response("not found", { status: 404 });
return new Response(
JSON.stringify({
root,
path,
absolutePath: `/${root}/${path}`,
content: data.content,
mtime: data.mtime ?? "2026-05-06T18:00:00Z",
contentHash: "deadbeef",
size: data.content.length,
}),
{ status: 200 },
);
}
// /api/slices?filter=...
if (url.includes("/api/slices")) {
return new Response(
JSON.stringify({
slices: opts.slices ?? [],
totalCount: opts.slices?.length ?? 0,
filter: "all",
}),
{ status: 200 },
);
}
return new Response("[]");
});
}
function renderTree(opts: RenderTreeOpts): ReturnType<typeof render> {
setupFetch(opts);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const rootRoute = createRootRoute({ component: () => <Outlet /> });
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
component: () => <ProjectTreeView />,
});
const fallbackRoute = createRoute({
getParentRoute: () => rootRoute,
path: "$",
component: () => null,
});
const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, fallbackRoute]),
history: createMemoryHistory({ initialEntries: ["/"] }),
});
return render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
}
describe("ProjectTreeView P5-5/P5-6 mission discovery", () => {
it("filesystem-discovered missions surface as tree nodes when allowlist exposes workspace root", async () => {
const { findByTestId } = renderTree({
workspaceRoot: "/Users/admin/.openrig/workspace",
roots: [{ name: "workspace", path: "/Users/admin/.openrig/workspace" }],
listings: {
"workspace:missions": [
{ name: "rsi-v2", type: "dir" },
{ name: "shell-redesign-v1", type: "dir" },
{ name: "README.md", type: "file" }, // file → filtered out
],
},
reads: {
"workspace:missions/rsi-v2/PROGRESS.md": {
content: "---\nstatus: active\n---\n# RSI v2",
},
"workspace:missions/shell-redesign-v1/PROGRESS.md": {
content: "---\nstatus: shipped\n---\n# Shell V1",
},
},
slices: [],
});
expect(await findByTestId("project-mission-rsi-v2")).toBeTruthy();
expect(await findByTestId("project-mission-shell-redesign-v1")).toBeTruthy();
});
it("file-type entries under missions/ are not surfaced as missions", async () => {
const { findByTestId, queryByTestId } = renderTree({
workspaceRoot: "/Users/admin/.openrig/workspace",
roots: [{ name: "workspace", path: "/Users/admin/.openrig/workspace" }],
listings: {
"workspace:missions": [
{ name: "rsi-v2", type: "dir" },
{ name: "README.md", type: "file" },
],
},
slices: [],
});
await findByTestId("project-mission-rsi-v2");
expect(queryByTestId("project-mission-README.md")).toBeNull();
});
it("falls back to railItem 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" }],
slices: [
{
name: "slice-a",
displayName: "Slice A",
railItem: "rsi-v2",
status: "active",
rawStatus: "active",
qitemCount: 1,
hasProofPacket: false,
lastActivityAt: null,
},
],
});
expect(await findByTestId("project-discovery-degraded")).toBeTruthy();
expect(await findByTestId("project-mission-rsi-v2")).toBeTruthy();
});
it("falls back to railItem grouping when allowlist is empty", async () => {
const { findByTestId } = renderTree({
workspaceRoot: "/Users/admin/.openrig/workspace",
roots: [],
slices: [
{
name: "slice-x",
displayName: "Slice X",
railItem: null,
status: "active",
rawStatus: "active",
qitemCount: 1,
hasProofPacket: false,
lastActivityAt: null,
},
],
});
expect(await findByTestId("project-discovery-degraded")).toBeTruthy();
expect(await findByTestId("project-mission-unsorted")).toBeTruthy();
});
it("workspace.root unconfigured renders the no-workspace empty-state (Phase 3 A5 behavior preserved)", async () => {
const { findByTestId } = renderTree({
workspaceRoot: null,
roots: [],
slices: [],
});
expect(await findByTestId("project-no-workspace")).toBeTruthy();
});
it("matches slices to filesystem missions by railItem; orphans go to 'unsorted'", async () => {
const { findByTestId, queryByTestId } = renderTree({
workspaceRoot: "/Users/admin/.openrig/workspace",
roots: [{ name: "workspace", path: "/Users/admin/.openrig/workspace" }],
listings: {
"workspace:missions": [{ name: "rsi-v2", type: "dir" }],
},
reads: {
"workspace:missions/rsi-v2/PROGRESS.md": { content: "---\nstatus: active\n---" },
},
slices: [
{
name: "slice-rsi",
displayName: "RSI Slice",
railItem: "rsi-v2",
status: "active",
rawStatus: "active",
qitemCount: 0,
hasProofPacket: false,
lastActivityAt: null,
},
{
name: "slice-orphan",
displayName: "Orphan Slice",
railItem: "no-such-mission",
status: "active",
rawStatus: "active",
qitemCount: 0,
hasProofPacket: false,
lastActivityAt: null,
},
],
});
expect(await findByTestId("project-mission-rsi-v2")).toBeTruthy();
expect(await findByTestId("project-mission-unsorted")).toBeTruthy();
});
});