feat(for-you): wire ApprovalCard to real data; remove ConceptCard from production switch

The storytelling adapter (buildStorytellingFeedItems) now accepts a 4th
argument: already-classified FeedCard[] from useActivityFeed +
classifyFeed. It filters to kind === "approval", maps each to
ApprovalCardSource (qitemId extracted from event payload; fallback to
FeedCard.id), and caps at 2 entries to match the band's tight preview
budget.

The ConceptCard component is preserved, but its branch is removed from
StorytellingFeed's switch — the adapter never emits "concept" so the
branch was dead. A 0.3.2 slice will pick a deliberate data source and
re-add the branch.

Feed.tsx now passes its existing rawCards (classifyFeed output) as the
new 4th arg. No new state or hooks; the upstream pipeline already
existed for the long-form FeedCard list.

Tests added to storytelling-feed-adapter.test.ts:
- approval-kind FeedCards → ApprovalCard items with qitemId from
  payload (qitemId AND qitem_id resolve; fallback to FeedCard.id when
  neither key is present)
- approvals capped at 2
- non-approval FeedCards are ignored (Shipped/Progress/Incident come
  from missions+slices, not from feedCards)
- output never contains concept-kind items
- title / oneLiner / bodyPreview / drillInHref shape verified

All storytelling tests green (52/52). Pre-existing tsc errors in
SpecsTable + TopologyTableView (missing @tanstack/react-table) are
unrelated.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-14 21:00:38 -07:00
1 parent ce01c226cc
commit 14234dcf8b
3 files changed
+165 -2

No files matched your search

@@ -12,6 +12,7 @@
import { useState, type ReactNode } from "react";
import { CornerBracket } from "../../dashboard/vellum/index.js";
import type { FeedCard } from "../../../lib/feed-classifier.js";
// -----------------------------------------------------------------------------
// CardShell — shared chrome
@@ -501,6 +502,17 @@ export interface AdapterSliceRow {
status?: string | null;
lastActivityAt?: string | null;
}
// Wired card kinds (post 0.3.1 storytelling-adapter wire-up):
// - progress ← missions (mapped from useMissionDiscovery rows)
// - shipped ← slices with status shipped/complete/done
// - incident ← slices with status blocked/failed/danger/etc.
// - approval ← FeedCard items with kind === "approval" (from
// useActivityFeed → classifyFeed) — caller threads in
// via the 4th arg.
// Deferred: concept — ConceptCard component is preserved for a 0.3.2
// slice that picks a deliberate data source; until then the adapter
// never emits concept items and the production switch in
// StorytellingFeed has no concept branch.
export function buildStorytellingFeedItems(
missions: AdapterMissionRow[],
slices: AdapterSliceRow[],
@@ -511,6 +523,14 @@ export function buildStorytellingFeedItems(
* disappears from the storytelling band).
*/
completedMissionIds?: Set<string>,
/**
* 0.3.1 storytelling-adapter wire-up — already-classified FeedCard
* items from useActivityFeed/classifyFeed. The adapter filters to
* `kind === "approval"` and emits up to 2 ApprovalCards in the
* preview band. Other kinds are ignored here (FeedCard.tsx continues
* to render the long-form list below).
*/
feedCards?: FeedCard[],
): FeedCardItem[] {
const items: FeedCardItem[] = [];
// Two filters: (1) localStorage optimistic hide set; (2) durable
@@ -560,6 +580,38 @@ export function buildStorytellingFeedItems(
items.push({ kind: "incident", source: { sliceId, title, oneLiner, status: "info" } });
}
}
// Approval wire-up: classified FeedCards with kind === "approval" are
// mapped to ApprovalCardSource. qitemId lives in the source event
// payload (FeedCard.id is `${type}-${seq}`, not the qitem id); fall
// back to FeedCard.id only if neither key is present so each card
// still has a stable React key. Cap at 2 to match the band's tight
// preview budget.
const approvals = (feedCards ?? []).filter((c) => c.kind === "approval");
for (const card of approvals.slice(0, 2)) {
const payload = (card.source.payload ?? {}) as Record<string, unknown>;
const fromPayload =
(typeof payload.qitemId === "string" && payload.qitemId.length > 0)
? payload.qitemId
: (typeof payload.qitem_id === "string" && payload.qitem_id.length > 0)
? payload.qitem_id
: null;
const qitemId = fromPayload ?? card.id;
const author = card.authorSession ? ` from ${card.authorSession}` : "";
const bodyPreview =
card.body && card.body.length > 240
? `${card.body.slice(0, 237)}...`
: card.body;
items.push({
kind: "approval",
source: {
qitemId,
title: card.title,
oneLiner: `Needs approval${author}`,
bodyPreview,
drillInHref: "/for-you",
},
});
}
return items;
}
@@ -582,11 +634,14 @@ export function StorytellingFeed({
return (
<div data-testid="storytelling-feed" className="flex flex-col gap-4">
{items.map((item, i) => {
// ConceptCard is preserved as a component but is not reachable
// here — the adapter never emits `kind: "concept"`. A 0.3.2
// slice will pick a data source and re-add the branch.
if (item.kind === "shipped") return <ShippedCard key={i} source={item.source} />;
if (item.kind === "incident") return <IncidentCard key={i} source={item.source} />;
if (item.kind === "progress") return <ProgressCard key={i} source={item.source} onMarkComplete={onMarkMissionComplete} />;
if (item.kind === "approval") return <ApprovalCard key={i} source={item.source} />;
return <ConceptCard key={i} source={item.source} />;
return null;
})}
</div>
);
+2 -1
View File
@@ -320,8 +320,9 @@ export function Feed() {
missionsWithStatus,
Array.isArray(sliceRows) ? sliceRows : [],
completedMissionIds,
rawCards,
),
[missionsWithStatus, sliceRows, completedMissionIds],
[missionsWithStatus, sliceRows, completedMissionIds, rawCards],
);
// Slice 18 §3.5 — Getting Started complete-and-hide. Optimistic local
@@ -7,6 +7,40 @@
import { describe, it, expect } from "vitest";
import { buildStorytellingFeedItems } from "../src/components/feed/cards/storytelling-cards.js";
import type { FeedCard } from "../src/lib/feed-classifier.js";
import type { ActivityEvent } from "../src/hooks/useActivityFeed.js";
function makeApprovalFeedCard(opts: {
qitemId?: string;
altKey?: "qitem_id";
title?: string;
body?: string;
authorSession?: string;
id?: string;
}): FeedCard {
const payload: Record<string, unknown> = {};
if (opts.qitemId !== undefined) {
if (opts.altKey === "qitem_id") payload.qitem_id = opts.qitemId;
else payload.qitemId = opts.qitemId;
}
const evt: ActivityEvent = {
seq: 1,
type: "queue.created",
payload,
createdAt: "2026-05-15T00:00:00.000Z",
receivedAt: 1_700_000_000_000,
};
return {
id: opts.id ?? "queue.created-1",
kind: "approval",
title: opts.title ?? "Approval needed",
body: opts.body,
authorSession: opts.authorSession,
receivedAt: evt.receivedAt,
createdAt: evt.createdAt,
source: evt,
};
}
describe("buildStorytellingFeedItems — production adapter", () => {
it("routes missions into ProgressCard items (Finding 2 fix: ProgressCard now wired)", () => {
@@ -100,4 +134,77 @@ describe("buildStorytellingFeedItems — production adapter", () => {
// guards against runtime data drift (daemon could return null).
expect(buildStorytellingFeedItems(null, undefined)).toEqual([]);
});
it("routes approval-kind FeedCards into ApprovalCard items with qitemId extracted from payload", () => {
const items = buildStorytellingFeedItems(
[],
[],
undefined,
[
makeApprovalFeedCard({ qitemId: "qitem-abc", title: "Approve this", body: "body text", authorSession: "advisor@rig" }),
makeApprovalFeedCard({ qitemId: "qitem-def", altKey: "qitem_id", title: "Approve that" }),
],
);
expect(items).toHaveLength(2);
expect(items.every((i) => i.kind === "approval")).toBe(true);
if (items[0]!.kind === "approval") {
expect(items[0]!.source.qitemId).toBe("qitem-abc");
expect(items[0]!.source.title).toBe("Approve this");
expect(items[0]!.source.oneLiner).toContain("advisor@rig");
expect(items[0]!.source.bodyPreview).toBe("body text");
expect(items[0]!.source.drillInHref).toBe("/for-you");
}
if (items[1]!.kind === "approval") {
// Snake-case payload key also resolves.
expect(items[1]!.source.qitemId).toBe("qitem-def");
}
});
it("caps approvals at 2 to keep the preview band tight", () => {
const items = buildStorytellingFeedItems(
[],
[],
undefined,
[
makeApprovalFeedCard({ qitemId: "q1" }),
makeApprovalFeedCard({ qitemId: "q2" }),
makeApprovalFeedCard({ qitemId: "q3" }),
makeApprovalFeedCard({ qitemId: "q4" }),
],
);
expect(items.filter((i) => i.kind === "approval")).toHaveLength(2);
});
it("ignores non-approval FeedCards (Shipped / Progress / Incident come from missions+slices, not from feedCards)", () => {
const nonApproval: FeedCard[] = [
{ ...makeApprovalFeedCard({ qitemId: "x1" }), kind: "shipped" },
{ ...makeApprovalFeedCard({ qitemId: "x2" }), kind: "progress" },
{ ...makeApprovalFeedCard({ qitemId: "x3" }), kind: "action-required" },
{ ...makeApprovalFeedCard({ qitemId: "x4" }), kind: "observation" },
];
const items = buildStorytellingFeedItems([], [], undefined, nonApproval);
// No approval inputs ⇒ no approval outputs. Non-approval FeedCards
// are NOT routed through this adapter (mission/slice rows are the
// source for those kinds).
expect(items).toEqual([]);
});
it("never emits concept-kind items — ConceptCard is deferred to a 0.3.2 slice", () => {
const items = buildStorytellingFeedItems(
[{ name: "m1", path: "missions/m1" }],
[{ name: "s1", status: "shipped" }, { name: "s2", status: "blocked" }],
undefined,
[makeApprovalFeedCard({ qitemId: "q1" })],
);
expect(items.some((i) => i.kind === "concept")).toBe(false);
});
it("falls back to FeedCard.id when payload has no qitemId/qitem_id key", () => {
const card = makeApprovalFeedCard({ id: "queue.created-42" });
const items = buildStorytellingFeedItems([], [], undefined, [card]);
expect(items).toHaveLength(1);
if (items[0]!.kind === "approval") {
expect(items[0]!.source.qitemId).toBe("queue.created-42");
}
});
});