+ {/* Page header is kit-canonical "Chat / 6 threads…". When an
+ operator-console run is active we add a breadcrumb crumb
+ (e.g. "Chat / Operator-console run") so the page identity
+ stays consistent but the active context is clear. */}
-
- Chat
-
+
+
+ Chat
+
+ {activeFinRunId && (
+ <>
+ /
+
+
+ Operator-console run
+
+ >
+ )}
+
6 threads. Every turn keeps the entity context, sources, and report — so you can keep going without restarting.
@@ -2315,6 +2369,10 @@ export function ExactChatSurface() {
+ {/* Thread header stays the same in workspace mode — entity
+ icon, title, and meta belong to the thread context, not
+ to the operator-console swap. The Workspace · on toggle
+ in .nb-chat-header-actions is the only header change. */}
O
Orbital Labs · should I follow up?
@@ -2331,6 +2389,11 @@ export function ExactChatSurface() {
+ {/* Workspace toggle removed per design review: operator runs
+ are now an inline turn type (see .nb-stream-inner below).
+ Header reverts to the kit-canonical Open report / Share
+ pair. Runs are triggered from the composer chips or the
+ suggested-action chips below. */}
@@ -2355,9 +2418,31 @@ export function ExactChatSurface() {
+ {/* Chat turns ALWAYS render. Operator runs interleave: when
+ finRun is set, the run timeline appears as a synthetic
+ agent turn at the end of the thread. This restores the
+ kit's interleaved-content discipline (messages + sources
+ + match cards + operator runs all in one scroll). */}
{turns.map((turn) => (
))}
+ {activeFinRunId && (
+
+
+
+ Operator-console run
+
+
+
+
+ )}
@@ -2411,6 +2496,12 @@ export function ExactChatSurface() {
Claude Sonnet 4.5
+ {/* Capability indicators: text/image/pdf/audio/video/web/code/tools.
+ Sits on the existing composer next to the model trigger,
+ where the kit puts model metadata. Tooltips surface what
+ the active model can/can't accept (OpenRouter / pi-ai
+ modality pattern). */}
+
Memory-first · 0 paid calls
@@ -2426,12 +2517,58 @@ export function ExactChatSurface() {
+ {/* Suggested chips are reactive to thread + run state:
+ - Active run: post-run actions (Open evidence, Re-extract, Export memo)
+ - No run, entity thread: context-derived workflows for that entity
+ - No run, generic: kit-canonical Research / Capture / Ask chips
+ Clicking a workflow chip starts the operator run inline. */}
- {STREAM_PROMPTS.map((p) => (
-
- ))}
+ {(() => {
+ if (activeFinRunId) {
+ // Post-run actions — generic for now; future PR can read
+ // the run's payload to surface run-specific follow-ups
+ // (e.g. "Re-extract debt rate" if EXTRACTION had needs_review).
+ return ["Show evidence", "Re-run with tighter sources", "Export memo as PR", "Compare to peers"].map((p) => (
+
+ ));
+ }
+ // Context-derive workflows from the active thread entity.
+ // The Orbital Labs thread → Orbital-relevant prompts that
+ // route to financial workflows. Generic threads → kit-canonical.
+ const entitySlug = String(searchParams.get("entity") ?? "orbital").toLowerCase();
+ const isOrbital = entitySlug.includes("orbital");
+ const contextChips: { label: string; demo?: "att" | "crm" | "covenant" | "variance" }[] = isOrbital
+ ? [
+ { label: "Run cost-of-debt analysis", demo: "att" },
+ { label: "Compare to legal-tech peers" },
+ { label: "Check covenant compliance", demo: "covenant" },
+ { label: "Variance vs. plan", demo: "variance" },
+ ]
+ : [
+ { label: "Research a company" },
+ { label: "Capture an event note" },
+ { label: "Ask about a person" },
+ ];
+ return contextChips.map((c) => (
+
+ ));
+ })()}
diff --git a/src/features/designKit/exact/exactKit.css b/src/features/designKit/exact/exactKit.css
index b41180b85..401429ebf 100644
--- a/src/features/designKit/exact/exactKit.css
+++ b/src/features/designKit/exact/exactKit.css
@@ -4510,7 +4510,10 @@
border: 1px solid var(--border-subtle);
border-radius: 14px;
background: var(--bg-surface);
- overflow: hidden;
+ /* Removed `overflow: hidden` so position: sticky on .nb-stream-composer
+ can stick to the viewport bottom. The grid still defines the four
+ rows; sticky just keeps the composer pinned as the user scrolls
+ past the chat content. */
box-shadow: var(--shadow-sm);
}
@@ -4970,11 +4973,37 @@
background: color-mix(in oklab, var(--success) 10%, var(--bg-secondary));
}
-/* Composer */
+/* Composer — pinned at viewport bottom (kit-canonical chat composer).
+ Uses position: fixed because the chat surface itself doesn't fill
+ viewport, so sticky has no scroll context to engage. Fixed
+ guarantees the composer is always reachable; the scroll content
+ below picks up bottom padding so cards don't hide behind it.
+ Mobile (xl:hidden bottom nav) sits below this via the bottom offset. */
.nb-stream-composer {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: calc(56px + env(safe-area-inset-bottom, 0px));
+ z-index: 30;
padding: 10px 22px 14px;
- background: transparent;
+ background: var(--bg-surface, var(--bg-primary));
border-top: 1px solid var(--border-subtle);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+ box-shadow: 0 -8px 24px -16px rgba(15, 23, 42, 0.12);
+}
+@media (min-width: 1280px) {
+ /* xl: mobile bottom nav (xl:hidden) is gone — composer hugs
+ viewport bottom directly. */
+ .nb-stream-composer {
+ bottom: 0;
+ }
+}
+/* Reserve space at the bottom of the chat scroll so the last cards
+ aren't hidden behind the fixed composer. The composer is roughly
+ ~190px tall (pins + textarea + tools row + suggested chips). */
+.nb-stream-scroll {
+ padding-bottom: 220px;
}
.nb-stream-composer-inner {
max-width: 760px; margin: 0 auto;
diff --git a/src/features/financialOperator/components/ApprovalCard.tsx b/src/features/financialOperator/components/ApprovalCard.tsx
index 252f42440..6ab713e09 100644
--- a/src/features/financialOperator/components/ApprovalCard.tsx
+++ b/src/features/financialOperator/components/ApprovalCard.tsx
@@ -1,3 +1,20 @@
+/**
+ * ApprovalCard — kit match-card shape.
+ *
+ * Refactor: previous version was a 4-option 2x2 grid with full descriptions
+ * and consequence text — information-rich but visually heavy compared to
+ * the kit's match-card pattern (1 terracotta primary + 2 ghost actions
+ * in a single tight row, with reasoning hidden behind "Show evidence").
+ *
+ * New shape mirrors `nb-match-card` from ExactChatSurface (Confirm match /
+ * Keep separate / Show evidence):
+ * - Primary CTA in terracotta (the "approve" option)
+ * - Up to 2 ghost actions (the most important alternatives)
+ * - Any remaining options collapse behind "Show evidence" (or "More
+ * options" when there's no evidence to show)
+ * - Consequence text + descriptions live in the expand, not the row
+ */
+
import { useState } from "react";
import { useAction } from "convex/react";
import { api } from "../../../../convex/_generated/api";
@@ -16,14 +33,22 @@ const PRIMARY_VARIANTS: Array =
"approve",
];
-export function ApprovalCard({ runId, stepId, status, data, selectedOptionId }: Props) {
+export function ApprovalCard({
+ runId,
+ stepId,
+ status,
+ data,
+ selectedOptionId,
+}: Props) {
const recordDecision = useAction(
api.domains.financialOperator.orchestrator.recordApprovalDecision,
);
const [pendingId, setPendingId] = useState(null);
const [error, setError] = useState(null);
+ const [showEvidence, setShowEvidence] = useState(false);
- const isLocked = status === "approved" || status === "rejected" || status === "complete";
+ const isLocked =
+ status === "approved" || status === "rejected" || status === "complete";
async function handleClick(
optionId: ApprovalRequestPayload["options"][number]["id"],
@@ -40,67 +65,112 @@ export function ApprovalCard({ runId, stepId, status, data, selectedOptionId }:
}
}
+ // Partition options: primary first, then up to 2 ghosts, rest in expand.
+ const primary = data.options.find((o) => PRIMARY_VARIANTS.includes(o.id));
+ const others = data.options.filter((o) => !PRIMARY_VARIANTS.includes(o.id));
+ const ghosts = others.slice(0, 2);
+ const overflow = others.slice(2);
+
return (
-
{data.question}
- {data.context && (
-
{data.context}
- )}
+
+ {data.question}
+
-
- {data.options.map((opt) => {
- const isPrimary = PRIMARY_VARIANTS.includes(opt.id);
- const isSelected = selectedOptionId === opt.id;
- const isPending = pendingId === opt.id;
- const consequence = data.consequences?.[opt.id];
- return (
-
- );
- })}
+ {/* Primary + ghost row — kit match-card shape */}
+
+ {primary && (
+
+ )}
+ {ghosts.map((opt) => (
+
+ ))}
+
+ {/* Evidence expand — context, consequences, overflow options */}
+ {showEvidence && (
+
+ {data.context && (
+
+ {data.context}
+
+ )}
+ {data.consequences && Object.keys(data.consequences).length > 0 && (
+
+ {Object.entries(data.consequences).map(([id, msg]) => (
+ -
+
+ {id}
+
+ → {msg}
+
+ ))}
+
+ )}
+ {overflow.length > 0 && (
+
+ {overflow.map((opt) => (
+
+ ))}
+
+ )}
+
+ )}
+
{error && (
-
+
{error}
)}
{isLocked && (
-
+
Decision recorded — this step is locked.
)}
diff --git a/src/features/financialOperator/components/ArtifactCard.tsx b/src/features/financialOperator/components/ArtifactCard.tsx
index 2e4fb3cea..68bf2ec42 100644
--- a/src/features/financialOperator/components/ArtifactCard.tsx
+++ b/src/features/financialOperator/components/ArtifactCard.tsx
@@ -52,7 +52,7 @@ export function ArtifactCard({ data }: Props) {
href={data.url}
target="_blank"
rel="noreferrer noopener"
- className="inline-flex items-center gap-1 rounded border border-edge bg-surface/50 px-2.5 py-1 text-[12px] text-content hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-[#d97757]/50 focus-visible:outline-none"
+ className="inline-flex items-center gap-1 rounded border border-edge bg-surface/50 px-2.5 py-1 text-[12px] text-content hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]/50 focus-visible:outline-none"
>
Open artifact
diff --git a/src/features/financialOperator/components/CalculationCard.tsx b/src/features/financialOperator/components/CalculationCard.tsx
index d01542679..005bae4e7 100644
--- a/src/features/financialOperator/components/CalculationCard.tsx
+++ b/src/features/financialOperator/components/CalculationCard.tsx
@@ -73,7 +73,7 @@ function KVList({
return (
{k}
Open source
diff --git a/src/features/financialOperator/components/FinancialOperatorOverlay.tsx b/src/features/financialOperator/components/FinancialOperatorOverlay.tsx
new file mode 100644
index 000000000..d30891b77
--- /dev/null
+++ b/src/features/financialOperator/components/FinancialOperatorOverlay.tsx
@@ -0,0 +1,180 @@
+/**
+ * FinancialOperatorOverlay — global, surface-agnostic drawer that mounts
+ * the typed-card timeline next to whatever chat surface the user is on.
+ *
+ * Why a global overlay vs editing FastAgentPanel directly:
+ * - FastAgentPanel.tsx is 3700+ lines; surgical message-bubble edits
+ * have a high blast radius
+ * - URL-param driven state means any surface that wants to "host" a
+ * financial run just needs to set `?finRun=` in the URL
+ * - Closing/expanding is local to the overlay; chat scroll, agent
+ * panel state, etc are untouched
+ *
+ * Lifecycle:
+ * 1. User triggers a financial run anywhere (chip, button, MCP tool)
+ * 2. URL is updated to include `?finRun=`
+ * 3. This overlay listens, mounts the timeline as a right-side drawer
+ * 4. User can close (clears the param) or keep it docked while chatting
+ *
+ * The fixture demo view (/finance-demo) and this overlay use the SAME
+ * `FinancialOperatorTimeline` component — single source of truth.
+ */
+
+import { useState, useEffect, useCallback } from "react";
+import { ChevronRight, X, Maximize2, Minimize2 } from "lucide-react";
+import type { Id } from "../../../../convex/_generated/dataModel";
+import { FinancialOperatorTimeline } from "./FinancialOperatorTimeline";
+import { ModelCapabilityBadge } from "./ModelCapabilityBadge";
+
+const OVERLAY_MODEL = "claude-opus-4-7";
+
+const URL_PARAM = "finRun";
+const STORAGE_KEY = "nb-fin-run-collapsed";
+
+function readRunIdFromUrl(): Id<"financialOperatorRuns"> | null {
+ if (typeof window === "undefined") return null;
+ const params = new URLSearchParams(window.location.search);
+ const v = params.get(URL_PARAM);
+ return v && v.length > 0 ? (v as Id<"financialOperatorRuns">) : null;
+}
+
+function clearRunFromUrl() {
+ if (typeof window === "undefined") return;
+ const url = new URL(window.location.href);
+ url.searchParams.delete(URL_PARAM);
+ window.history.replaceState({}, "", url.toString());
+ // Notify listeners (popstate doesn't fire on replaceState).
+ window.dispatchEvent(new PopStateEvent("popstate"));
+}
+
+export function FinancialOperatorOverlay() {
+ const [runId, setRunId] = useState | null>(null);
+ const [workspaceMode, setWorkspaceMode] = useState(false);
+ const [collapsed, setCollapsed] = useState(() => {
+ try {
+ return localStorage.getItem(STORAGE_KEY) === "1";
+ } catch {
+ return false;
+ }
+ });
+
+ // Sync runId + workspace mode with URL (initial mount + popstate).
+ // When workspace mode is active, WorkspaceModePane handles the timeline
+ // — the side-drawer overlay should defer to it to avoid double-render.
+ useEffect(() => {
+ const sync = () => {
+ setRunId(readRunIdFromUrl());
+ if (typeof window !== "undefined") {
+ setWorkspaceMode(
+ new URLSearchParams(window.location.search).get("ws") === "1",
+ );
+ }
+ };
+ sync();
+ window.addEventListener("popstate", sync);
+ return () => window.removeEventListener("popstate", sync);
+ }, []);
+
+ // Persist collapse state
+ useEffect(() => {
+ try {
+ localStorage.setItem(STORAGE_KEY, collapsed ? "1" : "0");
+ } catch {
+ /* private mode */
+ }
+ }, [collapsed]);
+
+ const handleClose = useCallback(() => {
+ clearRunFromUrl();
+ setRunId(null);
+ }, []);
+
+ // Skip the side drawer when we're on the chat surface — ExactChatSurface
+ // renders the operator-console timeline inline as a synthetic agent
+ // turn, so the side drawer would double-render the same run.
+ // Also skip on the standalone /finance-demo route for the same reason.
+ if (typeof window !== "undefined") {
+ const search = window.location.search;
+ const path = window.location.pathname;
+ const onChat = /[?&]surface=workspace(\b|&|$)/.test(search);
+ const onDemoRoute =
+ path.startsWith("/finance-demo") ||
+ path.startsWith("/financial-operator") ||
+ path.startsWith("/finops");
+ if (onChat || onDemoRoute) return null;
+ }
+ // Legacy: defer when the old workspace-mode pane is active too.
+ if (workspaceMode) return null;
+ if (!runId) return null;
+
+ // Collapsed pill — small chip in the bottom-right corner.
+ if (collapsed) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+/**
+ * Helper for callers (chips, buttons, agent dispatchers): set the active
+ * run in the URL so the overlay mounts. Any caller that has a runId can
+ * invoke this — no direct dependency on FastAgentPanel internals.
+ */
+export function setActiveFinancialRun(
+ runId: Id<"financialOperatorRuns"> | string,
+) {
+ if (typeof window === "undefined") return;
+ const url = new URL(window.location.href);
+ url.searchParams.set(URL_PARAM, String(runId));
+ window.history.replaceState({}, "", url.toString());
+ window.dispatchEvent(new PopStateEvent("popstate"));
+}
diff --git a/src/features/financialOperator/components/ModelCapabilityBadge.tsx b/src/features/financialOperator/components/ModelCapabilityBadge.tsx
new file mode 100644
index 000000000..aa1a4a5bc
--- /dev/null
+++ b/src/features/financialOperator/components/ModelCapabilityBadge.tsx
@@ -0,0 +1,217 @@
+/**
+ * ModelCapabilityBadge — single info pill with popover for full modality matrix.
+ *
+ * Per design review: 8 always-visible capability icons next to the model
+ * trigger competed visually with the rest of the composer. The kit's
+ * discipline is one icon per pill, progressive disclosure for detail.
+ *
+ * Replacement: a tiny `i` info indicator next to the model name. Click
+ * (or focus) opens a popover that lists supported + unsupported
+ * modalities with the same icons + descriptions. Tooltips on always-on
+ * icons are an anti-pattern — the better fix is fewer icons, not more
+ * tooltips.
+ *
+ * Pattern borrowed from open-source unified routers (OpenRouter, pi-ai,
+ * LibreChat) which expose `architecture.input_modalities` per model.
+ * Curated registry below covers the models NodeBench routes today;
+ * unknown models fall back to text-only with `(unverified)` (HONEST_SCORES).
+ */
+
+import { useState, useEffect, useRef } from "react";
+import {
+ Code2,
+ FileText,
+ Globe,
+ Image as ImageIcon,
+ Info,
+ Mic,
+ Type,
+ Video,
+ Wrench,
+} from "lucide-react";
+
+export type ModelCapability =
+ | "text"
+ | "image"
+ | "pdf"
+ | "audio"
+ | "video"
+ | "web_search"
+ | "code_exec"
+ | "tools";
+
+interface CapabilityMeta {
+ icon: typeof Type;
+ shortLabel: string;
+ description: string;
+}
+
+const META: Record = {
+ text: { icon: Type, shortLabel: "Text", description: "Reads and writes text natively." },
+ image: { icon: ImageIcon, shortLabel: "Image", description: "Accepts image inputs (vision-language model)." },
+ pdf: { icon: FileText, shortLabel: "PDF / files", description: "Accepts PDF documents directly without external parsing." },
+ audio: { icon: Mic, shortLabel: "Audio", description: "Transcribes or reasons over audio input." },
+ video: { icon: Video, shortLabel: "Video", description: "Reads video frames as input." },
+ web_search: { icon: Globe, shortLabel: "Web search", description: "Native web grounding / citations." },
+ code_exec: { icon: Code2, shortLabel: "Code", description: "Executes code in a sandbox." },
+ tools: { icon: Wrench, shortLabel: "Tools", description: "Function / tool calling with structured output." },
+};
+
+const ALL_CAPABILITIES: ModelCapability[] = [
+ "text",
+ "image",
+ "pdf",
+ "audio",
+ "video",
+ "web_search",
+ "code_exec",
+ "tools",
+];
+
+export const MODEL_CAPABILITIES: Record = {
+ // Anthropic
+ "claude-opus-4-7": ["text", "image", "pdf", "tools"],
+ "claude-sonnet-4-6": ["text", "image", "pdf", "tools"],
+ "claude-haiku-4-5": ["text", "image", "pdf", "tools"],
+ // OpenAI
+ "gpt-5": ["text", "image", "tools"],
+ "gpt-4.1": ["text", "image", "tools"],
+ "gpt-4o": ["text", "image", "audio", "tools"],
+ "o1": ["text", "tools"],
+ "o3": ["text", "tools"],
+ // Google
+ "gemini-3-pro": ["text", "image", "pdf", "audio", "video", "tools"],
+ "gemini-3-flash": ["text", "image", "pdf", "audio", "video", "tools"],
+ "gemini-2.5-flash": ["text", "image", "pdf", "audio", "video", "tools"],
+ // xAI
+ "grok-4": ["text", "image", "tools"],
+ // Open weights via OpenRouter
+ "kimi-k2.6": ["text", "tools"],
+ "deepseek-v3.5": ["text", "tools"],
+ "glm-4.6v": ["text", "image", "tools"],
+};
+
+export function getCapabilitiesForModel(model: string | undefined | null): {
+ capabilities: ModelCapability[];
+ isKnown: boolean;
+} {
+ if (!model) return { capabilities: ["text"], isKnown: false };
+ const normalized = model.toLowerCase();
+ for (const [key, caps] of Object.entries(MODEL_CAPABILITIES)) {
+ if (normalized.includes(key.toLowerCase())) {
+ return { capabilities: caps, isKnown: true };
+ }
+ }
+ return { capabilities: ["text"], isKnown: false };
+}
+
+interface Props {
+ model: string;
+ /** Override the capability set explicitly (e.g. for OpenRouter responses). */
+ capabilities?: ModelCapability[];
+ className?: string;
+}
+
+/**
+ * Renders as a single 16px `i` info button with subtle hover. Click
+ * opens a popover anchored to the button. The popover lists the model
+ * name + verified-state, then the modality matrix (supported in
+ * terracotta, unsupported in muted line-through).
+ */
+export function ModelCapabilityBadge({ model, capabilities, className }: Props) {
+ const [open, setOpen] = useState(false);
+ const containerRef = useRef(null);
+
+ const resolved = capabilities ?? getCapabilitiesForModel(model).capabilities;
+ const isKnown = capabilities !== undefined || getCapabilitiesForModel(model).isKnown;
+ const supported = new Set(resolved);
+
+ // Close on outside click + Escape
+ useEffect(() => {
+ if (!open) return;
+ const onDoc = (e: MouseEvent) => {
+ if (!containerRef.current) return;
+ if (!containerRef.current.contains(e.target as Node)) setOpen(false);
+ };
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") setOpen(false);
+ };
+ document.addEventListener("mousedown", onDoc);
+ document.addEventListener("keydown", onKey);
+ return () => {
+ document.removeEventListener("mousedown", onDoc);
+ document.removeEventListener("keydown", onKey);
+ };
+ }, [open]);
+
+ return (
+
+
+ {open && (
+
+
+
+ {model}
+
+ {!isKnown && (
+
+ unverified
+
+ )}
+
+
+ {ALL_CAPABILITIES.map((cap) => {
+ const meta = META[cap];
+ const Icon = meta.icon;
+ const isSupported = supported.has(cap);
+ return (
+ -
+
+
+
+
+
+ {meta.shortLabel}
+ {" "}
+ — {isSupported ? meta.description : "Not supported by this model."}
+
+
+ );
+ })}
+
+
+ Modality matrix from curated registry. Unknown models default to text-only (HONEST_SCORES).
+
+
+ )}
+
+ );
+}
diff --git a/src/features/financialOperator/components/ResultCard.tsx b/src/features/financialOperator/components/ResultCard.tsx
index ae2731343..aa5bacbc9 100644
--- a/src/features/financialOperator/components/ResultCard.tsx
+++ b/src/features/financialOperator/components/ResultCard.tsx
@@ -54,7 +54,7 @@ export function ResultCard({ data }: Props) {
diff --git a/src/features/financialOperator/components/StepShell.tsx b/src/features/financialOperator/components/StepShell.tsx
index d6b0ed583..d8866ee8e 100644
--- a/src/features/financialOperator/components/StepShell.tsx
+++ b/src/features/financialOperator/components/StepShell.tsx
@@ -1,3 +1,17 @@
+/**
+ * StepShell — common chrome around every typed step card.
+ *
+ * Built on the kit's `.nb-panel` + type utility classes (per
+ * docs/architecture/FINANCIAL_OPERATOR_DESIGN_ALIGNMENT.md):
+ *
+ * - Outer container: `.nb-panel` (12px radius, 1px hairline, panel bg)
+ * - Sequence number + kind label: `.type-kicker` (kit canonical kicker)
+ * - Title: `.type-card-title`
+ * - Footer meta: `.type-caption`
+ * - Left accent stripe per kind keeps cards visually distinguishable
+ * without inventing new card chrome
+ */
+
import type { ReactNode } from "react";
import type { StepKind, StepStatus } from "../types";
import { StepStatusBadge } from "./StepStatusBadge";
@@ -14,16 +28,20 @@ const KIND_LABEL: Record = {
result: "Result",
};
-const KIND_ACCENT: Record = {
- run_brief: "border-l-blue-400/50",
- tool_call: "border-l-slate-400/40",
- extraction: "border-l-purple-400/50",
- validation: "border-l-cyan-400/50",
- calculation: "border-l-emerald-400/50",
- evidence: "border-l-indigo-400/50",
- artifact: "border-l-amber-400/50",
- approval_request: "border-l-[#d97757]",
- result: "border-l-emerald-400",
+/**
+ * Left accent stripe per kind. Resolves through CSS vars + color-mix so
+ * the stripe is on-brand in both light and dark themes.
+ */
+const KIND_STRIPE: Record = {
+ run_brief: "before:bg-[color:color-mix(in_oklab,var(--brand-indigo,#5E6AD2)_55%,transparent)]",
+ tool_call: "before:bg-[color:var(--text-muted)]",
+ extraction: "before:bg-[color:color-mix(in_oklab,var(--accent-primary)_45%,var(--brand-indigo,#5E6AD2)_25%)]",
+ validation: "before:bg-[color:color-mix(in_oklab,var(--brand-indigo,#5E6AD2)_50%,transparent)]",
+ calculation: "before:bg-[color:var(--success,#047857)]",
+ evidence: "before:bg-[color:color-mix(in_oklab,var(--brand-indigo,#5E6AD2)_55%,transparent)]",
+ artifact: "before:bg-[color:var(--warning,#B45309)]",
+ approval_request: "before:bg-[color:var(--accent-primary)]",
+ result: "before:bg-[color:var(--success,#047857)]",
};
interface StepShellProps {
@@ -36,16 +54,6 @@ interface StepShellProps {
children: ReactNode;
}
-/**
- * Common chrome around every step card:
- * - Sequence number (1, 2, 3…)
- * - Kind label (Plan / Tool / Extraction / …)
- * - Status badge
- * - Title + body
- * - Optional duration + error footer
- *
- * Accent stripe on the left differentiates kinds at a glance.
- */
export function StepShell({
kind,
status,
@@ -57,29 +65,37 @@ export function StepShell({
}: StepShellProps) {
return (
-
+
#{(seq + 1).toString().padStart(2, "0")}
-
- {KIND_LABEL[kind]}
-
- {title}
+ {KIND_LABEL[kind]}
+ {title}
- {children}
+ {children}
{(durationMs !== undefined || errorMessage) && (
-