Polish slice detail demo surfaces

This commit is contained in:
mvschwarz committed 2026-05-07 03:41:22 -07:00
1 parent f664c26b75
commit a5faf034e4
10 files changed
+856 -564

No files matched your search

@@ -1,132 +0,0 @@
import type { NodeDetailPeer, NodeDetailEdge, NodeDetailTranscript, NodeDetailCompactSpec } from "../hooks/useNodeDetail.js";
import { copyText } from "../lib/copy-text.js";
interface ContextUsageData {
availability: string;
reason?: string | null;
usedPercentage?: number | null;
remainingPercentage?: number | null;
contextWindowSize?: number | null;
totalInputTokens?: number | null;
totalOutputTokens?: number | null;
currentUsage?: string | null;
source?: string | null;
sampledAt?: string | null;
fresh?: boolean;
}
interface LiveIdentityDisplayProps {
peers: NodeDetailPeer[];
edges: { outgoing: NodeDetailEdge[]; incoming: NodeDetailEdge[] };
transcript: NodeDetailTranscript;
compactSpec: NodeDetailCompactSpec;
contextUsage?: ContextUsageData;
}
export function LiveIdentityDisplay({ peers, edges, transcript, compactSpec, contextUsage }: LiveIdentityDisplayProps) {
return (
<>
{/* Edges */}
{(edges.outgoing.length > 0 || edges.incoming.length > 0) && (
<section data-testid="detail-edges" className="px-4 py-3 border-b border-stone-100">
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-2">Edges</div>
<div className="space-y-0.5 font-mono text-[10px]">
{edges.outgoing.map((e, i) => (
<div key={`out-${i}`} className="flex gap-1">
<span className="text-stone-400">→</span>
<span className="text-stone-500">{e.kind}</span>
<span className="text-stone-900">{e.to?.logicalId ?? "?"}</span>
</div>
))}
{edges.incoming.map((e, i) => (
<div key={`in-${i}`} className="flex gap-1">
<span className="text-stone-400">←</span>
<span className="text-stone-500">{e.kind}</span>
<span className="text-stone-900">{e.from?.logicalId ?? "?"}</span>
</div>
))}
</div>
</section>
)}
{/* Peers */}
{peers.length > 0 && (
<section data-testid="detail-peers" className="px-4 py-3 border-b border-stone-100">
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-2">Peers</div>
<div className="space-y-0.5 font-mono text-[10px]">
{peers.map((p) => (
<div key={p.logicalId} className="space-y-0">
<div className="flex justify-between">
<span className="text-stone-900">{p.logicalId}</span>
<span className="text-stone-500">{p.runtime ?? "—"}</span>
</div>
{p.canonicalSessionName && (
<div className="text-[9px] text-stone-400 truncate">{p.canonicalSessionName}</div>
)}
</div>
))}
</div>
</section>
)}
{/* Transcript */}
{transcript.enabled && transcript.tailCommand && (
<section data-testid="detail-transcript" className="px-4 py-3 border-b border-stone-100">
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-2">Transcript</div>
<div className="font-mono text-[9px] text-stone-700">{transcript.path ?? "enabled"}</div>
<button
onClick={() => copyText(transcript.tailCommand!)}
className="mt-1 px-2 py-1 border border-stone-300 font-mono text-[8px] uppercase hover:bg-stone-200 text-left truncate w-full"
>
Copy tail command
</button>
</section>
)}
{/* Compact Spec */}
{compactSpec.name && (
<section data-testid="detail-compact-spec" className="px-4 py-3 border-b border-stone-100">
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-2">Agent Spec</div>
<div className="space-y-0.5 font-mono text-[10px]">
<div className="flex justify-between"><span className="text-stone-500">Spec</span><span className="text-stone-900">{compactSpec.name}</span></div>
{compactSpec.version && (
<div className="flex justify-between"><span className="text-stone-500">Version</span><span className="text-stone-900">{compactSpec.version}</span></div>
)}
{compactSpec.profile && (
<div className="flex justify-between"><span className="text-stone-500">Profile</span><span className="text-stone-900">{compactSpec.profile}</span></div>
)}
<div className="flex justify-between"><span className="text-stone-500">Skills</span><span className="text-stone-900">{compactSpec.skillCount}</span></div>
<div className="flex justify-between"><span className="text-stone-500">Guidance</span><span className="text-stone-900">{compactSpec.guidanceCount}</span></div>
</div>
</section>
)}
{/* Context Usage */}
<section data-testid="detail-context-usage" className="px-4 py-3 border-b border-stone-100">
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-2">Context</div>
{contextUsage?.availability === "known" ? (
<div className="space-y-0.5 font-mono text-[10px]">
<div className="flex justify-between"><span className="text-stone-500">Used</span><span className="text-stone-900">{contextUsage.usedPercentage}%</span></div>
<div className="flex justify-between"><span className="text-stone-500">Remaining</span><span className="text-stone-900">{contextUsage.remainingPercentage}%</span></div>
{contextUsage.contextWindowSize && (
<div className="flex justify-between"><span className="text-stone-500">Window</span><span className="text-stone-900">{contextUsage.contextWindowSize.toLocaleString()}</span></div>
)}
{contextUsage.totalInputTokens != null && (
<div className="flex justify-between"><span className="text-stone-500">Input tokens</span><span className="text-stone-900">{contextUsage.totalInputTokens.toLocaleString()}</span></div>
)}
{contextUsage.totalOutputTokens != null && (
<div className="flex justify-between"><span className="text-stone-500">Output tokens</span><span className="text-stone-900">{contextUsage.totalOutputTokens.toLocaleString()}</span></div>
)}
{contextUsage.sampledAt && (
<div className="flex justify-between"><span className="text-stone-500">Sampled</span><span className="text-stone-400 text-[9px]">{contextUsage.sampledAt}</span></div>
)}
{contextUsage.fresh === false && (
<div className="font-mono text-[9px] text-amber-600 mt-1">Stale sample</div>
)}
</div>
) : (
<div className="font-mono text-[10px] text-stone-400">unknown{contextUsage?.reason ? ` (${contextUsage.reason})` : ""}</div>
)}
</section>
</>
);
}
+326 -213
View File
@@ -1,44 +1,15 @@
// V1 polish slice Phase 5.1 P5.1-1 + P5.1-1a — agent detail dedup + layout.
// V1 agent-detail canonical surface.
//
// Founder direction at V1 polish (verbatim):
// "We have two competing UI elements for agent detail now... we need to
// take everything that was in the right sidebar and move it into the
// agent detail page... action buttons any kind of action you would put
// like near the top so launching cmux copying tmux resuming all those...
// then more informational stuff would load at the bottom"
// "Startup files all of those files should really be clickable so that
// when you click them it opens up the right hand content drawer"
// "There's redundancy on the agent detail page. So there's tabs across
// the top that say detail, transcript and terminal. And then below that
// is another set of tabs called identity agent spec, start up and
// transcript. So you have transcript kind of like twice... we should
// pick where we want the tabs and put them there... probably makes
// sense to put them down below"
// "I don't want to lose any content"
//
// Resolution (per Phase 5.1 ACK §2 audit + §6 drift disclosures):
// - Persistent header at TOP: WorkflowHeader + Action buttons +
// Status/failure card (merged from retired NodeDetailPanel) + Preview
// pane (merged) + LiveNodeCurrentState (Activity + Current Work) +
// Recent Events (merged; founder ask "don't lose content") +
// Infrastructure startup command card (merged; conditional).
// - Single 5-tab BODY row: Identity / Agent Spec / Startup / Transcript
// / Terminal. Replaces the prior outer SeatScopePage tabs +
// LiveNodeDetails inner tabs nesting (= "transcript twice" perception).
// - Startup tab: each file wrapped in FileReferenceTrigger — click
// opens FileViewer in drawer per content-drawer.md L23-L34.
// - Terminal tab: SessionPreviewPane mounts the live transcript-tail.
// - DRIFT P5.1-D1: SC-11 seat-scope outer tabs (detail/transcript/
// terminal) RETIRED at V1 polish per founder direction. SeatScopePage
// drops outer tabs entirely; LiveNodeDetails owns the canonical
// 5-tab surface inline.
// Polish-8 keeps the title and action row at the top, then makes the tab row
// the only body switcher. Status, preview, current work, transcript, terminal,
// and startup content live inside their named tabs so the page has one clear
// information hierarchy.
import { useState } from "react";
import { useNodeDetail, type NodeDetailData } from "../hooks/useNodeDetail.js";
import { useSpecLibrary, useLibraryReview } from "../hooks/useSpecLibrary.js";
import { WorkspacePage } from "./WorkspacePage.js";
import { WorkflowHeader } from "./WorkflowScaffold.js";
import { LiveIdentityDisplay } from "./LiveIdentityDisplay.js";
import { AgentSpecDisplay } from "./AgentSpecDisplay.js";
import { PreviewPane } from "./preview/PreviewPane.js";
import { SessionPreviewPane } from "./preview/SessionPreviewPane.js";
@@ -56,6 +27,8 @@ interface LiveNodeDetailsProps {
logicalId: string;
}
const SECTION_CLASS = "border border-outline-variant bg-white/30 p-3";
function statusColor(status: string | null): string {
switch (status) {
case "ready": return "text-green-600";
@@ -73,15 +46,24 @@ function startupStatusLabel(status: string | null): string {
}
}
/** Extract agent name from a local:agents/<name> ref. Returns null for unsupported forms. */
function resolveAgentName(agentRef: string | null): string | null {
if (!agentRef) return null;
const match = agentRef.match(/^local:agents\/([^/]+)$/);
return match?.[1] ?? null;
}
function AgentSpecSection({ agentRef }: { agentRef: string | null }) {
const agentName = resolveAgentName(agentRef);
function InfoRow({ label, value }: { label: string; value: string | number | null | undefined }) {
if (value === null || value === undefined || value === "") return null;
return (
<div className="flex justify-between gap-3 font-mono text-[10px]">
<span className="text-stone-500">{label}</span>
<span className="truncate text-right text-stone-900">{value}</span>
</div>
);
}
function AgentSpecSection({ data }: { data: NodeDetailData }) {
const agentName = resolveAgentName(data.agentRef);
const { data: agentEntries = [], isLoading: entriesLoading } = useSpecLibrary("agent");
const matches = agentName
@@ -91,36 +73,40 @@ function AgentSpecSection({ agentRef }: { agentRef: string | null }) {
const entryId = matches.length === 1 ? matches[0]!.id : null;
const { data: review, isLoading: reviewLoading } = useLibraryReview(entryId);
if (!agentName) {
return <div data-testid="agent-spec-unavailable" className="p-4 font-mono text-[10px] text-stone-400">No agent spec available</div>;
}
return (
<div data-testid="live-agent-spec-section" className="space-y-4">
{data.compactSpec.name && (
<section data-testid="detail-compact-spec" className={SECTION_CLASS}>
<div className="mb-2 font-mono text-[8px] uppercase tracking-wider text-stone-400">Resolved Agent Spec</div>
<div className="space-y-0.5">
<InfoRow label="Spec" value={data.compactSpec.name} />
<InfoRow label="Version" value={data.compactSpec.version} />
<InfoRow label="Profile" value={data.compactSpec.profile} />
<InfoRow label="Skills" value={data.compactSpec.skillCount} />
<InfoRow label="Guidance" value={data.compactSpec.guidanceCount} />
</div>
</section>
)}
if (entriesLoading || reviewLoading) {
return <div className="p-4 font-mono text-[10px] text-stone-400">Loading agent spec...</div>;
}
if (matches.length === 0) {
return <div data-testid="agent-spec-unavailable" className="p-4 font-mono text-[10px] text-stone-400">No agent spec available</div>;
}
if (matches.length > 1) {
return (
<div data-testid="agent-spec-ambiguous" className="p-4 font-mono text-[10px] text-amber-600">
Agent spec ambiguous ({matches.length} matches for &quot;{agentName}&quot;)
</div>
);
}
if (!review || review.kind !== "agent") {
return <div data-testid="agent-spec-unavailable" className="p-4 font-mono text-[10px] text-stone-400">No agent spec available</div>;
}
return <AgentSpecDisplay review={review as AgentSpecReview} yaml={review.raw} testIdPrefix="live-agent" />;
{!agentName ? (
<div data-testid="agent-spec-unavailable" className="p-4 font-mono text-[10px] text-stone-400">No agent spec available</div>
) : entriesLoading || reviewLoading ? (
<div className="p-4 font-mono text-[10px] text-stone-400">Loading agent spec...</div>
) : matches.length === 0 ? (
<div data-testid="agent-spec-unavailable" className="p-4 font-mono text-[10px] text-stone-400">No agent spec available</div>
) : matches.length > 1 ? (
<div data-testid="agent-spec-ambiguous" className="p-4 font-mono text-[10px] text-amber-600">
Agent spec ambiguous ({matches.length} matches for &quot;{agentName}&quot;)
</div>
) : !review || review.kind !== "agent" ? (
<div data-testid="agent-spec-unavailable" className="p-4 font-mono text-[10px] text-stone-400">No agent spec available</div>
) : (
<AgentSpecDisplay review={review as AgentSpecReview} yaml={review.raw} testIdPrefix="live-agent" />
)}
</div>
);
}
/** Action buttons — per founder direction, "action buttons any kind of
* action you would put like near the top". Cmux launch + copy tmux +
* copy resume. Migrated from retired NodeDetailPanel "Actions" section. */
function ActionButtonsRow({ rigId, logicalId, data }: { rigId: string; logicalId: string; data: NodeDetailData }) {
const handleCopyAttach = async () => {
if (data.tmuxAttachCommand) await copyText(data.tmuxAttachCommand);
@@ -131,7 +117,9 @@ function ActionButtonsRow({ rigId, logicalId, data }: { rigId: string; logicalId
`/api/rigs/${encodeURIComponent(rigId)}/nodes/${encodeURIComponent(logicalId)}/open-cmux`,
{ method: "POST" },
);
} catch { /* best-effort */ }
} catch {
// best effort
}
};
const handleCopyResume = async () => {
if (data.resumeCommand) await copyText(data.resumeCommand);
@@ -167,9 +155,6 @@ function ActionButtonsRow({ rigId, logicalId, data }: { rigId: string; logicalId
);
}
/** Status section — startup status pip + restore outcome + failure
* banner with recovery guidance. Merged from retired NodeDetailPanel
* Status section. Surfaces in the persistent header. */
function StatusSection({ data }: { data: NodeDetailData }) {
const showFailure =
data.startupStatus === "failed" ||
@@ -300,13 +285,10 @@ function LiveNodeCurrentState({ data }: { data: NodeDetailData }) {
);
}
/** Recent Events — preserved per founder direction "don't want to lose
* any content" (P5.1 dispatch ask #3). Compact list of last 10 events.
* Migrated from retired NodeDetailPanel. */
function RecentEventsSection({ data }: { data: NodeDetailData }) {
if (!data.recentEvents || data.recentEvents.length === 0) return null;
return (
<section data-testid="live-node-recent-events" className="border border-outline-variant bg-white/30 p-3">
<section data-testid="live-node-recent-events" className={SECTION_CLASS}>
<div className="font-mono text-[8px] uppercase tracking-wider text-stone-400 mb-2">
Recent Events
</div>
@@ -322,13 +304,271 @@ function RecentEventsSection({ data }: { data: NodeDetailData }) {
);
}
function IdentitySummary({ data }: { data: NodeDetailData }) {
return (
<section data-testid="live-identity-summary" className={SECTION_CLASS}>
<div className="mb-2 font-mono text-[8px] uppercase tracking-wider text-stone-400">Identity</div>
<div className="grid gap-1 sm:grid-cols-2">
<InfoRow label="Runtime" value={data.runtime} />
<InfoRow label="Model" value={data.model} />
<InfoRow label="Profile" value={data.profile} />
<InfoRow label="Spec" value={data.resolvedSpecName} />
<InfoRow label="Version" value={data.resolvedSpecVersion} />
<InfoRow label="CWD" value={data.cwd} />
</div>
</section>
);
}
function EdgesSection({ data }: { data: NodeDetailData }) {
const { outgoing, incoming } = data.edges;
if (outgoing.length === 0 && incoming.length === 0) return null;
return (
<section data-testid="detail-edges" className={SECTION_CLASS}>
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-2">Edges</div>
<div className="space-y-0.5 font-mono text-[10px]">
{outgoing.map((e, i) => (
<div key={`out-${i}`} className="flex gap-1">
<span className="text-stone-400">-&gt;</span>
<span className="text-stone-500">{e.kind}</span>
<span className="text-stone-900">{e.to?.logicalId ?? "?"}</span>
</div>
))}
{incoming.map((e, i) => (
<div key={`in-${i}`} className="flex gap-1">
<span className="text-stone-400">&lt;-</span>
<span className="text-stone-500">{e.kind}</span>
<span className="text-stone-900">{e.from?.logicalId ?? "?"}</span>
</div>
))}
</div>
</section>
);
}
function PeersSection({ data }: { data: NodeDetailData }) {
if (data.peers.length === 0) return null;
return (
<section data-testid="detail-peers" className={SECTION_CLASS}>
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-2">Peers</div>
<div className="space-y-1 font-mono text-[10px]">
{data.peers.map((p) => (
<div key={p.logicalId} className="space-y-0">
<div className="flex justify-between gap-3">
<span className="text-stone-900">{p.logicalId}</span>
<span className="text-stone-500">{p.runtime ?? "-"}</span>
</div>
{p.canonicalSessionName && (
<div className="text-[9px] text-stone-400 truncate">{p.canonicalSessionName}</div>
)}
</div>
))}
</div>
</section>
);
}
function ContextUsageSection({ data }: { data: NodeDetailData }) {
const contextUsage = data.contextUsage;
return (
<section data-testid="detail-context-usage" className={SECTION_CLASS}>
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-2">Context</div>
{contextUsage?.availability === "known" ? (
<div className="space-y-0.5 font-mono text-[10px]">
<InfoRow label="Used" value={contextUsage.usedPercentage != null ? `${contextUsage.usedPercentage}%` : null} />
<InfoRow label="Remaining" value={contextUsage.remainingPercentage != null ? `${contextUsage.remainingPercentage}%` : null} />
<InfoRow label="Window" value={contextUsage.contextWindowSize?.toLocaleString()} />
<InfoRow label="Input tokens" value={contextUsage.totalInputTokens?.toLocaleString()} />
<InfoRow label="Output tokens" value={contextUsage.totalOutputTokens?.toLocaleString()} />
<InfoRow label="Sampled" value={contextUsage.sampledAt} />
{contextUsage.fresh === false && (
<div className="font-mono text-[9px] text-amber-600 mt-1">Stale sample</div>
)}
</div>
) : (
<div className="font-mono text-[10px] text-stone-400">
unknown{contextUsage?.reason ? ` (${contextUsage.reason})` : ""}
</div>
)}
</section>
);
}
function IdentityTab({ data }: { data: NodeDetailData }) {
return (
<div data-testid="live-identity-section" className="space-y-4">
<IdentitySummary data={data} />
<LiveNodeCurrentState data={data} />
<RecentEventsSection data={data} />
<EdgesSection data={data} />
<PeersSection data={data} />
<ContextUsageSection data={data} />
</div>
);
}
function StartupTab({ rigId, logicalId, data }: { rigId: string; logicalId: string; data: NodeDetailData }) {
return (
<div data-testid="live-startup-section" className="space-y-4">
<StatusSection data={data} />
{data.canonicalSessionName && (
<section data-testid="live-node-preview" className={SECTION_CLASS}>
<div className="font-mono text-[8px] uppercase tracking-wider text-stone-400 mb-2">Preview</div>
<PreviewPane
rigId={rigId}
rigName={data.rigName}
logicalId={logicalId}
testIdPrefix="detail-preview"
/>
</section>
)}
{data.infrastructureStartupCommand && (
<section data-testid="live-node-infra-startup" className={SECTION_CLASS}>
<div className="font-mono text-[8px] uppercase tracking-wider text-stone-400 mb-2">
Startup Command
</div>
<code className="font-mono text-[9px] text-stone-700 bg-stone-100 px-2 py-1 block">
{data.infrastructureStartupCommand}
</code>
</section>
)}
{data.startupActions.length > 0 && (
<section data-testid="live-startup-actions" className={SECTION_CLASS}>
<div className="font-mono text-[8px] uppercase tracking-wider text-stone-400 mb-2">Startup Actions</div>
<div className="space-y-1">
{data.startupActions.map((action, index) => (
<div key={`${action.type}-${action.value}-${index}`} className="font-mono text-[10px] text-stone-700">
<span className="text-stone-500">{action.type}:</span> {action.value}
</div>
))}
</div>
</section>
)}
{data.startupFiles.length > 0 ? (
<section className="border border-outline-variant bg-white/30">
<div className="px-3 py-2 border-b border-outline-variant font-mono text-xs font-bold">
Startup Files
</div>
<ul className="divide-y divide-outline-variant">
{data.startupFiles.map((f, i) => (
<li
key={`${f.path}-${i}`}
data-testid={`live-startup-file-${f.path}`}
>
<FileReferenceTrigger
data={{ path: f.path }}
testId={`live-startup-file-trigger-${f.path}`}
className="block w-full px-3 py-2 text-left hover:bg-stone-100/60 transition-colors font-mono text-[10px]"
>
<span className="font-bold underline decoration-dotted decoration-stone-400">
{f.path}
</span>
<span className="text-stone-400 ml-2">({f.deliveryHint})</span>
{f.required && (
<span className="text-red-500 text-[8px] ml-1">REQUIRED</span>
)}
</FileReferenceTrigger>
</li>
))}
</ul>
</section>
) : (
<div className="font-mono text-[10px] text-stone-400 p-4">
No startup files declared
</div>
)}
</div>
);
}
function TranscriptTab({ data }: { data: NodeDetailData }) {
if (!data.transcript.enabled) {
return (
<div data-testid="live-transcript-section" className="font-mono text-[10px] text-stone-400 p-4">
Transcript capture not enabled
</div>
);
}
return (
<div data-testid="live-transcript-section" className="space-y-4">
<section data-testid="detail-transcript" className={SECTION_CLASS}>
<div className="font-mono text-xs font-bold mb-2">Transcript</div>
<div className="font-mono text-[10px] text-stone-700">{data.transcript.path ?? "enabled"}</div>
{data.transcript.tailCommand && (
<button
type="button"
onClick={() => copyText(data.transcript.tailCommand!)}
className="mt-2 w-full border border-stone-300 bg-white/40 px-2 py-1 text-left font-mono text-[8px] uppercase text-stone-700 hover:bg-stone-100"
>
Copy tail command
</button>
)}
</section>
</div>
);
}
function TerminalTab({ data }: { data: NodeDetailData }) {
return (
<div data-testid="live-terminal-section" className="space-y-4">
{data.canonicalSessionName ? (
<div data-testid="live-terminal-shell" className="bg-stone-950/65 p-2 text-stone-50 backdrop-blur-sm">
<SessionPreviewPane
sessionName={data.canonicalSessionName}
lines={80}
testIdPrefix="live-terminal-preview"
variant="compact-terminal"
/>
</div>
) : (
<div className="font-mono text-[10px] text-stone-400 p-4">
No canonical session name; terminal preview unavailable.
</div>
)}
</div>
);
}
function TabNav({
tabs,
activeTab,
onSelect,
}: {
tabs: Tab[];
activeTab: Tab;
onSelect: (tab: Tab) => void;
}) {
return (
<div className="flex gap-1 border-b border-outline-variant" role="tablist" data-testid="live-node-tabs">
{tabs.map((tab) => (
<button
key={tab}
role="tab"
aria-selected={activeTab === tab}
data-testid={`live-tab-${tab}`}
onClick={() => onSelect(tab)}
className={`px-3 py-1.5 font-mono text-[10px] uppercase tracking-wider transition-colors ${
activeTab === tab
? "border-b-2 border-stone-900 text-stone-900 font-bold -mb-px"
: "text-stone-500 hover:text-stone-700"
}`}
>
{tab.replace("-", " ")}
</button>
))}
</div>
);
}
export function LiveNodeDetails({ rigId, logicalId }: LiveNodeDetailsProps) {
const { data, isLoading, error } = useNodeDetail(rigId, logicalId);
const [activeTab, setActiveTab] = useState<Tab>("identity");
const isAgent = data ? data.nodeKind !== "infrastructure" : true;
// Single canonical 5-tab body row per DRIFT P5.1-D1. Terminal folds in
// from the prior outer SeatScopePage tab; Transcript surfaces only
// here (eliminates the "transcript twice" duplication).
const tabs: Tab[] = isAgent
? ["identity", "agent-spec", "startup", "transcript", "terminal"]
: ["identity", "startup", "transcript", "terminal"];
@@ -336,8 +576,6 @@ export function LiveNodeDetails({ rigId, logicalId }: LiveNodeDetailsProps) {
return (
<WorkspacePage>
<div data-testid="live-node-details" className="space-y-4">
{/* Persistent header — TOP. Action buttons → status → preview →
current state → recent events → infra (conditional). */}
<WorkflowHeader
eyebrow="Live Node Details"
title={data?.canonicalSessionName ?? logicalId}
@@ -351,143 +589,18 @@ export function LiveNodeDetails({ rigId, logicalId }: LiveNodeDetailsProps) {
</div>
)}
{data && <ActionButtonsRow rigId={rigId} logicalId={logicalId} data={data} />}
{data && <StatusSection data={data} />}
{data?.canonicalSessionName && (
<section data-testid="live-node-preview" className="border border-outline-variant bg-white/30 p-3">
<div className="font-mono text-[8px] uppercase tracking-wider text-stone-400 mb-2">Preview</div>
<PreviewPane
rigId={rigId}
rigName={data.rigName}
logicalId={logicalId}
testIdPrefix="detail-preview"
/>
</section>
)}
{data && <LiveNodeCurrentState data={data} />}
{data && <RecentEventsSection data={data} />}
{data?.nodeKind === "infrastructure" && data.infrastructureStartupCommand && (
<section data-testid="live-node-infra-startup" className="border border-outline-variant bg-white/30 p-3">
<div className="font-mono text-[8px] uppercase tracking-wider text-stone-400 mb-2">
Startup Command
{data && (
<>
<ActionButtonsRow rigId={rigId} logicalId={logicalId} data={data} />
<TabNav tabs={tabs} activeTab={activeTab} onSelect={setActiveTab} />
<div data-testid="live-node-tab-body" className="space-y-4">
{activeTab === "identity" && <IdentityTab data={data} />}
{activeTab === "agent-spec" && isAgent && <AgentSpecSection data={data} />}
{activeTab === "startup" && <StartupTab rigId={rigId} logicalId={logicalId} data={data} />}
{activeTab === "transcript" && <TranscriptTab data={data} />}
{activeTab === "terminal" && <TerminalTab data={data} />}
</div>
<code className="font-mono text-[9px] text-stone-700 bg-stone-100 px-2 py-1 block">
{data.infrastructureStartupCommand}
</code>
</section>
)}
{/* Single tabs row at BOTTOM per founder direction "probably makes
sense to put them down below". 5 tabs canonical body. */}
<div className="flex gap-1 border-b border-outline-variant" role="tablist" data-testid="live-node-tabs">
{tabs.map((tab) => (
<button
key={tab}
role="tab"
aria-selected={activeTab === tab}
data-testid={`live-tab-${tab}`}
onClick={() => setActiveTab(tab)}
className={`px-3 py-1.5 font-mono text-[10px] uppercase tracking-wider transition-colors ${
activeTab === tab
? "border-b-2 border-stone-900 text-stone-900 font-bold -mb-px"
: "text-stone-500 hover:text-stone-700"
}`}
>
{tab.replace("-", " ")}
</button>
))}
</div>
{/* Tab content */}
{data && activeTab === "identity" && (
<LiveIdentityDisplay
peers={data.peers}
edges={data.edges}
transcript={data.transcript}
compactSpec={data.compactSpec}
contextUsage={data.contextUsage}
/>
)}
{data && activeTab === "agent-spec" && isAgent && (
<AgentSpecSection agentRef={data.agentRef} />
)}
{data && activeTab === "startup" && (
<div data-testid="live-startup-section" className="space-y-4">
{data.startupFiles.length > 0 ? (
<div className="border border-outline-variant bg-white/30">
<div className="px-3 py-2 border-b border-outline-variant font-mono text-xs font-bold">
Startup Files
</div>
<ul className="divide-y divide-outline-variant">
{data.startupFiles.map((f, i) => (
<li
key={`${f.path}-${i}`}
data-testid={`live-startup-file-${f.path}`}
>
{/* P5.1-1a: each startup file wrapped in
FileReferenceTrigger per content-drawer.md L26 —
click opens FileViewer in drawer. */}
<FileReferenceTrigger
data={{ path: f.path }}
testId={`live-startup-file-trigger-${f.path}`}
className="block w-full px-3 py-2 text-left hover:bg-stone-100/60 transition-colors font-mono text-[10px]"
>
<span className="font-bold underline decoration-dotted decoration-stone-400">
{f.path}
</span>
<span className="text-stone-400 ml-2">({f.deliveryHint})</span>
{f.required && (
<span className="text-red-500 text-[8px] ml-1">REQUIRED</span>
)}
</FileReferenceTrigger>
</li>
))}
</ul>
</div>
) : (
<div className="font-mono text-[10px] text-stone-400 p-4">
No startup files declared
</div>
)}
</div>
)}
{data && activeTab === "transcript" && (
<div data-testid="live-transcript-section" className="space-y-4">
{data.transcript.enabled ? (
<div className="border border-outline-variant bg-white/30 p-3">
<div className="font-mono text-xs font-bold mb-2">Transcript</div>
<div className="font-mono text-[10px] text-stone-700">{data.transcript.path}</div>
{data.transcript.tailCommand && (
<code className="block mt-1 font-mono text-[9px] text-stone-500 bg-stone-100 px-2 py-1">
{data.transcript.tailCommand}
</code>
)}
</div>
) : (
<div className="font-mono text-[10px] text-stone-400 p-4">
Transcript capture not enabled
</div>
)}
</div>
)}
{data && activeTab === "terminal" && (
<div data-testid="live-terminal-section" className="space-y-4">
{data.canonicalSessionName ? (
<SessionPreviewPane
sessionName={data.canonicalSessionName}
lines={50}
testIdPrefix="live-terminal-preview"
/>
) : (
<div className="font-mono text-[10px] text-stone-400 p-4">
No canonical session name; terminal preview unavailable.
</div>
)}
</div>
</>
)}
</div>
</WorkspacePage>
+154 -38
View File
@@ -5,7 +5,7 @@
// slice = +story +tests = 7 tabs
//
// V1 attempt-3 Phase 5 P5-2: SliceScopePage tab content piping.
// Per code-map AFTER tree fold mapping (founder-approved at Phase 5 dispatch):
// Per code-map AFTER tree fold mapping from Phase 5 dispatch:
// - StoryTab → story tab (preserved; events + phaseDefinitions props)
// - TestsVerificationTab → tests tab (preserved; tests prop)
// - TopologyTab → topology tab (preserved; topology prop)
@@ -23,7 +23,7 @@ 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 { useSliceDetail, type SliceDetail } 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";
@@ -230,52 +230,172 @@ function SliceQueueTab({ qitemIds }: { qitemIds: string[] }) {
);
}
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.
function formatMaybeDate(ts: string | null): string {
if (!ts) return "unknown";
const date = new Date(ts);
if (Number.isNaN(date.getTime())) return ts;
return date.toLocaleString();
}
function SliceMetric({ label, value }: { label: string; value: string | number }) {
return (
<div className="border border-outline-variant bg-white/30 p-3">
<div className="font-mono text-[8px] uppercase tracking-[0.14em] text-on-surface-variant">{label}</div>
<div className="mt-1 font-mono text-sm font-bold text-stone-900">{value}</div>
</div>
);
}
function SliceArtifactsTab({ detail }: { detail: SliceDetail }) {
const docsTree = detail.docs.tree;
const proofPackets = detail.tests.proofPackets;
return (
<div data-testid="slice-artifacts-tab" className="space-y-6">
<section data-testid="slice-artifacts-files" className="border border-outline-variant bg-white/20 p-4">
<SectionHeader tone="muted">Files</SectionHeader>
{docsTree.length > 0 ? (
<ul className="mt-3 divide-y divide-outline-variant border border-outline-variant bg-white/30">
{docsTree.map((entry) => (
<li key={entry.relPath} className="grid grid-cols-[1fr_auto_auto] gap-3 px-3 py-2 font-mono text-[10px]">
<span className="truncate text-stone-900">{entry.relPath}</span>
<span className="uppercase tracking-[0.10em] text-stone-500">{entry.type}</span>
<span className="text-stone-400">{entry.size == null ? "-" : `${entry.size}b`}</span>
</li>
))}
</ul>
) : (
<div className="mt-3 font-mono text-[10px] text-stone-400">No slice files indexed.</div>
)}
</section>
<section data-testid="slice-artifacts-commits" className="border border-outline-variant bg-white/20 p-4">
<SectionHeader tone="muted">Commits</SectionHeader>
{detail.commitRefs.length > 0 ? (
<ul className="mt-3 divide-y divide-outline-variant border border-outline-variant bg-white/30">
{detail.commitRefs.map((commitRef) => (
<li key={commitRef} className="px-3 py-2 font-mono text-[10px] text-stone-900">
{commitRef}
</li>
))}
</ul>
) : (
<div className="mt-3 font-mono text-[10px] text-stone-400">No commit refs indexed for this slice.</div>
)}
</section>
<section data-testid="slice-artifacts-proof" className="border border-outline-variant bg-white/20 p-4">
<SectionHeader tone="muted">Proof Packets</SectionHeader>
{proofPackets.length > 0 ? (
<ul className="mt-3 divide-y divide-outline-variant border border-outline-variant bg-white/30">
{proofPackets.map((packet) => (
<li key={packet.dirName} className="px-3 py-2 font-mono text-[10px] text-stone-700">
<div className="font-bold text-stone-900">{packet.dirName}</div>
<div className="mt-1 uppercase tracking-[0.10em] text-stone-500">{packet.passFailBadge}</div>
<div className="mt-1 text-stone-400">
{packet.screenshots.length} screenshots / {packet.videos.length} videos / {packet.traces.length} traces
</div>
</li>
))}
</ul>
) : (
<div className="mt-3 font-mono text-[10px] text-stone-400">No proof packets indexed.</div>
)}
</section>
<section data-testid="slice-artifacts-docs">
<SectionHeader tone="muted">Docs</SectionHeader>
<SectionHeader tone="muted">Docs Browser</SectionHeader>
<div className="mt-2 border border-outline-variant">
<DocsTab sliceName={sliceName} tree={docsTree} />
<DocsTab sliceName={detail.name} 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} />
<DecisionsTab rows={detail.decisions.rows} />
</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.
function SliceOverviewTab({ detail }: { detail: SliceDetail }) {
const currentStep = detail.acceptance.currentStep;
const primaryDocs = detail.docs.tree.filter((entry) =>
entry.type === "file" && /(^|\/)(README|IMPLEMENTATION-PRD|PROGRESS)\.md$/i.test(entry.relPath),
);
return (
<div data-testid="slice-overview-tab" className="border border-outline-variant">
<DocsTab sliceName={sliceName} tree={docsTree} />
<div data-testid="slice-overview-tab" className="space-y-6">
<section data-testid="slice-overview-summary" className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<SliceMetric label="Status" value={detail.status} />
<SliceMetric label="Progress" value={`${detail.acceptance.percentage}%`} />
<SliceMetric label="Qitems" value={detail.qitemIds.length} />
<SliceMetric label="Last Activity" value={formatMaybeDate(detail.lastActivityAt)} />
</section>
<section data-testid="slice-overview-current-step" className="border border-outline-variant bg-white/20 p-4">
<SectionHeader tone="muted">Current Step</SectionHeader>
{currentStep ? (
<div className="mt-3 grid gap-2 font-mono text-[10px] text-stone-700 sm:grid-cols-2">
<div>
<div className="text-[8px] uppercase tracking-[0.14em] text-stone-400">Step</div>
<div className="font-bold text-stone-900">{currentStep.stepId}</div>
</div>
<div>
<div className="text-[8px] uppercase tracking-[0.14em] text-stone-400">Role</div>
<div className="font-bold text-stone-900">{currentStep.role}</div>
</div>
<div className="sm:col-span-2">
<div className="text-[8px] uppercase tracking-[0.14em] text-stone-400">Objective</div>
<div>{currentStep.objective ?? "No objective declared."}</div>
</div>
<div>
<div className="text-[8px] uppercase tracking-[0.14em] text-stone-400">Allowed exits</div>
<div>{currentStep.allowedExits.join(", ") || "-"}</div>
</div>
<div>
<div className="text-[8px] uppercase tracking-[0.14em] text-stone-400">Hop count</div>
<div>{currentStep.hopCount}</div>
</div>
</div>
) : (
<div className="mt-3 font-mono text-[10px] text-stone-400">No workflow step is currently bound.</div>
)}
</section>
<section data-testid="slice-overview-readiness" className="border border-outline-variant bg-white/20 p-4">
<SectionHeader tone="muted">Readiness</SectionHeader>
<div className="mt-3 space-y-2 font-mono text-[10px] text-stone-700">
<div>{detail.acceptance.doneItems} of {detail.acceptance.totalItems} acceptance items complete.</div>
<div>{detail.tests.aggregate.passCount} proof packets passing / {detail.tests.aggregate.failCount} failing.</div>
{detail.acceptance.closureCallout && (
<div className="border border-amber-300 bg-amber-50 px-2 py-1 text-amber-800">
{detail.acceptance.closureCallout}
</div>
)}
{detail.workflowBinding && (
<div className="text-stone-500">
Workflow: {detail.workflowBinding.workflowName} v{detail.workflowBinding.workflowVersion}
</div>
)}
</div>
</section>
<section data-testid="slice-overview-docs" className="border border-outline-variant bg-white/20 p-4">
<SectionHeader tone="muted">Primary Docs</SectionHeader>
{primaryDocs.length > 0 ? (
<ul className="mt-3 divide-y divide-outline-variant border border-outline-variant bg-white/30">
{primaryDocs.map((entry) => (
<li key={entry.relPath} className="px-3 py-2 font-mono text-[10px] text-stone-900">
{entry.relPath}
</li>
))}
</ul>
) : (
<div className="mt-3 font-mono text-[10px] text-stone-400">No README, implementation PRD, or progress file indexed.</div>
)}
</section>
</div>
);
}
@@ -341,7 +461,7 @@ export function SliceScopePage() {
<StoryTab events={detail.story.events} phaseDefinitions={detail.story.phaseDefinitions} />
) : null}
{active === "overview" ? (
<SliceOverviewTab sliceName={detail.name} docsTree={detail.docs.tree} />
<SliceOverviewTab detail={detail} />
) : null}
{active === "progress" ? (
// FOLDED: AcceptanceTab content. Acceptance is the canonical "progress"
@@ -350,11 +470,7 @@ export function SliceScopePage() {
<AcceptanceTab acceptance={detail.acceptance} />
) : null}
{active === "artifacts" ? (
<SliceArtifactsTab
sliceName={detail.name}
docsTree={detail.docs.tree}
decisionsRows={detail.decisions.rows}
/>
<SliceArtifactsTab detail={detail} />
) : null}
{active === "tests" ? (
<TestsVerificationTab sliceName={detail.name} tests={detail.tests} />
@@ -0,0 +1,196 @@
import { useMemo } from "react";
import dagre from "dagre";
import {
Background,
Controls,
Handle,
MarkerType,
Position,
ReactFlow,
type Edge,
type Node,
type NodeProps,
type NodeTypes,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import type { SpecGraphPayload, SpecGraphNode } from "../../../hooks/useSlices.js";
import { RegistrationMarks } from "../../ui/registration-marks.js";
const NODE_WIDTH = 200;
const NODE_HEIGHT = 112;
interface SliceWorkflowStepData extends Record<string, unknown> {
step: SpecGraphNode;
}
type SliceWorkflowNode = Node<SliceWorkflowStepData, "sliceWorkflowStep">;
const nodeTypes: NodeTypes = {
sliceWorkflowStep: SliceWorkflowStepNode,
};
export function SliceWorkflowGraph({ specGraph }: { specGraph: SpecGraphPayload }) {
const { nodes, edges } = useMemo(() => buildSliceWorkflowGraph(specGraph), [specGraph]);
return (
<section
data-testid="topology-spec-graph"
data-spec-name={specGraph.specName}
data-spec-version={specGraph.specVersion}
data-layout="react-flow-dagre"
className="border border-outline-variant bg-white/20"
>
<header className="flex items-center justify-between border-b border-outline-variant bg-white/20 px-3 py-2">
<div className="font-mono text-[10px] uppercase tracking-[0.10em] text-stone-500">
Workflow graph - {specGraph.specName} v{specGraph.specVersion}
</div>
<div className="font-mono text-[9px] uppercase tracking-[0.10em] text-stone-400">
{specGraph.nodes.length} steps / {specGraph.edges.length} edges
</div>
</header>
<div data-testid="slice-workflow-graph" className="h-[420px] bg-[radial-gradient(circle_at_1px_1px,rgba(87,83,78,0.22)_1px,transparent_0)] [background-size:18px_18px]">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
fitView
minZoom={0.2}
maxZoom={1.5}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
proOptions={{ hideAttribution: true }}
>
<Background gap={18} size={0.5} color="#d6d3cd" />
<Controls showInteractive={false} />
</ReactFlow>
</div>
<div className="sr-only" data-testid="slice-workflow-edge-metadata">
{specGraph.edges.map((edge) => (
<span
key={`${edge.fromStepId}-${edge.toStepId}`}
data-testid={`spec-edge-${edge.fromStepId}-${edge.toStepId}`}
data-routing-type={edge.routingType}
data-is-loop-back={edge.isLoopBack}
>
{edge.fromStepId} to {edge.toStepId}
</span>
))}
</div>
</section>
);
}
export function buildSliceWorkflowGraph(specGraph: SpecGraphPayload): { nodes: SliceWorkflowNode[]; edges: Edge[] } {
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
dagreGraph.setGraph({
rankdir: "LR",
nodesep: 80,
ranksep: 110,
marginx: 24,
marginy: 24,
});
specGraph.nodes.forEach((node) => {
dagreGraph.setNode(node.stepId, { width: NODE_WIDTH, height: NODE_HEIGHT });
});
specGraph.edges.forEach((edge) => {
dagreGraph.setEdge(edge.fromStepId, edge.toStepId);
});
dagre.layout(dagreGraph);
const nodes: SliceWorkflowNode[] = specGraph.nodes.map((step) => {
const pos = dagreGraph.node(step.stepId) as { x?: number; y?: number } | undefined;
return {
id: step.stepId,
type: "sliceWorkflowStep",
position: {
x: (pos?.x ?? 0) - NODE_WIDTH / 2,
y: (pos?.y ?? 0) - NODE_HEIGHT / 2,
},
data: { step },
sourcePosition: Position.Right,
targetPosition: Position.Left,
width: NODE_WIDTH,
height: NODE_HEIGHT,
};
});
const edges: Edge[] = specGraph.edges.map((edge) => ({
id: `spec-edge-${edge.fromStepId}-${edge.toStepId}`,
source: edge.fromStepId,
target: edge.toStepId,
type: "smoothstep",
markerEnd: { type: MarkerType.ArrowClosed, color: edge.isLoopBack ? "#b45309" : "#57534e" },
style: {
stroke: edge.isLoopBack ? "#b45309" : "#57534e",
strokeWidth: edge.isLoopBack ? 1.5 : 1.25,
strokeDasharray: edge.isLoopBack ? "6 5" : undefined,
},
label: edge.isLoopBack ? "loop" : edge.routingType,
labelStyle: {
fill: edge.isLoopBack ? "#b45309" : "#78716c",
fontSize: 9,
fontFamily: "monospace",
textTransform: "uppercase",
},
}));
return { nodes, edges };
}
function SliceWorkflowStepNode({ data }: NodeProps<SliceWorkflowNode>) {
const step = data.step;
return (
<div
data-testid={`spec-node-${step.stepId}`}
data-is-current={step.isCurrent}
data-is-entry={step.isEntry}
data-is-terminal={step.isTerminal}
className={`relative h-[112px] w-[200px] border border-outline-variant bg-white/30 p-3 font-mono hard-shadow ${
step.isCurrent ? "ring-2 ring-emerald-500/50 bg-emerald-50/60" : ""
}`}
>
<RegistrationMarks testIdPrefix={`slice-workflow-${step.stepId}`} />
<Handle type="target" position={Position.Left} className="!h-2 !w-2 !border-outline-variant !bg-stone-500" />
<Handle type="source" position={Position.Right} className="!h-2 !w-2 !border-outline-variant !bg-stone-500" />
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-[11px] font-bold uppercase tracking-[0.04em] text-stone-950">{step.stepId}</div>
<div className="mt-0.5 text-[9px] uppercase tracking-[0.10em] text-stone-500">{step.role}</div>
</div>
<div className="flex flex-col items-end gap-1">
{step.isEntry && (
<span
data-testid={`spec-node-${step.stepId}-entry-badge`}
className="border border-blue-300 bg-blue-50 px-1 text-[8px] uppercase tracking-[0.10em] text-blue-900"
>
entry
</span>
)}
{step.isCurrent && (
<span
data-testid={`spec-node-${step.stepId}-current-badge`}
className="border border-emerald-400 bg-emerald-100 px-1 text-[8px] uppercase tracking-[0.10em] text-emerald-900"
>
current
</span>
)}
{step.isTerminal && (
<span
data-testid={`spec-node-${step.stepId}-terminal-badge`}
className="border border-stone-300 bg-stone-100 px-1 text-[8px] uppercase tracking-[0.10em] text-stone-700"
>
terminal
</span>
)}
</div>
</div>
<div className="mt-3 text-[10px] leading-4 text-stone-800">{step.label}</div>
{step.preferredTarget && (
<div className="mt-2 truncate text-[9px] text-stone-500">{step.preferredTarget}</div>
)}
</div>
);
}
@@ -1,30 +1,12 @@
// Slice Story View v0 + v1 — Story tab.
// Slice Story View v1.
//
// Chronological narrative across the slice's lifecycle. Each row = one
// event with timestamp, actor session, optional spec-driven phase tag,
// summary, and a click-to-expand JSON detail view.
//
// v1 dimension #2: when the slice is bound to a workflow_instance,
// events group under spec-declared phase headings (the spec's step
// labels). When unbound (or for events that don't trace back through
// a workflow_step_trail — doc edits, proof packets), events render in
// a single chronological flow without phase grouping.
//
// v0's hardcoded RSI-v2 phase taxonomy ("discovery"/"product-lab"/...)
// is GONE — the only phase tags that show are the ones the spec
// declares. A spec for code review work would phase
// Spec → Implementation → Code Review → Deploy → Monitor; a deployment
// spec would phase Build → Test → Stage → Prod. Same Story tab.
// Shows the slice lifecycle as a newest-first connected step tree. Each step
// keeps the spec-defined phase chip and expandable detail from the prior flat
// timeline, while adding visible flow cues for event-to-event handoff reading.
import { useMemo, useState } from "react";
import type { PhaseDefinition, StoryEvent } from "../../../hooks/useSlices.js";
// Stable color palette assigned by phase index — first phase declared
// in the spec gets emerald, second amber, etc. Order is the spec's
// declared step order so RSI v2 sees its own consistent coloring.
// Cycles after eight phases (more than any v0/v1 spec is expected to
// declare); operator-authored specs with 9+ phases would see colors
// repeat — acceptable v1 fallback.
const PHASE_PALETTE: string[] = [
"bg-amber-100 text-amber-800 border-amber-300",
"bg-violet-100 text-violet-800 border-violet-300",
@@ -44,9 +26,6 @@ export function StoryTab({
events: StoryEvent[];
phaseDefinitions: PhaseDefinition[] | null;
}) {
// Build phase id → display label + color class map from the bound
// spec's declared phases. Unbound (or untagged events) use the
// stone palette + the literal phase id (when present) as the label.
const phaseMeta = useMemo(() => {
const map = new Map<string, { label: string; colorClass: string }>();
if (phaseDefinitions) {
@@ -60,42 +39,86 @@ export function StoryTab({
return map;
}, [phaseDefinitions]);
const sortedEvents = useMemo(
() => [...events].sort((a, b) => timestampSortValue(b.ts) - timestampSortValue(a.ts)),
[events],
);
if (events.length === 0) {
return <div className="p-4 font-mono text-[10px] text-stone-400" data-testid="story-empty">No events captured for this slice yet.</div>;
}
return (
<div data-testid="story-tab" className="divide-y divide-stone-100">
{events.map((event, idx) => (
<StoryRow
key={`${event.ts}-${event.kind}-${idx}`}
event={event}
phaseMeta={phaseMeta}
/>
))}
<div data-testid="story-tab" className="space-y-0">
<div data-testid="story-step-tree" data-order="newest-first" className="relative border border-outline-variant bg-white/20 p-3">
{sortedEvents.map((event, idx) => (
<StoryStepCard
key={`${event.ts}-${event.kind}-${idx}`}
event={event}
phaseMeta={phaseMeta}
isLast={idx === sortedEvents.length - 1}
/>
))}
</div>
</div>
);
}
function StoryRow({
function timestampSortValue(ts: string): number {
const value = Date.parse(ts);
return Number.isFinite(value) ? value : 0;
}
function detailString(detail: Record<string, unknown> | null, keys: string[]): string | null {
if (!detail) return null;
for (const key of keys) {
const value = detail[key];
if (typeof value === "string" && value.length > 0) return value;
}
return null;
}
function flowLabel(event: StoryEvent): { source: string | null; target: string | null } {
const source =
detailString(event.detail, ["fromSession", "sourceSession", "source", "actorSession"]) ??
event.actorSession;
const target = detailString(event.detail, ["toSession", "destinationSession", "targetSession", "target"]);
return { source, target };
}
function StoryStepCard({
event,
phaseMeta,
isLast,
}: {
event: StoryEvent;
phaseMeta: Map<string, { label: string; colorClass: string }>;
isLast: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const tsShort = event.ts.length > 19 ? event.ts.slice(0, 19) : event.ts;
const meta = event.phase ? phaseMeta.get(event.phase) : undefined;
const phaseLabel = meta?.label ?? event.phase ?? "untagged";
const phaseClass = meta?.colorClass ?? UNTAGGED_CLASS;
const flow = flowLabel(event);
return (
<div className="px-4 py-2 hover:bg-stone-50">
<div className="relative pl-9 pb-3 last:pb-0">
{!isLast && (
<div
data-testid={`story-step-connector-${event.kind}`}
className="absolute left-[14px] top-7 bottom-0 w-px bg-outline-variant"
/>
)}
<div
data-testid={`story-step-dot-${event.kind}`}
className="absolute left-[8px] top-4 h-3 w-3 border border-outline-variant bg-white shadow-[1px_1px_0_rgba(46,52,46,0.12)]"
/>
<button
type="button"
data-testid={`story-row-${event.kind}`}
onClick={() => setExpanded((v) => !v)}
className="w-full text-left"
className="w-full border border-outline-variant bg-white/30 px-3 py-2 text-left hard-shadow hover:bg-white/40"
>
<div className="flex items-center gap-2">
<span className="font-mono text-[9px] text-stone-500 shrink-0">{tsShort}</span>
@@ -107,14 +130,23 @@ function StoryRow({
{phaseLabel}
</span>
<span className="font-mono text-[10px] text-stone-700 truncate">{event.kind}</span>
{event.actorSession && (
<span className="font-mono text-[9px] text-stone-400 shrink-0">{event.actorSession}</span>
{event.qitemId && (
<span className="ml-auto shrink-0 font-mono text-[8px] uppercase tracking-[0.10em] text-stone-400">
{event.qitemId}
</span>
)}
</div>
<div className="ml-[88px] font-mono text-[10px] text-stone-800">{event.summary}</div>
<div className="mt-1 font-mono text-[10px] text-stone-900">{event.summary}</div>
{(flow.source || flow.target) && (
<div data-testid={`story-step-flow-${event.kind}`} className="mt-1 flex items-center gap-2 font-mono text-[9px] text-stone-500">
<span className="truncate">{flow.source ?? "unknown source"}</span>
<span className="text-stone-400">-&gt;</span>
<span className="truncate">{flow.target ?? "unresolved target"}</span>
</div>
)}
</button>
{expanded && event.detail && (
<pre data-testid={`story-row-detail-${event.kind}`} className="ml-[88px] mt-1 overflow-x-auto bg-stone-50 p-2 font-mono text-[9px] text-stone-700">
<pre data-testid={`story-row-detail-${event.kind}`} className="mt-1 overflow-x-auto bg-stone-50 p-2 font-mono text-[9px] text-stone-700">
{JSON.stringify(event.detail, null, 2)}
</pre>
)}
@@ -1,16 +1,15 @@
// Slice Story View v0 + v1 — Topology tab.
//
// v0: per-rig session-name list grouped by rig with click-through to
// the main topology surface (per PRD: "this tab is read-only; clicking
// a node from here takes the operator to the regular topology /
// node-drawer surface").
// the main topology surface. The tab remains a read-current-state view
// and links out to the canonical topology surface for deeper action.
//
// v1 dimension #1: when a workflow_instance is bound to the slice,
// renders the spec graph (one node per spec step; edges from each
// step's next_hop.suggested_roles). Composes with the v0 per-rig
// listing — both views render together. The current step is
// highlighted, the entry step is marked, terminal steps are marked,
// and loop-back edges are styled differently from forward edges.
// listing. The current step is highlighted, the entry step is marked,
// terminal steps are marked, and loop-back edges are styled differently
// from forward edges.
//
// v1 dimension #4: edges carry a routingType field. Phase D's spec
// format does NOT yet have a routing_type metadata field (per audit-
@@ -21,8 +20,9 @@
import { Link } from "@tanstack/react-router";
import { useState } from "react";
import type { SliceDetail, SpecGraphPayload } from "../../../hooks/useSlices.js";
import type { SliceDetail } from "../../../hooks/useSlices.js";
import { SessionPreviewPane } from "../../preview/SessionPreviewPane.js";
import { SliceWorkflowGraph } from "./SliceWorkflowGraph.js";
export function TopologyTab({ topology }: { topology: SliceDetail["topology"] }) {
const { affectedRigs, totalSeats, specGraph } = topology;
@@ -53,7 +53,7 @@ export function TopologyTab({ topology }: { topology: SliceDetail["topology"] })
</div>
</header>
{specGraph && <SpecGraphPanel specGraph={specGraph} />}
{specGraph && <SliceWorkflowGraph specGraph={specGraph} />}
{affectedRigs.length > 0 && (
<div data-testid="topology-rig-listing" className="space-y-3">
@@ -121,112 +121,3 @@ function SeatRow({ session }: { session: string }) {
</li>
);
}
// v1 dimension #1: spec graph panel — nodes (one per step) + edges
// (derived from each step's next_hop.suggested_roles). v1 ships a
// list-based + adjacency view (not a force-laid-out canvas) — same
// "see the spec shape" intent at a fraction of the React Flow
// integration cost; v2+ can swap in a rendered subgraph.
function SpecGraphPanel({ specGraph }: { specGraph: SpecGraphPayload }) {
return (
<section
data-testid="topology-spec-graph"
data-spec-name={specGraph.specName}
data-spec-version={specGraph.specVersion}
className="border border-stone-300 bg-white"
>
<header className="border-b border-stone-200 bg-stone-50 px-3 py-2">
<div className="font-mono text-[10px] uppercase tracking-[0.10em] text-stone-500">
Spec graph · {specGraph.specName} v{specGraph.specVersion}
</div>
</header>
<div className="grid grid-cols-1 divide-y divide-stone-100">
{specGraph.nodes.map((node) => {
const outgoingEdges = specGraph.edges.filter((e) => e.fromStepId === node.stepId);
return (
<div
key={node.stepId}
data-testid={`spec-node-${node.stepId}`}
data-is-current={node.isCurrent}
data-is-entry={node.isEntry}
data-is-terminal={node.isTerminal}
className={`px-3 py-2 ${node.isCurrent ? "bg-emerald-50" : ""}`}
>
<div className="flex items-center gap-2">
<span
className={`font-mono text-[11px] font-bold ${
node.isCurrent ? "text-emerald-900" : "text-stone-900"
}`}
>
{node.stepId}
</span>
<span className="font-mono text-[9px] uppercase tracking-[0.10em] text-stone-500">
role: {node.role}
</span>
{node.isEntry && (
<span
data-testid={`spec-node-${node.stepId}-entry-badge`}
className="border border-blue-300 bg-blue-50 px-1 font-mono text-[8px] uppercase tracking-[0.10em] text-blue-900"
>
entry
</span>
)}
{node.isCurrent && (
<span
data-testid={`spec-node-${node.stepId}-current-badge`}
className="border border-emerald-400 bg-emerald-100 px-1 font-mono text-[8px] uppercase tracking-[0.10em] text-emerald-900"
>
current
</span>
)}
{node.isTerminal && (
<span
data-testid={`spec-node-${node.stepId}-terminal-badge`}
className="border border-stone-300 bg-stone-100 px-1 font-mono text-[8px] uppercase tracking-[0.10em] text-stone-700"
>
terminal
</span>
)}
{node.preferredTarget && (
<span className="ml-auto font-mono text-[9px] text-stone-400">
→ {node.preferredTarget}
</span>
)}
</div>
{outgoingEdges.length > 0 && (
<ul className="mt-1 ml-4">
{outgoingEdges.map((edge) => (
<li
key={`${edge.fromStepId}-${edge.toStepId}`}
data-testid={`spec-edge-${edge.fromStepId}-${edge.toStepId}`}
data-routing-type={edge.routingType}
data-is-loop-back={edge.isLoopBack}
className="flex items-center gap-2"
>
<span
aria-label={edge.isLoopBack ? "loop-back edge" : "forward edge"}
className={`font-mono text-[10px] ${
edge.isLoopBack ? "text-amber-700" : "text-stone-500"
}`}
>
{edge.isLoopBack ? "↺" : "→"}
</span>
<span className="font-mono text-[10px] text-stone-700">
{edge.toStepId}
</span>
{edge.isLoopBack && (
<span className="font-mono text-[8px] uppercase tracking-[0.10em] text-amber-700">
loop
</span>
)}
</li>
))}
</ul>
)}
</div>
);
})}
</div>
</section>
);
}
+26 -18
View File
@@ -88,17 +88,17 @@ describe("LiveNodeDetails", () => {
expect(screen.getByText("Live Node Details")).toBeDefined();
expect(screen.getByTestId("detail-edges")).toBeDefined();
expect(screen.getByTestId("live-node-actions").nextElementSibling?.getAttribute("data-testid")).toBe("live-node-tabs");
expect(screen.queryByTestId("live-node-status")).toBeNull();
expect(screen.queryByTestId("detail-transcript")).toBeNull();
expect(screen.queryByTestId("detail-compact-spec")).toBeNull();
});
it("agent spec tab shows unavailable when agentRef is null", async () => {
mockNodeDetail({ ...NODE_DETAIL, agentRef: null });
renderDetails();
await waitFor(() => {
expect(screen.getByTestId("live-node-details")).toBeDefined();
});
fireEvent.click(screen.getByTestId("live-tab-agent-spec"));
fireEvent.click(await screen.findByTestId("live-tab-agent-spec"));
await waitFor(() => {
expect(screen.getByTestId("agent-spec-unavailable")).toBeDefined();
@@ -109,11 +109,7 @@ describe("LiveNodeDetails", () => {
mockNodeDetail({ ...NODE_DETAIL, agentRef: "remote:agents/impl" });
renderDetails();
await waitFor(() => {
expect(screen.getByTestId("live-node-details")).toBeDefined();
});
fireEvent.click(screen.getByTestId("live-tab-agent-spec"));
fireEvent.click(await screen.findByTestId("live-tab-agent-spec"));
await waitFor(() => {
expect(screen.getByTestId("agent-spec-unavailable")).toBeDefined();
@@ -125,10 +121,9 @@ describe("LiveNodeDetails", () => {
renderDetails("infra.server");
// Wait for data to load and re-render without agent-spec tab
await waitFor(() => {
expect(screen.queryByTestId("live-tab-agent-spec")).toBeNull();
});
await screen.findByTestId("live-tab-identity");
expect(screen.queryByTestId("live-tab-agent-spec")).toBeNull();
expect(screen.getByTestId("live-tab-identity")).toBeDefined();
expect(screen.getByTestId("live-tab-startup")).toBeDefined();
});
@@ -137,18 +132,31 @@ describe("LiveNodeDetails", () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
await waitFor(() => {
expect(screen.getByTestId("live-node-details")).toBeDefined();
});
fireEvent.click(screen.getByTestId("live-tab-startup"));
fireEvent.click(await screen.findByTestId("live-tab-startup"));
await waitFor(() => {
expect(screen.getByTestId("live-startup-section")).toBeDefined();
expect(screen.getByTestId("live-node-status")).toBeDefined();
expect(screen.getByTestId("live-node-preview")).toBeDefined();
expect(screen.getByText(/role\.md/)).toBeDefined();
});
});
it("transcript and terminal tabs own their named content", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
fireEvent.click(await screen.findByTestId("live-tab-transcript"));
expect(await screen.findByTestId("detail-transcript")).toBeDefined();
fireEvent.click(screen.getByTestId("live-tab-terminal"));
const terminalShell = await screen.findByTestId("live-terminal-shell");
expect(terminalShell.className).toContain("bg-stone-950/65");
await waitFor(() => {
expect(screen.getByTestId("live-terminal-preview-pane").getAttribute("data-variant")).toBe("compact-terminal");
});
});
it("PL-019: full details shows activity and current qitems from node detail", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
+19 -6
View File
@@ -86,7 +86,7 @@ function makeDetail(overrides: Partial<SliceDetail> = {}): SliceDetail {
status: "active",
rawStatus: "active",
qitemIds: ["qitem-A", "qitem-B"],
commitRefs: [],
commitRefs: ["abc1234"],
lastActivityAt: "2026-05-06T18:00:00Z",
workflowBinding: null,
story: { events: [], phaseDefinitions: null },
@@ -96,12 +96,10 @@ function makeDetail(overrides: Partial<SliceDetail> = {}): SliceDetail {
percentage: 33,
items: [
{
line: 1,
rawLine: "[x] item one",
status: "done",
text: "item one",
path: "PROGRESS.md",
} as any,
done: true,
source: { file: "PROGRESS.md", line: 1 },
},
],
closureCallout: null,
currentStep: null,
@@ -161,10 +159,25 @@ describe("SliceScopePage P5-2 tab content piping", () => {
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-files']")).toBeTruthy();
expect(container.querySelector("[data-testid='slice-artifacts-commits']")).toBeTruthy();
expect(container.querySelector("[data-testid='slice-artifacts-docs']")).toBeTruthy();
expect(container.querySelector("[data-testid='slice-artifacts-decisions']")).toBeTruthy();
});
it("overview tab mounts a distinct summary surface rather than the docs browser", async () => {
const { container, findByTestId } = renderSliceScope({
sliceId: "idea-ledger",
detail: makeDetail(),
});
await findByTestId("project-tab-nav");
fireEvent.click(container.querySelector("[data-testid='project-tab-overview']")!);
expect(await findByTestId("slice-overview-tab")).toBeTruthy();
expect(container.querySelector("[data-testid='slice-overview-summary']")).toBeTruthy();
expect(container.querySelector("[data-testid='slice-overview-current-step']")).toBeTruthy();
expect(container.querySelector("[data-testid='slice-artifacts-docs']")).toBeNull();
});
it("queue tab lists qitemIds wrapped in QueueItemTrigger; click fires setSelection", async () => {
const { container, findByTestId, setSelection } = renderSliceScope({
sliceId: "idea-ledger",
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
describe("polish-8 demo-readiness source guards", () => {
it("LiveNodeDetails uses tab-owned body content and compact terminal preview", () => {
const src = read("../src/components/LiveNodeDetails.tsx");
expect(src).not.toContain("LiveIdentityDisplay");
expect(src).toContain("data-testid=\"live-node-tab-body\"");
expect(src).toContain("variant=\"compact-terminal\"");
expect(src).toContain("bg-stone-950/65");
});
it("StoryTab renders a newest-first connected step tree", () => {
const src = read("../src/components/slices/tabs/StoryTab.tsx");
expect(src).toContain("data-testid=\"story-step-tree\"");
expect(src).toContain("data-order=\"newest-first\"");
expect(src).toContain("story-step-connector");
expect(src).toContain("timestampSortValue(b.ts) - timestampSortValue(a.ts)");
});
it("Slice Overview and Artifacts are distinct surfaces", () => {
const src = read("../src/components/project/ScopePages.tsx");
expect(src).toContain("function SliceOverviewTab({ detail }");
expect(src).toContain("slice-overview-summary");
expect(src).toContain("function SliceArtifactsTab({ detail }");
expect(src).toContain("slice-artifacts-files");
expect(src).toContain("slice-artifacts-commits");
});
it("TopologyTab reaches the React Flow workflow graph instead of the adjacency panel", () => {
const topologySrc = read("../src/components/slices/tabs/TopologyTab.tsx");
const graphSrc = read("../src/components/slices/tabs/SliceWorkflowGraph.tsx");
expect(topologySrc).toContain("<SliceWorkflowGraph specGraph={specGraph} />");
expect(topologySrc).not.toContain("function SpecGraphPanel");
expect(graphSrc).toContain("ReactFlow");
expect(graphSrc).toContain("dagre.layout");
expect(graphSrc).toContain("RegistrationMarks");
});
});
+14
View File
@@ -79,6 +79,18 @@ describe("PL-slice-story-view-v1 StoryTab", () => {
render(<StoryTab events={events} phaseDefinitions={null} />);
expect(screen.getByTestId("story-row-phase-queue.created").textContent).toBe("step-x");
});
it("renders a newest-first connected step tree", () => {
const events = [
event({ kind: "old.event", ts: "2026-05-04T00:00:00.000Z", summary: "older" }),
event({ kind: "new.event", ts: "2026-05-04T01:00:00.000Z", summary: "newer" }),
];
render(<StoryTab events={events} phaseDefinitions={null} />);
expect(screen.getByTestId("story-step-tree").getAttribute("data-order")).toBe("newest-first");
const rows = screen.getAllByTestId(/story-row-/);
expect(rows[0]?.getAttribute("data-testid")).toBe("story-row-new.event");
expect(screen.getByTestId("story-step-connector-new.event")).toBeDefined();
});
});
// --- AcceptanceTab ---
@@ -182,6 +194,8 @@ describe("PL-slice-story-view-v1 TopologyTab", () => {
};
render(makeRouter(topologyShape(sg)));
await waitFor(() => expect(screen.getByTestId("topology-spec-graph")).toBeDefined());
expect(screen.getByTestId("topology-spec-graph").getAttribute("data-layout")).toBe("react-flow-dagre");
expect(screen.getByTestId("slice-workflow-graph")).toBeDefined();
expect(screen.getByTestId("topology-spec-name").textContent).toBe("test-loop");
// Per-step nodes rendered.
expect(screen.getByTestId("spec-node-discovery")).toBeDefined();