mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
Polish slice detail demo surfaces
This commit is contained in:
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 "{agentName}")
|
||||
</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 "{agentName}")
|
||||
</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">-></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"><-</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>
|
||||
|
||||
@@ -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">-></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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user