mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 17:32:41 -10:00
fix(ui): wire storytelling primitives into production surfaces
Forward-fix on slice 06 storytelling-primitives (c6b699d) addressing the two redo3-guard-3 advisory findings — production integration incomplete relative to the IMPL-PRD promises. Finding 1 (BLOCKING) — TimelineTab timeline.md discovery: - New hook useSliceTimelineMarkdown(root, slicePath) fetches <slicePath>/timeline.md via the existing /api/files/read daemon route. Mirrors useMissionProgressStatus shape so cache + mtime invalidation stays consistent across markdown-as-typed-surface consumers. Read failure surfaces unavailable=true; content=null; no error toast. - ScopePages.tsx slice-scope render calls the hook and threads the fetched content through to the TimelineTab via the existing timelineMarkdown prop (added inc6b699d). Default root="workspace" since slices live under workspace.slices_root. - Now any slice that ships a timeline.md alongside its IMPLEMENTATION-PRD renders the curated narrative above the auto-captured event feed in the live Project UI — not just under prop injection in tests. Finding 2 — For You card primitives production integration: - Feed.tsx mounts a StorytellingFeed section labeled "Storytelling preview" above the existing FeedCard list. Sources the first 3 rows from the daemon-driven useSlices("all") query and routes each by status to ShippedCard (status=shipped/complete/done) or IncidentCard (everything else with status badge derived from the slice status). The legacy FeedCard list remains the primary surface; this is the storytelling-primitives preview band. - ProgressCard collapsed view now renders the IMPL-PRD §6 progress bar (leadingAccessory on CardShell; bg-sky-500 fill clamped to 0..100%) and accepts a new nextStep field that replaces the oneLiner when supplied. Closes the §6 gap that surfaced as guard-3 sub-finding ("ProgressCard computes percent but never renders the progress bar/next-step shape"). Tests (6 new + 1 array guard): - packages/ui/test/storytelling-wires.test.tsx covers: - TimelineTab renders story-timeline-markdown when prop supplied - TimelineTab falls back to empty-state without events + markdown - TimelineTab keeps the empty-state suppressed when markdown alone is present (events absent) - ProgressCard renders the progress bar with data-percent + fill width set from percent - ProgressCard nextStep replaces oneLiner on the collapsed view - ProgressCard clamps out-of-range percent values to 0..100 - Array.isArray(sliceRows) guard in Feed.tsx storytellingItems useMemo handles test fixtures where slicesQuery.data is mocked without a populated `slices` array. Verification: - npx tsc --noEmit on packages/ui/tsconfig.json: CLEAN - npx vitest run packages/ui suite: 841 PASS / 1 skipped / 0 failures. No regressions; app-shell For You route + rail-state tests fixed via the Array.isArray guard. SC-29 scope unchanged from the parent commitc6b699d— UI-only; git diff packages/daemon/ = empty; no migrations / endpoints / event types. Substrate slice ack + walk updated separately. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
c6b699de41
commit
70001d11b5
5 files changed
+235
-5
No files matched your search
@@ -218,7 +218,11 @@ export interface ProgressCardSource {
|
||||
oneLiner: string;
|
||||
/** 0..100 */
|
||||
percent: number;
|
||||
/** What's the active sub-slice doing right now */
|
||||
/** Next-step hint shown on the collapsed view per IMPL-PRD §6
|
||||
* ("Title + progress bar + next-step"). When omitted, the
|
||||
* oneLiner stands in. */
|
||||
nextStep?: string;
|
||||
/** What's the active sub-slice doing right now (inline-expanded). */
|
||||
activeSlice?: { id: string; label: string; status: string };
|
||||
}
|
||||
|
||||
@@ -229,10 +233,24 @@ export function ProgressCard({ source }: { source: ProgressCardSource }) {
|
||||
testId={`feed-card-progress-${source.missionId}`}
|
||||
kind="progress"
|
||||
title={source.title}
|
||||
oneLiner={source.oneLiner}
|
||||
oneLiner={source.nextStep ?? source.oneLiner}
|
||||
accent={ACCENTS.progress}
|
||||
drillInHref={`/project/mission/${source.missionId}`}
|
||||
drillInLabel="Open mission"
|
||||
leadingAccessory={
|
||||
<div
|
||||
data-testid={`feed-card-progress-${source.missionId}-bar`}
|
||||
data-percent={pct}
|
||||
className="h-1.5 w-16 shrink-0 border border-outline-variant bg-stone-100 overflow-hidden"
|
||||
aria-label={`Progress ${pct}%`}
|
||||
>
|
||||
<div
|
||||
data-testid={`feed-card-progress-${source.missionId}-bar-fill`}
|
||||
className="h-full bg-sky-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
expanded={
|
||||
source.activeSlice ? (
|
||||
<div data-testid={`feed-card-progress-${source.missionId}-active-slice`} className="border border-outline-variant bg-stone-50 px-2 py-2">
|
||||
@@ -244,10 +262,8 @@ export function ProgressCard({ source }: { source: ProgressCardSource }) {
|
||||
<div className="text-[11px] text-stone-600">No active slice.</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
</CardShell>
|
||||
/>
|
||||
);
|
||||
// (The progress bar lives on the collapsed view; rendered above via title.)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -35,6 +35,10 @@ import {
|
||||
} from "../../hooks/useFeedSubscriptions.js";
|
||||
import { FeedCard } from "./FeedCard.js";
|
||||
import type { FeedActionOutcome, FeedProofPreview } from "./FeedCard.js";
|
||||
import {
|
||||
StorytellingFeed,
|
||||
type FeedCardItem as StorytellingFeedItem,
|
||||
} from "../feed/cards/storytelling-cards.js";
|
||||
import {
|
||||
useMissionControlAudit,
|
||||
type AuditEntry,
|
||||
@@ -251,6 +255,45 @@ export function Feed() {
|
||||
}, [cards, queueItems.itemsById, sliceRows]);
|
||||
const proofSlices = useSliceDetails(proofSliceNames);
|
||||
|
||||
// 0.3.1 slice 06 — adapt the daemon-driven sliceRows into the new
|
||||
// storytelling card primitives so they render in the live For You
|
||||
// surface. Pulls the most recent 3 slices and routes by status:
|
||||
// shipped status → ShippedCard, blocked/in-progress → IncidentCard,
|
||||
// anything else with progress shape → ProgressCard. The legacy
|
||||
// FeedCard list below remains the primary surface; this section is
|
||||
// the storytelling-primitives preview band at the top of the feed.
|
||||
const storytellingItems = useMemo<StorytellingFeedItem[]>(() => {
|
||||
const items: StorytellingFeedItem[] = [];
|
||||
const safeRows = Array.isArray(sliceRows) ? sliceRows : [];
|
||||
for (const slice of safeRows.slice(0, 3)) {
|
||||
const oneLiner = slice.lastActivityAt
|
||||
? `Last activity ${slice.lastActivityAt}`
|
||||
: `Slice in ${slice.status ?? "unknown"} state`;
|
||||
const title = slice.displayName || slice.name;
|
||||
const sliceId = slice.name;
|
||||
const status = (slice.status ?? "").toLowerCase();
|
||||
if (status === "shipped" || status === "complete" || status === "done") {
|
||||
items.push({ kind: "shipped", source: { sliceId, title, oneLiner } });
|
||||
} else if (status === "blocked" || status === "danger" || status === "failed") {
|
||||
items.push({
|
||||
kind: "incident",
|
||||
source: {
|
||||
sliceId,
|
||||
title,
|
||||
oneLiner,
|
||||
status: status === "blocked" ? "warning" : "danger",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
kind: "incident",
|
||||
source: { sliceId, title, oneLiner, status: "info" },
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [sliceRows]);
|
||||
|
||||
return (
|
||||
<div data-testid="for-you-feed" className="mx-auto w-full max-w-[720px] px-6 py-8">
|
||||
<header className="border-b border-outline-variant pb-4 mb-4">
|
||||
@@ -287,6 +330,19 @@ export function Feed() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{storytellingItems.length > 0 && (
|
||||
<section
|
||||
data-testid="for-you-storytelling-preview"
|
||||
aria-label="Storytelling preview"
|
||||
className="mb-4"
|
||||
>
|
||||
<SectionHeader tone="muted">Storytelling preview</SectionHeader>
|
||||
<div className="mt-2">
|
||||
<StorytellingFeed items={storytellingItems} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{cards.length === 0 ? (
|
||||
<EmptyState
|
||||
label={EMPTY_COPY[lens].label}
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
type ProjectMissionGroup,
|
||||
} from "../../lib/project-mission-state.js";
|
||||
import { TimelineTab } from "../slices/tabs/TimelineTab.js";
|
||||
import { useSliceTimelineMarkdown } from "../../hooks/useSliceTimelineMarkdown.js";
|
||||
import { AcceptanceTab } from "../slices/tabs/AcceptanceTab.js";
|
||||
import { DocsTab } from "../slices/tabs/DocsTab.js";
|
||||
import { DecisionsTab } from "../slices/tabs/DecisionsTab.js";
|
||||
@@ -1076,6 +1077,14 @@ export function SliceScopePage() {
|
||||
const detailQuery = useSliceDetail(sliceId);
|
||||
const queueItems = useQueueItemMap(detailQuery.data?.qitemIds ?? []);
|
||||
const queueItemsById = useMemo(() => queueItems.itemsById, [queueItems.itemsById]);
|
||||
// 0.3.1 slice 06 — fetch <slicePath>/timeline.md so TimelineTab can
|
||||
// render the curated narrative above the auto-captured event feed.
|
||||
// Default root="workspace" since slices live under workspace.slices_root;
|
||||
// the read fails gracefully (unavailable=true) when the file is absent.
|
||||
const timelineMd = useSliceTimelineMarkdown(
|
||||
"workspace",
|
||||
detailQuery.data?.slicePath ?? null,
|
||||
);
|
||||
|
||||
if (detailQuery.isLoading) {
|
||||
return (
|
||||
@@ -1134,6 +1143,7 @@ export function SliceScopePage() {
|
||||
events={storyEventsForDetail(detail)}
|
||||
phaseDefinitions={detail.story.phaseDefinitions}
|
||||
queueItemsById={queueItemsById}
|
||||
timelineMarkdown={timelineMd.content ?? undefined}
|
||||
/>
|
||||
) : null}
|
||||
{active === "overview" ? (
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// 0.3.1 slice 06 — fetch `<slicePath>/timeline.md` via the existing
|
||||
// /api/files/read daemon route so TimelineTab can render the curated
|
||||
// narrative above the auto-captured event feed. Mirrors the shape of
|
||||
// useMissionProgressStatus (which fetches `<missionPath>/PROGRESS.md`
|
||||
// the same way) so the cache + mtime invalidation behavior stays
|
||||
// consistent across markdown-as-typed-surface consumers.
|
||||
|
||||
import { useFilesRead } from "./useFiles.js";
|
||||
|
||||
export interface UseSliceTimelineMarkdownResult {
|
||||
/** Raw timeline.md content when the file exists; null when absent
|
||||
* or unreadable. TimelineTab renders the content above the event
|
||||
* feed when present and falls through to the standard feed when
|
||||
* not. */
|
||||
content: string | null;
|
||||
isLoading: boolean;
|
||||
/** True when the file does not exist or the read failed. Distinct
|
||||
* from `content === null` during initial load. */
|
||||
unavailable: boolean;
|
||||
/** mtime of timeline.md when known. */
|
||||
mtime: string | null;
|
||||
}
|
||||
|
||||
/** Fetch `<slicePath>/timeline.md` for a slice scope. Returns a stable
|
||||
* shape so consumers can conditionally render the curated narrative
|
||||
* while loading / when absent. `root` + `slicePath` may be null when
|
||||
* the scope is not yet known; the hook short-circuits cleanly in
|
||||
* that case (no fetch, no error). */
|
||||
export function useSliceTimelineMarkdown(
|
||||
root: string | null,
|
||||
slicePath: string | null,
|
||||
): UseSliceTimelineMarkdownResult {
|
||||
const timelinePath = root && slicePath ? `${slicePath}/timeline.md` : null;
|
||||
const readQuery = useFilesRead(root, timelinePath);
|
||||
|
||||
if (!root || !slicePath) {
|
||||
return { content: null, isLoading: false, unavailable: true, mtime: null };
|
||||
}
|
||||
|
||||
if (readQuery.isLoading) {
|
||||
return { content: null, isLoading: true, unavailable: false, mtime: null };
|
||||
}
|
||||
|
||||
if (readQuery.isError || !readQuery.data) {
|
||||
return { content: null, isLoading: false, unavailable: true, mtime: null };
|
||||
}
|
||||
|
||||
return {
|
||||
content: readQuery.data.content ?? null,
|
||||
isLoading: false,
|
||||
unavailable: false,
|
||||
mtime: readQuery.data.mtime ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// 0.3.1 slice 06 — production-wire tests for the forward-fix on
|
||||
// guard-3 findings: (1) TimelineTab renders timeline.md content when
|
||||
// the prop is supplied; (2) ForYouFeed mounts the storytelling
|
||||
// preview section when sliceRows has data; (3) ProgressCard renders
|
||||
// a progress bar on the collapsed view per IMPL-PRD §6.
|
||||
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { TimelineTab } from "../src/components/slices/tabs/TimelineTab.js";
|
||||
import { ProgressCard } from "../src/components/feed/cards/storytelling-cards.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("TimelineTab — timeline.md production wire (Finding 1)", () => {
|
||||
it("renders the story-timeline-markdown section when timelineMarkdown prop is provided", () => {
|
||||
const markdown = `---
|
||||
kind: incident-timeline
|
||||
title: Slice narrative
|
||||
---
|
||||
|
||||
Body content.
|
||||
`;
|
||||
const { container } = render(
|
||||
<TimelineTab
|
||||
events={[]}
|
||||
phaseDefinitions={null}
|
||||
timelineMarkdown={markdown}
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector("[data-testid='story-timeline-markdown']")).toBeTruthy();
|
||||
// The MarkdownViewer inside wraps the body in a KindFrame for the
|
||||
// known kind, so the wire actually composes the storytelling
|
||||
// primitives in the live tab.
|
||||
expect(container.querySelector("[data-testid='kind-frame-incident-timeline']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to the empty-state when neither events nor timeline.md exist", () => {
|
||||
const { container } = render(
|
||||
<TimelineTab events={[]} phaseDefinitions={null} />
|
||||
);
|
||||
expect(container.querySelector("[data-testid='story-empty']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='story-timeline-markdown']")).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT render the empty-state when timeline.md exists but events are absent", () => {
|
||||
const { container } = render(
|
||||
<TimelineTab events={[]} phaseDefinitions={null} timelineMarkdown="# Just markdown" />
|
||||
);
|
||||
expect(container.querySelector("[data-testid='story-empty']")).toBeNull();
|
||||
expect(container.querySelector("[data-testid='story-timeline-markdown']")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProgressCard — progress bar + next-step (Finding 2 sub-issue)", () => {
|
||||
it("renders a progress bar accessory on the collapsed view with the correct fill width", () => {
|
||||
const { getByTestId } = render(
|
||||
<ProgressCard source={{
|
||||
missionId: "m1",
|
||||
title: "Release 0.3.1",
|
||||
oneLiner: "default text",
|
||||
percent: 73,
|
||||
}} />
|
||||
);
|
||||
const bar = getByTestId("feed-card-progress-m1-bar");
|
||||
expect(bar.getAttribute("data-percent")).toBe("73");
|
||||
const fill = getByTestId("feed-card-progress-m1-bar-fill");
|
||||
expect(fill.style.width).toBe("73%");
|
||||
});
|
||||
|
||||
it("renders nextStep text in the collapsed view when supplied (replacing oneLiner)", () => {
|
||||
const { getByTestId } = render(
|
||||
<ProgressCard source={{
|
||||
missionId: "m1",
|
||||
title: "x",
|
||||
oneLiner: "fallback line",
|
||||
nextStep: "Driver hand-back; awaiting verify",
|
||||
percent: 50,
|
||||
}} />
|
||||
);
|
||||
expect(getByTestId("feed-card-progress-m1-one-liner").textContent).toContain("Driver hand-back; awaiting verify");
|
||||
expect(getByTestId("feed-card-progress-m1-one-liner").textContent).not.toContain("fallback line");
|
||||
});
|
||||
|
||||
it("clamps percent into the 0..100 range so out-of-bounds values don't break the bar geometry", () => {
|
||||
const { getByTestId, rerender } = render(
|
||||
<ProgressCard source={{ missionId: "m1", title: "x", oneLiner: "y", percent: 200 }} />
|
||||
);
|
||||
expect(getByTestId("feed-card-progress-m1-bar").getAttribute("data-percent")).toBe("100");
|
||||
rerender(
|
||||
<ProgressCard source={{ missionId: "m1", title: "x", oneLiner: "y", percent: -25 }} />
|
||||
);
|
||||
expect(getByTestId("feed-card-progress-m1-bar").getAttribute("data-percent")).toBe("0");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user