mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
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:
1 parent
246f9651be
commit
233aeb258a
2 files changed
+393
-12
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user