shell-redesign V1 attempt-3 Phase 5 P5-2: slice tab content piping

SliceScopePage replaces 6 PlaceholderTab calls with real tab components
mounted via useSliceDetail(sliceId). Loading + error/404 states render
EmptyState; the 7 canonical slice tabs each route to a mounted component.
Per code-map AFTER tree FOLD MAPPING (founder-approved at Phase 5 dispatch
ask #5):

  PRESERVED (re-mounted with SliceDetail props):
    - StoryTab → 'story' tab (events + phaseDefinitions)
    - TestsVerificationTab → 'tests' tab (sliceName + tests)
    - TopologyTab → 'topology' tab (topology)

  FOLDED:
    - AcceptanceTab → 'progress' tab (canonical: acceptance items + currentStep
      ARE the slice's "progress" surface per project-tree.md L65)
    - DocsTab + DecisionsTab → 'artifacts' tab (vertical sections; Docs +
      Decisions are the canonical artifact surfaces per project-tree.md L70)
    - DocsTab → 'overview' tab (pre-selects README.md / IMPLEMENTATION-PRD.md
      per existing initial-selection logic; richer overview shape is V2)

  NEW inline:
    - SliceQueueTab: renders detail.qitemIds wrapped in QueueItemTrigger
      (P5-1 trigger primitive). Click → QueueItemViewer in drawer per
      content-drawer.md L26 auto-open contract. Empty-state when zero
      qitems.

NEW packages/ui/test/p5-slice-scope.test.tsx (9 tests, all passing):
- Loading / error states render EmptyState with sliceId
- Default landing tab is 'story'
- Each fold mapping verified by testid presence (slice-artifacts-docs/
  decisions sections; slice-queue-trigger-* per qitem; tests-empty
  fallback for TestsVerificationTab; etc.)
- queue tab click → setSelection({type:'qitem', data:{qitemId}})
  fires through DrawerSelectionContext.Provider mock
- tests/topology tabs render without falling back to placeholder

Workspace + Mission scope tab piping NOT yet wired (Phase 5 polish;
P5-5 lays the data layer for filesystem-walk-derived missions).

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

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:35:11 -07:00
1 parent 246f9651be
commit 233aeb258a
2 files changed
+393 -12

No files matched your search

+176 -12
View File
@@ -4,8 +4,17 @@
// mission = same 5 tabs
// slice = +story +tests = 7 tabs
//
// Phase 3 V1 implements the tab nav structure; tab content uses existing
// pages where mountable + EmptyState placeholders for Phase 5 polish.
// V1 attempt-3 Phase 5 P5-2: SliceScopePage tab content piping.
// Per code-map AFTER tree fold mapping (founder-approved at Phase 5 dispatch):
// - StoryTab → story tab (preserved; events + phaseDefinitions props)
// - TestsVerificationTab → tests tab (preserved; tests prop)
// - TopologyTab → topology tab (preserved; topology prop)
// - AcceptanceTab → progress tab (FOLDED; canon-7 progress is acceptance + currentStep)
// - DocsTab + DecisionsTab → artifacts tab (FOLDED vertically; canon-7 artifacts is docs+decisions)
// - Overview tab → README/IMPLEMENTATION-PRD via DocsTab pre-selected for now
// - Queue tab → qitemIds list with QueueItemTrigger (P5-1 wiring) per content-drawer.md L26
// 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 { Link, useParams } from "@tanstack/react-router";
@@ -14,6 +23,14 @@ 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 } from "../../hooks/useSlices.js";
import { StoryTab } from "../slices/tabs/StoryTab.js";
import { AcceptanceTab } from "../slices/tabs/AcceptanceTab.js";
import { DocsTab } from "../slices/tabs/DocsTab.js";
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";
type SharedTab = "overview" | "progress" | "artifacts" | "queue" | "topology";
type SliceTab = SharedTab | "story" | "tests";
@@ -177,26 +194,173 @@ export function MissionScopePage() {
);
}
function SliceQueueTab({ qitemIds }: { qitemIds: string[] }) {
// 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.
if (qitemIds.length === 0) {
return (
<EmptyState
label="NO QITEMS"
description="No queue items associated with this slice."
variant="card"
testId="slice-queue-empty"
/>
);
}
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>
);
}
function SliceArtifactsTab({
sliceName,
docsTree,
decisionsRows,
}: {
sliceName: string;
docsTree: import("../../hooks/useSlices.js").DocsTreeEntry[];
decisionsRows: import("../../hooks/useSlices.js").DecisionRow[];
}) {
// FOLDED per code-map AFTER tree: canonical artifacts tab combines
// DocsTab (slice docs tree + lazy markdown viewer) + DecisionsTab
// (operator-driven decision-log timeline). Vertical sections so each
// sub-tool is fully visible without nested tabs.
return (
<div data-testid="slice-artifacts-tab" className="space-y-6">
<section data-testid="slice-artifacts-docs">
<SectionHeader tone="muted">Docs</SectionHeader>
<div className="mt-2 border border-outline-variant">
<DocsTab sliceName={sliceName} tree={docsTree} />
</div>
</section>
<section data-testid="slice-artifacts-decisions">
<SectionHeader tone="muted">Decisions</SectionHeader>
<div className="mt-2 border border-outline-variant">
<DecisionsTab rows={decisionsRows} />
</div>
</section>
</div>
);
}
function SliceOverviewTab({
sliceName,
docsTree,
}: {
sliceName: string;
docsTree: import("../../hooks/useSlices.js").DocsTreeEntry[];
}) {
// Per project-tree.md L57-L59: slice overview renders README.md /
// IMPLEMENTATION-PRD.md with steering section. DocsTab already
// pre-selects README.md (or IMPLEMENTATION-PRD.md as fallback) on
// mount (StoryTab.tsx-equivalent initial selection). Reuses DocsTab
// for consistency; bigger overview shape is V2.
return (
<div data-testid="slice-overview-tab" className="border border-outline-variant">
<DocsTab sliceName={sliceName} tree={docsTree} />
</div>
);
}
export function SliceScopePage() {
const { sliceId } = useParams({ from: "/project/slice/$sliceId" });
const [active, setActive] = useState<SliceTab>("story");
const detailQuery = useSliceDetail(sliceId);
if (detailQuery.isLoading) {
return (
<ScopeShell
eyebrow="Slice"
title={sliceId}
tabs={SLICE_TABS}
active={active}
onSelect={(id) => setActive(id as SliceTab)}
>
<EmptyState
label="LOADING"
description={`Fetching /api/slices/${sliceId}…`}
variant="card"
testId="slice-scope-loading"
/>
</ScopeShell>
);
}
if (detailQuery.isError || !detailQuery.data) {
return (
<ScopeShell
eyebrow="Slice"
title={sliceId}
tabs={SLICE_TABS}
active={active}
onSelect={(id) => setActive(id as SliceTab)}
>
<EmptyState
label="SLICE NOT AVAILABLE"
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).`
}
variant="card"
testId="slice-scope-error"
/>
</ScopeShell>
);
}
const detail = detailQuery.data;
return (
<ScopeShell
eyebrow="Slice"
title={sliceId}
title={detail.displayName || detail.name}
tabs={SLICE_TABS}
active={active}
onSelect={(id) => setActive(id as SliceTab)}
>
{/* Slice tabs from prior shell take rich slice-detail data as props
(StoryTab needs events+phaseDefinitions, TestsVerificationTab
needs tests, TopologyTab needs topology) — re-mount with data
piping is Phase 5 polish. Phase 3 V1 renders a placeholder per
tab. */}
<PlaceholderTab
label={`SLICE ${active.toUpperCase()}`}
description={`${active === "story" ? "Slice timeline narrative" : active === "tests" ? "Test/verification proof packets" : active === "topology" ? "Tabular agents on slice" : "Slice scope tab"} — Phase 5 wires existing tab component with slice-detail data piping.`}
/>
{active === "story" ? (
<StoryTab events={detail.story.events} phaseDefinitions={detail.story.phaseDefinitions} />
) : null}
{active === "overview" ? (
<SliceOverviewTab sliceName={detail.name} docsTree={detail.docs.tree} />
) : null}
{active === "progress" ? (
// FOLDED: AcceptanceTab content. Acceptance is the canonical "progress"
// proof at slice scope per project-tree.md L65 (acceptance items from
// IMPLEMENTATION-PRD with current state).
<AcceptanceTab acceptance={detail.acceptance} />
) : null}
{active === "artifacts" ? (
<SliceArtifactsTab
sliceName={detail.name}
docsTree={detail.docs.tree}
decisionsRows={detail.decisions.rows}
/>
) : null}
{active === "tests" ? (
<TestsVerificationTab sliceName={detail.name} tests={detail.tests} />
) : null}
{active === "queue" ? <SliceQueueTab qitemIds={detail.qitemIds} /> : null}
{active === "topology" ? <TopologyTab topology={detail.topology} /> : null}
</ScopeShell>
);
}
+217
View File
@@ -0,0 +1,217 @@
// V1 attempt-3 Phase 5 P5-2 — SliceScopePage tab content piping reachability.
//
// Each canonical slice tab (story/overview/progress/artifacts/tests/queue/
// topology) renders its mounted component once /api/slices/:name resolves.
// Loading + error states get their own EmptyState renders. The fold
// mapping (AcceptanceTab → progress; DocsTab+DecisionsTab → artifacts;
// QueueItemTrigger → queue) is verified via testid presence.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, cleanup, waitFor, fireEvent } from "@testing-library/react";
import {
createMemoryHistory,
RouterProvider,
createRouter,
createRootRoute,
createRoute,
Outlet,
} from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { DrawerSelectionContext } from "../src/components/AppShell.js";
import { SliceScopePage } 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 renderSliceScope(opts: {
sliceId: string;
detail?: SliceDetail | null;
status?: number;
}): { setSelection: ReturnType<typeof vi.fn> } & ReturnType<typeof render> {
const setSelection = vi.fn();
// Mock /api/slices/:name response.
mockFetch.mockImplementation(async (url: string) => {
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 });
}
return new Response("[]");
});
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const rootRoute = createRootRoute({ component: () => <Outlet /> });
const sliceRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/project/slice/$sliceId",
component: () => (
<DrawerSelectionContext.Provider value={{ selection: null, setSelection }}>
<SliceScopePage />
</DrawerSelectionContext.Provider>
),
});
// Stub other routes that tabs may try to Link to (TopologyTab uses
// /topology/seat/... links etc).
const fallbackRoute = createRoute({
getParentRoute: () => rootRoute,
path: "$",
component: () => null,
});
const router = createRouter({
routeTree: rootRoute.addChildren([sliceRoute, fallbackRoute]),
history: createMemoryHistory({ initialEntries: [`/project/slice/${opts.sliceId}`] }),
});
const utils = render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
return { setSelection, ...utils };
}
function makeDetail(overrides: Partial<SliceDetail> = {}): SliceDetail {
return {
name: "idea-ledger",
displayName: "Idea Ledger",
railItem: null,
status: "active",
rawStatus: "active",
qitemIds: ["qitem-A", "qitem-B"],
commitRefs: [],
lastActivityAt: "2026-05-06T18:00:00Z",
workflowBinding: null,
story: { events: [], phaseDefinitions: null },
acceptance: {
totalItems: 3,
doneItems: 1,
percentage: 33,
items: [
{
line: 1,
rawLine: "[x] item one",
status: "done",
text: "item one",
path: "PROGRESS.md",
} as any,
],
closureCallout: null,
currentStep: null,
},
decisions: { rows: [] },
docs: { tree: [{ name: "README.md", relPath: "README.md", type: "file" } as any] },
tests: { proofPackets: [], aggregate: { passCount: 0, failCount: 0 } },
topology: { affectedRigs: [], totalSeats: 0, specGraph: null },
...overrides,
};
}
describe("SliceScopePage P5-2 tab content piping", () => {
it("loading state renders EmptyState with slice id", async () => {
// Don't resolve fetch — keep query in-flight.
mockFetch.mockImplementation(() => new Promise(() => {}));
const { findByTestId } = renderSliceScope({ sliceId: "idea-ledger", detail: null });
expect(await findByTestId("slice-scope-loading")).toBeTruthy();
});
it("error/404 renders EmptyState with not-available message", async () => {
const { findByTestId } = renderSliceScope({ sliceId: "missing-slice", status: 404 });
expect(await findByTestId("slice-scope-error")).toBeTruthy();
});
it("default landing tab is 'story'; mounts StoryTab with events + phaseDefinitions", async () => {
const { container, findByTestId } = renderSliceScope({
sliceId: "idea-ledger",
detail: makeDetail(),
});
// ScopeShell mounts; project-tab-nav rendered.
await findByTestId("project-tab-nav");
// Story is the default active tab.
const storyTab = container.querySelector("[data-testid='project-tab-story']");
expect(storyTab?.getAttribute("data-active")).toBe("true");
});
it("progress tab mounts AcceptanceTab (FOLDED per code-map)", async () => {
const { container, findByTestId } = renderSliceScope({
sliceId: "idea-ledger",
detail: makeDetail(),
});
await findByTestId("project-tab-nav");
fireEvent.click(container.querySelector("[data-testid='project-tab-progress']")!);
// AcceptanceTab renders a header progress bar with percentage. Smoke
// test: tabpanel exists + does NOT show the placeholder.
await waitFor(() => {
expect(container.querySelector("[data-testid='project-tab-placeholder-slice progress']")).toBeNull();
});
});
it("artifacts tab mounts FOLDED Docs + Decisions sections", async () => {
const { container, findByTestId } = renderSliceScope({
sliceId: "idea-ledger",
detail: makeDetail(),
});
await findByTestId("project-tab-nav");
fireEvent.click(container.querySelector("[data-testid='project-tab-artifacts']")!);
expect(await findByTestId("slice-artifacts-tab")).toBeTruthy();
expect(container.querySelector("[data-testid='slice-artifacts-docs']")).toBeTruthy();
expect(container.querySelector("[data-testid='slice-artifacts-decisions']")).toBeTruthy();
});
it("queue tab lists qitemIds wrapped in QueueItemTrigger; click fires setSelection", async () => {
const { container, findByTestId, setSelection } = renderSliceScope({
sliceId: "idea-ledger",
detail: makeDetail(),
});
await findByTestId("project-tab-nav");
fireEvent.click(container.querySelector("[data-testid='project-tab-queue']")!);
const trigger = await findByTestId("slice-queue-trigger-qitem-A");
fireEvent.click(trigger);
expect(setSelection).toHaveBeenCalledWith({
type: "qitem",
data: { qitemId: "qitem-A" },
});
});
it("queue tab empty-state when slice has no qitemIds", async () => {
const { container, findByTestId } = renderSliceScope({
sliceId: "idea-ledger",
detail: makeDetail({ qitemIds: [] }),
});
await findByTestId("project-tab-nav");
fireEvent.click(container.querySelector("[data-testid='project-tab-queue']")!);
expect(await findByTestId("slice-queue-empty")).toBeTruthy();
});
it("topology tab mounts TopologyTab", async () => {
const { container, findByTestId } = renderSliceScope({
sliceId: "idea-ledger",
detail: makeDetail(),
});
await findByTestId("project-tab-nav");
fireEvent.click(container.querySelector("[data-testid='project-tab-topology']")!);
// TopologyTab renders empty state when no rigs + no specGraph; the
// tab panel itself just must exist + have no placeholder.
await waitFor(() => {
expect(container.querySelector("[data-testid='project-tab-placeholder-slice topology']")).toBeNull();
});
});
it("tests tab mounts TestsVerificationTab", async () => {
const { container, findByTestId } = renderSliceScope({
sliceId: "idea-ledger",
detail: makeDetail(),
});
await findByTestId("project-tab-nav");
fireEvent.click(container.querySelector("[data-testid='project-tab-tests']")!);
// TestsVerificationTab renders empty-state when no proofPackets.
expect(await findByTestId("tests-empty")).toBeTruthy();
});
});