mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
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:
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>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user